diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index ebcd1f65e..43db914e2 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -11,14 +11,15 @@ The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, E - Shared logic comes from `@debatelab/core` (`packages/core`). Stores, the AI client and other `src/` wiring stay in the website. - Strings come from the website's catalogs in `src/locales/{en,es,hu}.json`. App-only strings go under `mobile.*`, in all three languages. - The Supabase project URLs and anon keys live once, in `src/lib/supabase/projects.ts` at the repo root; `src/lib/supabase-config.ts` imports them. The app uses staging unless a build sets `EXPO_PUBLIC_SUPABASE_ENV=production`. -- Google sign-in needs a development build, because Expo Go cannot receive OAuth redirects. Until then, sign-in is email code or demo code. +- Sign-in is email code and demo code everywhere; Google adds itself in development builds and Sign in with Apple in builds signed by DebateOwl's own Apple account (`social-sign-in.ts`, below). Expo Go cannot receive OAuth redirects. +- What needs DebateOwl's own Apple account (Sign in with Apple, push, Time Sensitive alerts, Handoff from Safari) turns on with `DEBATELAB_APPLE_ACCOUNT=1` in a development build. [docs/apple-platform.md](../../docs/apple-platform.md) is the account checklist and the map of the Apple integrations. - Native code has no DOM. eslint rejects `window`, `document`, `localStorage`, `sessionStorage` and `location` outside `*.web.ts(x)` files. A `.web.tsx` file exists only for the web preview. - `ios/` and `android/` are generated and gitignored. Configure native behaviour in `app.config.ts`; without `DEBATELAB_BUILD` it must stay exactly the Expo Go config (compare `npx expo config --json` before and after). `eas.json` holds the one `development` profile; signing comes from a gitignored `credentials.json` written on build day. - A form sheet's root view is `SheetScrollView` (`src/components/sheet.tsx`), with nothing around it: no `KeyboardAvoidingView`, no wrapping `View`. react-native-screens positions only a scroll view that is the sheet's direct child; a nested one lands below the visible sheet and the sheet looks empty. ## Layout -- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `add-file.tsx` (Import file), `source/[id].tsx`, `edit-debate.tsx` (Edit settings), `debate-check.tsx` (the five-day check), `limit.tsx` (the website's limit-reached and model-restricted dialogs), `save-case.tsx` (Save case with a title), `context-review.tsx` (the debate context Review) and `welcome.tsx` (Ollie's welcome and the first-run tour); `share/[code].tsx`, the read-only viewer for a share link, outside the sign-in guard; `+native-intent.tsx`, which maps website links to app routes; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/`, `factoids/` and `sparring/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx`, `crossfire-round/[id].tsx` (a saved round, read only), `timer.tsx` (a full-screen modal) with its `timer-format.tsx` sheet (a custom format), `speech-time.tsx` and `motions.tsx` (the website's speech time calculator and motion generator), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `add-file.tsx` (Import file), `source/[id].tsx`, `edit-debate.tsx` (Edit settings), `debate-check.tsx` (the five-day check), `limit.tsx` (the website's limit-reached and model-restricted dialogs), `plans.tsx` (Basic and Pro through the App Store), `save-case.tsx` (Save case with a title), `context-review.tsx` (the debate context Review) and `welcome.tsx` (Ollie's welcome and the first-run tour); `share/[code].tsx`, the read-only viewer for a share link, outside the sign-in guard; `+native-intent.tsx`, which maps website links to app routes; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/`, `brainstorm/`, `factoids/` and `sparring/` (each a form `index.tsx` and a document `[id].tsx`), `crossfire.tsx`, `crossfire-round/[id].tsx` (a saved round, read only), `timer.tsx` (a full-screen modal) with its `timer-format.tsx` sheet (a custom format), `speech-time.tsx` and `motions.tsx` (the website's speech time calculator and motion generator), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, full screen above the tabs. All are declared in the root `_layout.tsx`. Every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `modules/`: the app's own Expo modules, autolinked into development builds only. `key-commands` is the iPad's UIKeyCommand shortcuts (Swift, `DebateOwlKeyCommands`). - Lists that grow without bound (History, the Library, saved chats) are `ListScreen` (`components/list-screen.tsx`): `Screen`'s column and offline notice around a Reanimated FlatList, so only rows near the screen are drawn, with pull to refresh. A list drawn as one card frames each row with `CardRowFrame`, and the list slides rows into a removed row's gap (`itemLayoutAnimation`). A row fades up (`ListItemIn`) only when it arrives in the data, not when it scrolls back into view (`useRowArrivals`, `lib/row-arrivals.ts`). Not FlashList: it recycles cells, and these rows hold swipe, press and link-preview state. Debates, a round's speeches and other lists bounded by their format stay plain views. @@ -35,27 +36,34 @@ The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, E - A case's Regenerate and Undo are `case-edits.ts`: one part is replaced from a new generation with the website's merge (`mergeRegeneratedSection` in `packages/core/src/ai/generate-output.ts`), earlier versions are kept for the session, and Undo writes the restored version back to the saved row (`updateResult`). Held results are observable (`subscribeOpenResults`), so the document shows a change made from its menu or its sheets. - Ask's Stop keeps the answer so far, marked as stopped. Its General and Clarify modes go in the request; Clarify and the options `ai-stream` refuses stream live from the `ai` function through `postAI`. An answer saves to the Current Debate's `Ask` folder. Attachments (`ask-attachments.ts`, expo-document-picker, in Expo Go) are read on the phone (`read-file.ts`) and sent in the website's format (`packages/core/src/ai/ask-files.ts`). - A case's and an attack's next steps are `result-links.ts`: "Open in Crossfire" fills the next Crossfire form through the one-shot `crossfirePrefill` slot (the website's route state; the builders are `packages/core/src/crossfire/prefill.ts`), and a case is built into an unsaved map with `flowchartFromCase` (`packages/core/src/flowchart.ts`, shared with the website), only in the debate the Flowchart is on, as on the website. -- Limits mirror the website's subscription dialogs: `refusal.ts` turns a 402 or 403 refusal (with the limit details `errorFrom` keeps) into the `limit` sheet's parameters and holds the website's messages (`limit-message.ts`); the tools open the sheet instead of an inline error, and Ask, Crossfire and Sparring open it beside theirs. Upgrading waits for in-app purchase, so the sheet's next step is the plan in You. `usage.ts` reads today's uses for the line under each tool's title (`DailyUsageLine`, the website's DailyUsage) and reads again after every AI request (`onAIRequestSettled`). `session-quota.ts` asks what a Crossfire or Sparring round costs (`quotaCheck`), shows it above Start (`SessionCostNote`) and opens the sheet before a round the day cannot cover. +- Limits mirror the website's subscription dialogs: `refusal.ts` turns a 402 or 403 refusal (with the limit details `errorFrom` keeps) into the `limit` sheet's parameters and holds the website's messages (`limit-message.ts`); the tools open the sheet instead of an inline error, and Ask, Crossfire and Sparring open it beside theirs. Where the website offers Pro, the sheet's next step is `plans.tsx` in a build with StoreKit, else the plan in You. `usage.ts` reads today's uses for the line under each tool's title (`DailyUsageLine`, the website's DailyUsage) and reads again after every AI request (`onAIRequestSettled`). `session-quota.ts` asks what a Crossfire or Sparring round costs (`quotaCheck`), shows it above Start (`SessionCostNote`) and opens the sheet before a round the day cannot cover. - Timer mirrors the website's free round timer (`src/components/free-tools/DebateTimer.tsx`), not the in-app TimerPage: the seven formats with their speeches, points-of-information windows, prep banks, overtime and grace come from `packages/core/src/free-tools/` (moved there from `src/lib/free-tools/`, which re-exports them; the custom format's URL codec stays in the website). `timer.ts` holds the session (format choice, round, mute), its stored form and the Live Activity's times (`timer.test.ts`); `timer-store.ts` keeps it and the custom formats saved on the device in AsyncStorage, so a running speech survives closing the timer and restarting the app. `components/timer-runner.tsx`, in the root layout, sounds the cues (a knock when POIs open or close, a beep at one minute left, two at time; after a gap in the background only time up) and stops an exhausted prep bank, with signals mounted only while a clock runs, and keeps the Live Activity in step. `timer-signals.ts` plays the bundled sounds in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while the timer screen shows a running clock; `timer-signals.web.ts` only records the signals for the screenshot script. -- The Lock Screen timer: `src/widgets/timer-widgets.tsx` holds the Live Activity (`DebateTimerActivity`: Lock Screen banner and Dynamic Island) and the lock-screen widget (`DebateTimerWidget`, the `widgets[]` entry in `app.config.ts`), drawn by iOS from SwiftUI timer texts so they count without the app. Their functions run in the extension's own runtime ('widget' directive): no imports or outside names, every string in the props. `live-activity.ts` loads them only when `nativeCapabilities().liveActivity` (a development build with `DEBATELAB_APP_GROUP`), takes over or ends an activity left by an earlier launch, and ends the activity when no clock has time on it; `live-activity.web.ts` records the state for the screenshot script. Round 4's Home Screen widget adds its own entry to the same `widgets[]` array. +- The Lock Screen timer: `src/widgets/timer-widgets.tsx` holds the Live Activity (`DebateTimerActivity`: Lock Screen banner and Dynamic Island) and the lock-screen widget (`DebateTimerWidget`, the `widgets[]` entry in `app.config.ts`), drawn by iOS from SwiftUI timer texts so they count without the app. Their functions run in the extension's own runtime ('widget' directive): no imports or outside names, every string in the props. `live-activity.ts` loads them only when `nativeCapabilities().liveActivity` (a development build with `DEBATELAB_APP_GROUP`), takes over or ends an activity left by an earlier launch, and ends the activity when no clock has time on it; `live-activity.web.ts` records the state for the screenshot script. Round 4's Home Screen widget adds its own entry to the same `widgets[]` array. From iOS 17 the activity has Pause or Resume and Next speech buttons with fixed targets (`timer-toggle`, `timer-next`): iOS runs them in the app's process, `live-activity.ts` hears them through `addUserInteractionListener` and applies `applyTimerActivityAction` (`timer.ts`) to the stored round, and `TimerRunner` sends the new clock back. A prep bank paused while it ran stays on the Lock Screen as the session's `held` bank, so Resume restarts it; `settleHeld` keeps `held` in step on every change (`updateSession`), from either screen's buttons. `timer-alerts.ts` sets a local, time-sensitive notification for the moment the running clock runs out (`timerAlertAt`) whenever the app is off screen, because the app's own signals stop when iOS suspends it; it asks for permission the first time a clock starts. +- The generating Live Activity (`widgets/generating-activity.tsx`, `generating-activity.ts`, the words in `generating-activity-state.ts`): `useLiveRun({ tool, parts })` in Generate, Attack, Brainstorm and Factoids starts it with the run's motion, updates the step and the parts written, `complete()` marks it ready and `end('failed')` failed. Ollie's poses are copied into the App Group folder by `widget-images.ts`, which the Current Debate widget uses too. When the app leaves the screen the activity is given a stale date 30 seconds later, and the layout then says to open DebateOwl, because the stream stops while iOS suspends the app; the answer comes back from `ai_run_results` when it opens. A finished or failed run stays on the Lock Screen for 15 minutes when the app is away. Every live run also sends `notifyWhenReady` with this install's id (`setReadyPushInstall`, set at launch), so the `ai` function pushes `result_ready` to this phone once the answer is stored; `push.ts` hides that push while the app is on screen. - Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines), with the round's id as `quotaSession` on turns and the debrief so the round is charged once, as the website does and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. A saved round opens read only from History and the Debate tab (`crossfire-round/[id].tsx`, `fetchCrossfireRound`, with an offline copy): debrief, spoken delivery and transcript. The website lists saved rounds but cannot open them. - Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them and exports the website's Sparring PDF (`sparringExportHtml`; Word export is not in the app). - Voice rounds mirror the website's (`src/hooks/use-speech-capture.ts`, `src/lib/voice/`): `speech-recognition.ts` reaches `expo-speech-recognition` only in a build that has it (never in Expo Go); `speech-capture.ts` is the capture state machine (restart when iOS ends a request, three quick failures end it, a stop waits up to 1.5 s for the last words, long pauses timed; `speech-capture.test.ts`), and `use-speech-capture.ts` its hook. Words fill the draft, and the turn's delivery is measured with `packages/core/src/voice/delivery.ts` on submit. Turns send `voice: true` beside `input` and read the signed `voice` segments (`parseVoicePayload`); `opponent-voice.ts` plays them from the `voice` function with expo-audio and falls back to expo-speech, and `voice-availability.ts` knows when voice cannot run and remembers the first-use notice. `components/voice.tsx` has the switch with the notice (the website's wording, with Apple's recognizer in place of the browser), the microphone control, the opponent bar, delivery facts and the dictation button that Generate's motion and Ask's question use. In Expo Go the switch stays off with the note and dictation does not show. The `.web.ts` versions fake the recognizer from `window.__debatelabFakeSpeech` and only record the opponent's replies, for the screenshot script. - Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`). - History mirrors `src/pages/HistoryPage.tsx` and `UnsortedWork.tsx`: `history.ts` reads each table for one debate, or every kind plus Library sources outside all debates, and adds unsorted work to a debate through the website's `attach_to_debate` function. Chats reopen in Ask (`/ask?chat=`). A Sparring round opens `sparring/[id]` and swipes to delete as a case does (`deleteSparringRound`); an unsorted round is added to a debate with the side the student spoke, through `attachToDebate`, which also checks the signed-in account. - Announcements mirror the website's banner (`AppLayout.tsx`, `AnnouncementBanner.tsx`) at the top of the Debate tab: `announcements.ts` lists and dismisses through the `announcements` Edge Function, and `use-announcements.ts` keeps the website's startsAt/expiresAt filter (shared in `packages/core/src/announcements.ts`), its one-minute reload guard, and reloads on focus, foreground and account change. Dismissals are stored on the server, so they are shared with the website. -- You mirrors the website's Settings for what the app can do: Download my data (`exportAccountData` in `account.ts`, the `account` function's export written as `debatelab-data-.json` and shared through `shareJson`), Delete all my data (`delete-data.tsx`, the email-typed confirmation and `clear_current_user_data`; afterwards session memory is forgotten, one-off mode turns off and the debates reload, as the website clears its stores), and Terms and Privacy, opened in the in-app browser from `app.config.ts`'s `extra.websiteUrl` (`website.ts`). The plan card shows the website's billing states, dates and wording (`SubscriptionStatus.tsx`): `subscription.ts` reads `user_settings`, Stripe `subscriptions` and `app_store_subscriptions` through the shared reader (`packages/core/src/supabase/billing.ts`, via `billing-query.ts`) and resolves them with the resolver the AI functions use (`supabase/functions/_shared/billing-state.ts`); usage is the server's own counter (`ai_usage_today`). Upgrade, Manage subscription and promo codes wait for in-app purchase. +- You mirrors the website's Settings for what the app can do: Download my data (`exportAccountData` in `account.ts`, the `account` function's export written as `debatelab-data-.json` and shared through `shareJson`), Delete all my data (`delete-data.tsx`, the email-typed confirmation and `clear_current_user_data`; afterwards session memory is forgotten, one-off mode turns off and the debates reload, as the website clears its stores), and Terms and Privacy, opened in the in-app browser from `app.config.ts`'s `extra.websiteUrl` (`website.ts`). The plan card shows the website's billing states, dates and wording (`SubscriptionStatus.tsx`): `subscription.ts` reads `user_settings`, Stripe `subscriptions` and `app_store_subscriptions` through the shared reader (`packages/core/src/supabase/billing.ts`, via `billing-query.ts`) and resolves them with the resolver the AI functions use (`supabase/functions/_shared/billing-state.ts`); usage is the server's own counter (`ai_usage_today`). Where the build has StoreKit, the card's See plans opens `plans.tsx`. - One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, Factoids, Crossfire and Sparring forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. - Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts`, `sparring.ts` and `history.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats, judged Sparring rounds and History's lists) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` (including its iPad outline rail), the Flowchart and the iPad Library list until each copy shown is read from the server again or the screen showing it closes, moves on or leaves view (a screen passes `useCopyHolder().mark()` with its reads). A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy; a read made of several queries runs them through `readAll` (or `pickFailure`), so one query's refusal wins over another's missing answer. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. AI and saving results fail offline with their network errors. New notes, source edits and Active switches go through `offline-queue.ts` instead (`use-offline-queue.ts` starts it in the root layout): kept on the phone under the user id, replayed in order on launch, reconnect, foreground and every 30 seconds while anything waits, merged per source, dropped and reported when the server refuses them, and shown as "Waiting to sync" meanwhile. A queued note has a `local:` id until it syncs. A read added to those modules goes through `cachedRead`, takes an optional `CopyMark`, and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists; a delete uses `deletedListScopes`, which drops every debate's when no row came back), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. `debateListScopes` also names History's copy (`one-off` for work outside every debate); History's add-to-debate and filing a judged Sparring round into its debate both drop the item's copies on both sides, and Delete all my data drops every copy (`ALL_COPIES`). - Push: `push.ts` asks for permission and files the Expo push token under the signed-in user with the `register_push_token` RPC; the You tab's Notifications switch turns it on and off, the root layout registers again on launch and when the language changes, and sign-out releases the registration with the account's own access token, after any registration still in flight. Every registration carries the ID of the local record, and `release_push_token` withdraws that ID and deletes only the rows it wrote, so a registration the app gave up on is refused if it reaches the database after the release. Once a release is sent for an ID, the record is marked released and the next registration gets a new ID, so a late release cannot delete it. The local record is cleared only once a release succeeds; otherwise the next change on the phone, from any account, releases it first. The server side is in [supabase/README.md](../../supabase/README.md#push-notifications). `push.web.ts` registers nothing and only flips the switch for the screenshot script. - A token needs `app.config.ts`'s `extra.eas.projectId`, which `eas init` set to the `@pi-stack/debateowl` project (see [docs/development.md](../../docs/development.md#build-day)); without it the switch shows as unavailable, on Expo Go too. Expo Go on Android has no remote push since SDK 53, and Expo recommends a development build for remote push on iOS as well. - - Prebuild applies the `expo-notifications` config plugin automatically once the package is installed, even though `app.config.ts` does not list it, and the plugin sets the `aps-environment` entitlement. `DEBATELAB_BUILD=dev` removes it again (`withoutPushEntitlement`), because the borrowed Signulous profile has no push, so a development build cannot get a token even with a project ID: turning the switch on there fails. Real push needs a provisioning profile with the Push Notifications capability and an APNs key uploaded to EAS, both from Adam's own Apple Developer account; only then can `withoutPushEntitlement` go. + - Prebuild applies the `expo-notifications` config plugin automatically once the package is installed, even though `app.config.ts` does not list it, and the plugin sets the `aps-environment` entitlement. `DEBATELAB_BUILD=dev` removes it again (`withoutAccountEntitlements`, which also drops the Sign in with Apple entitlement `expo-apple-authentication`'s plugin adds), because the borrowed Signulous profile has no push, so a development build cannot get a token even with a project ID: turning the switch on there fails. A build signed by DebateOwl's own account sets `DEBATELAB_APPLE_ACCOUNT=1` and keeps both; it also needs an APNs key uploaded to EAS. + - A tapped notification opens its screen (`notification-routes.ts`, from `use-apple-platform.ts` in the root layout): `sparring_scored` the judged round, the timer's time-up alert the timer. `result_ready` only opens the app, where the tool screen picks its answer up. - iPad layouts (iPad mock A, with mock C's docked claim) come from one hook, `useLayoutClass()` in `src/lib/layout.ts`, which also holds every width they use: `phone` on every iPhone and in iPad windows under 700 points, `regular` from 700, `wide` from 1100. Phone layouts must not change, so each screen keeps its phone JSX and branches before it; compare iPhone screenshots before and after. A full-screen iPad window from the regular width shows the app's own sidebar (`components/app-sidebar.tsx`, rows in `sidebar-items.ts`) beside the root stack, so it stays while a tool is open: the Current Debate switcher, then every tool under collapsible Debate, Prepare, Practice, Library and You sections (closed sections are kept on the device). NativeTabs hides its bar there (`hidden`), and `app-tabs.web.tsx` hides the preview's; Split View, Slide Over, Stage Manager windows and widths under 700 keep the tab bar (`sidebarWidthFor`). A sidebar row closes the screens above the tabs before it opens its own, so the sidebar never stacks tools. Screens lay out for the room beside the sidebar: `useLayoutClass()` and `useContentWidth()` subtract `ReservedWidth`, which sheets reset to 0; never size a screen from `useWindowDimensions()` directly. The Prepare and Practice tab pages are not in the sidebar: the Debate dashboard and History cover their lists. The Library opens a source beside its list (`SourceView`, shared with the source sheet), Ask lists its conversations beside the chat (`ask-chat-list.tsx`), the Debate tab is a two-column dashboard, saved documents get an "On this page" rail from 900 points (`DocumentScreen`'s `outline`, `components/outline.tsx`), and the Flowchart docks a card's claim beside the map (`ClaimPanel` in `claim-panel.tsx`, shared with the claim sheet). A docked claim or a source open beside its list is dropped when that map, debate, account or row is no longer the one on screen. All are plain views sized from the window, so they run in Expo Go. -- Share links (`share-links.ts`, `use-share-link.ts`): a saved case, attack, brainstorm or factoids document's menu calls the `share` Edge Function with the session and reuses a link made this session for the same unchanged result, because every call makes a new link and counts toward the daily limit. With no browser Origin the function builds debateowl.com links, so links made against staging open production's site. `share/[code].tsx` reads a link back with the public `get_shared_item` RPC. Universal links (`deep-links.ts`, a tested parser that honours only https debateowl.com and www) need the Associated Domains entitlement, which `app.config.ts` adds to development builds, and the website's apple-app-site-association file, which is not deployed. +- Share links (`share-links.ts`, `use-share-link.ts`): a saved case, attack, brainstorm or factoids document's menu calls the `share` Edge Function with the session and reuses a link made this session for the same unchanged result, because every call makes a new link and counts toward the daily limit. With no browser Origin the function builds debateowl.com links, so links made against staging open production's site. `share/[code].tsx` reads a link back with the public `get_shared_item` RPC. Universal links (`deep-links.ts`, a tested parser that honours only https debateowl.com and www) need the Associated Domains entitlement, which `app.config.ts` adds to development builds, and the website's apple-app-site-association file, which is not deployed (its content is in [docs/apple-platform.md](../../docs/apple-platform.md)). - The first run (`first-run.ts`, `use-first-run.ts`, `welcome.tsx`) uses the website's keys: Ollie's welcome once per phone, the four-step tour once per account. The web preview opens it only with `?intro=show` (`first-run.web.ts`). `components/trial-banner.tsx` is the website's trial reminder (days left from `packages/core/src/trial.ts`); `auth.tsx`'s `sessionExpired` is set when the server ends a session (a refused refresh), never for a sign-out the student asked for, and the sign-in screen shows the website's notice. - Accessibility: every text field is `AppTextInput` (`components/app-text-input.tsx`), with the one Done bar above the keyboard (`KeyboardDoneBar` in the root layout); `Screen`, `DocumentScreen` and sheets scroll fields clear of the keyboard. Text grows with Dynamic Type; only fixed chrome sets `maxFontSizeMultiplier` (floating action-bar buttons and segmented controls 1.6, the sidebar 1.35). The web preview draws a text size from `window.__debateowlFontScale` (`theme/font-scale.web.ts`, the script's `FONT_SCALE`). `a11y.ts` announces a finished Ask answer, Crossfire reply, Sparring speech or tool result once; never make streamed text a live region. A list row's long-press menu is also its VoiceOver actions (`accessibilityActions`): saved work, History, Library sources, recent sources and the switcher. iPad shortcuts (⌘N new debate, ⌘K switcher, ⌘↩ send) go through `key-commands.ts`: one registry (`key-command-registry.ts`) gives a press to the newest screen listening, screens listen only while in view (`useFocusedKeyCommand`), and the development build delivers them from `modules/key-commands`, with titles in the ⌘ overlay; the web preview listens to `keydown` (`key-commands.web.ts`). The timer's space bar belongs to the timer work. - The Current Debate widget (`widgets/current-debate.tsx`, published by `debate-widget.ts` from the debate context and the Debate tab) needs a development build with `DEBATELAB_APP_GROUP`. Quick actions (`quick-actions.ts`, `expo-quick-actions`) and Spotlight (`spotlight.ts`, `expo-core-spotlight` 2.x, whose config plugin 3.0 dropped) are in development builds; `app.config.ts`'s round-4 section adds their native config. Spotlight indexes the Current Debate's documents (`spotlight-items.ts`) as `debatelab:///` links, and empties when the Current Debate changes, on sign-out and after Delete all my data. - Sharing into the app (`share-intent.ts`, `expo-share-intent`): with the App Group, development builds get the `DebateOwlShare` extension. A shared page opens Add link and starts its import; other text opens New note filled in (`share-intent-parse.ts`). `+native-intent.tsx` hands the extension's `debatelab://dataUrl=…` to `share-intent.ts`, and the sheets take what was shared only from its one-shot hand-off, never from route parameters, so no other link can start an import. +- Sign in with Apple and Google (`social-sign-in.ts`, on `sign-in.tsx` below the email and demo forms, Apple's own button first): Apple's identity token goes to `signInWithIdToken` with a nonce, and the name Apple gives on the first sign-in is saved to the account; Google is the website's OAuth flow in `openAuthSessionAsync` with PKCE (`flowType: 'pkce'` in `supabase.ts`, as the website's client has), and `oauth-callback.ts` reads the redirect (`oauth-callback.test.ts`). `app.config.ts`'s `extra.googleSignIn` and `extra.signInWithApple` turn the buttons on; the Supabase providers and the `debatelab://auth/callback` redirect are in the account checklist. +- In-app purchase: `modules/store-kit` is the app's own StoreKit 2 module (`StoreKitLogic.swift` holds the StoreKit work and typechecks alone; `StoreKitModule.swift` is the Expo glue), loaded by `store-kit.ts` with `requireOptionalNativeModule`, so Expo Go simply has none. `purchases.ts` buys with the account's id as `appAccountToken`, posts the signed transaction to `app-store-purchase`, and finishes it only after a 2xx, one at a time through a serial queue; an unfinished one is sent again at launch, on sign-in and in the foreground, each `Transaction.updates` version is sent as it comes (a refund is a new signed version of the same id) (`use-apple-platform.ts`), and nothing is sent under another account (`purchases.test.ts`). Offer codes are not offered: a code redeemed through Apple has no `appAccountToken`. The product ids are `PRODUCT_IDS`, registered in `20261029120000_app_store_debateowl_products.sql`. `store-kit.web.ts` is a placeholder store for the screenshot script. +- Siri, Shortcuts and Spotlight actions: `plugins/app-intents/DebateOwlAppIntents.swift` has six App Intents (start the timer, ask the coach, a new debate, write a case, open the Library, spar) and their App Shortcuts phrases, translated in `plugins/app-intents/.lproj/AppShortcuts.strings`. Xcode extracts App Intents metadata only from the target that compiles them, so `plugins/with-app-intents.js` (CommonJS, because Node loads what `app.config.ts` imports as it is) copies them into the app target at prebuild, writes the Spanish and Hungarian intent titles from the catalogs, and fails prebuild when a title drifts from `en.json`. Each intent opens the app on a `debatelab://` link: Ask reads `q` into the question without sending it, Generate reads `motion` into the form (the first value when a link repeats it). +- Handoff: `modules/handoff` (the app's own module) offers the open screen as an NSUserActivity; `handoff.ts` (`useHandoff` in the root layout) maps the route to its debateowl.com page with `handoffTarget` (`app-intent-links.ts`, tested), so a Mac continues in Safari, and opens a route an iPad or iPhone hands over (`continuedRoute`; a result only the other device holds opens History). `deep-links.ts` maps the website's free tools back to their screens for Handoff from Safari. Handing over from Safari to the app needs `activitycontinuation:debateowl.com`, which `DEBATELAB_APPLE_ACCOUNT=1` adds, and the website's association file. +- `review-prompt.ts` offers Apple's rating prompt a few seconds after the third finished result (a tool's `complete()`, a judged Sparring round), at most every 120 days. - `src/theme/`: the website's DebateOwl design tokens (`src/styles/tokens.css` and the roles in `src/index.css`) in light and dark. `primary` is the blue that passes as text; `primaryFill` with `primaryLedge` is the button face and the ledge it sits on. Success is mint and always comes with a check mark. Fredoka (`weight="display"`) is for titles, the motion and button labels, Nunito for everything else, loaded with expo-font from `@expo-google-fonts`. Buttons are the website's tactile ones (`components/buttons.tsx`), which sink onto their ledge while pressed. Cards and fields have 2pt edges. - `components/owl.tsx`: Ollie, from PNGs of the website's owl SVGs (`src/assets/owl`, `public/owl-mark.svg`), in the pose the website uses for each place: `point` for an empty start, `coach` beside Ask's answers, `arguing` for the Sparring opponent, `reading`, `surprised` and `sleepy` in empty states (`components/empty-state.tsx`), and a tool's pose while it works (`ToolProgress`). `scripts/render-owls.ts` renders them, the app icon, the splash image and the Android icons again after the website's owl changes. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index bc5554ccd..7a6039ca3 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -5,6 +5,9 @@ import path from 'node:path'; import type { ConfigContext, ExpoConfig } from 'expo/config'; import { type ConfigPlugin, withEntitlementsPlist } from 'expo/config-plugins'; +// CommonJS, because Node loads what app.config.ts imports as it is. +import { withAppIntents } from './plugins/with-app-intents'; + // One config for Expo Go, the web export and the development build. // DEBATELAB_BUILD=dev (set by the `development` profile in eas.json) adds the @@ -22,9 +25,21 @@ type PermissionStrings = { speechRecognition: string; microphone: string }; const build = process.env.DEBATELAB_BUILD; const appGroup = process.env.DEBATELAB_APP_GROUP; +// DEBATELAB_APPLE_ACCOUNT=1: the build is signed by DebateOwl's own Apple +// Developer team, whose App IDs carry the capabilities in docs/apple-platform.md +// (Sign in with Apple, Push, Time Sensitive Notifications, Associated Domains, +// In-App Purchase). Without it a development build keeps to what the borrowed +// signing profile allows. +const appleAccount = process.env.DEBATELAB_APPLE_ACCOUNT; if (build !== undefined && build !== '' && build !== 'dev') { throw new Error(`DEBATELAB_BUILD must be "dev" or unset, not "${build}".`); } +if (appleAccount !== undefined && appleAccount !== '' && appleAccount !== '1') { + throw new Error(`DEBATELAB_APPLE_ACCOUNT must be "1" or unset, not "${appleAccount}".`); +} +if (appleAccount === '1' && build !== 'dev') { + throw new Error('DEBATELAB_APPLE_ACCOUNT only applies to a development build (DEBATELAB_BUILD=dev).'); +} if (appGroup && build !== 'dev') { throw new Error('DEBATELAB_APP_GROUP only applies to a development build (DEBATELAB_BUILD=dev).'); } @@ -54,11 +69,14 @@ function iosLocale(strings: PermissionStrings) { // expo-widgets adds the push entitlement even with its push option off, and // prebuild applies expo-notifications' plugin on its own once the package is -// installed, which adds it too. The signing profile has no push, so every -// development build drops it until a profile with push exists. -const withoutPushEntitlement: ConfigPlugin = (config) => +// installed, which adds it too; expo-apple-authentication's plugin, also +// applied on its own, adds Sign in with Apple. The borrowed signing profile +// has neither, so a development build drops both unless it is signed by +// DebateOwl's own account (DEBATELAB_APPLE_ACCOUNT). +const withoutAccountEntitlements: ConfigPlugin = (config) => withEntitlementsPlist(config, (mod) => { delete mod.modResults['aps-environment']; + delete mod.modResults['com.apple.developer.applesignin']; return mod; }); @@ -163,6 +181,40 @@ export function withRound4(config: ExpoConfig, opts: { appGroup?: string; projec return config; } +// Round 6 (docs/apple-platform.md): Sign in with Apple and Google, the +// time-sensitive timer alert, Handoff and push, for development builds only, +// so the Expo Go config never changes. What needs DebateOwl's own Apple +// account waits for DEBATELAB_APPLE_ACCOUNT. +export function withApplePlatform(config: ExpoConfig, opts: { appleAccount: boolean }): ExpoConfig { + // Handoff (src/lib/handoff.ts), next to Expo Router's own activity type. + config.ios = { + ...config.ios, + infoPlist: { + ...config.ios?.infoPlist, + NSUserActivityTypes: [...((config.ios?.infoPlist?.NSUserActivityTypes as string[] | undefined) ?? []), 'com.pistack.debateowl.browsing'], + }, + }; + config.extra = { + ...config.extra, + // Google sign-in runs in any development build: the redirect comes back on the app's own scheme. + googleSignIn: true, + signInWithApple: opts.appleAccount, + }; + if (!opts.appleAccount) return config; + config.ios = { ...config.ios, usesAppleSignIn: true }; + config.ios.associatedDomains = [ + ...(config.ios.associatedDomains ?? []), + // Handoff from debateowl.com in Safari to the app. + 'activitycontinuation:debateowl.com', + ]; + config.ios.entitlements = { + ...config.ios.entitlements, + // The timer's time-up alert breaks through a Focus (src/lib/timer-alerts.ts). + 'com.apple.developer.usernotifications.time-sensitive': true, + }; + return config; +} + export default ({ projectRoot }: ConfigContext): ExpoConfig => { const config: ExpoConfig = { name: 'DebateOwl', @@ -278,7 +330,12 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { } // The Current Debate widget (with the App Group), quick actions and Spotlight; see withRound4 above. withRound4(config, { appGroup, projectRoot }); + withApplePlatform(config, { appleAccount: appleAccount === '1' }); + // Siri and Shortcuts: the intents compile into the app target (plugins/with-app-intents.js). + const withIntents = withAppIntents(config); + // DebateOwl's own account has Push and Sign in with Apple on its App ID, so its builds keep both. + if (appleAccount === '1') return withIntents; // Registered before Expo applies the plugins list. Mods registered later act // first, so this one runs after the plugins have written their entitlements. - return withoutPushEntitlement(config); + return withoutAccountEntitlements(withIntents); }; diff --git a/apps/mobile/modules/handoff/expo-module.config.json b/apps/mobile/modules/handoff/expo-module.config.json new file mode 100644 index 000000000..53616b326 --- /dev/null +++ b/apps/mobile/modules/handoff/expo-module.config.json @@ -0,0 +1,7 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["HandoffModule"], + "appDelegateSubscribers": ["HandoffAppDelegateSubscriber"] + } +} diff --git a/apps/mobile/modules/handoff/ios/Handoff.podspec b/apps/mobile/modules/handoff/ios/Handoff.podspec new file mode 100644 index 000000000..559328b4f --- /dev/null +++ b/apps/mobile/modules/handoff/ios/Handoff.podspec @@ -0,0 +1,19 @@ +Pod::Spec.new do |s| + s.name = 'Handoff' + s.version = '1.0.0' + s.summary = "DebateOwl's Handoff activity" + s.description = 'Advertises the screen open in DebateOwl (NSUserActivity) so a Mac continues on debateowl.com and an iPad in the app.' + s.author = '' + s.homepage = 'https://debateowl.com' + s.platforms = { :ios => '16.4' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + + s.source_files = "**/*.{h,m,mm,swift}" +end diff --git a/apps/mobile/modules/handoff/ios/HandoffModule.swift b/apps/mobile/modules/handoff/ios/HandoffModule.swift new file mode 100644 index 000000000..953c52ff4 --- /dev/null +++ b/apps/mobile/modules/handoff/ios/HandoffModule.swift @@ -0,0 +1,161 @@ +import ExpoModulesCore +import UIKit + +// Handoff for src/lib/handoff.ts. JavaScript says which screen is open (a title, +// the debateowl.com page that matches it and the app route); this advertises it +// as the current NSUserActivity, so a Mac signed in to the same Apple Account +// offers to continue in Safari, and an iPad with DebateOwl continues in the app. +// +// App to web needs no entitlement: the activity carries webpageURL. App to app +// needs the activity type in Info.plist's NSUserActivityTypes. Web to app (Safari +// on a Mac handing debateowl.com to the phone) would need the Associated Domains +// entitlement `activitycontinuation:debateowl.com`; it is not set up. +// +// The activity is made current with becomeCurrent() and kept here, not attached +// to a view controller's userActivity: UIKit empties a responder-managed +// activity's userInfo before it asks the responder to refill it, which would +// drop the route. + +public class HandoffModule: Module { + public func definition() -> ModuleDefinition { + Name("DebateOwlHandoff") + + // Another device handed a screen over; JavaScript takes it with takeContinuedPath. + Events("onContinue") + + OnCreate { + HandoffCenter.shared.onContinue = { [weak self] in + self?.sendEvent("onContinue", [:]) + } + } + + OnDestroy { + HandoffCenter.shared.onContinue = nil + } + + Function("setActivity") { (activity: [String: Any]) in + DispatchQueue.main.async { HandoffCenter.shared.set(activity) } + } + + Function("clearActivity") { + DispatchQueue.main.async { HandoffCenter.shared.clear() } + } + + // The route (or, failing that, the web address) another device handed over, + // once. Kept until asked, so a handover that launched the app is not lost + // while JavaScript starts. + Function("takeContinuedPath") { () -> String? in + HandoffCenter.shared.takePending() + } + } +} + +/// Receives activities handed over from another device. Expo's linking +/// subscriber only takes web-browsing activities, so ours would otherwise +/// open the app on its first screen. +public class HandoffAppDelegateSubscriber: ExpoAppDelegateSubscriber { + public func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([any UIUserActivityRestoring]?) -> Void + ) -> Bool { + return HandoffCenter.shared.receive(userActivity) + } +} + +// Unchecked: the activity is touched on the main thread only, `pending` behind +// the lock, and `onContinue` is set once when the module is created. +final class HandoffCenter: @unchecked Sendable { + static let shared = HandoffCenter() + /// Also listed in Info.plist's NSUserActivityTypes (app.config.ts). + static let activityType = "com.pistack.debateowl.browsing" + private static let hosts: Set = ["debateowl.com", "www.debateowl.com"] + + var onContinue: (() -> Void)? + + // Main thread only. + private var current: NSUserActivity? + private var activation: NSObjectProtocol? + + // Written on the main thread, read from JavaScript's. + private let lock = NSLock() + private var pending: String? + + /// Makes `entry` the current activity. Anything but our type with an https + /// debateowl.com address clears it instead. + func set(_ entry: [String: Any]) { + guard entry["type"] as? String == Self.activityType, + let address = entry["webpageURL"] as? String, + let url = URL(string: address), + url.scheme == "https", + let host = url.host?.lowercased(), + Self.hosts.contains(host) + else { + clear() + return + } + let title = entry["title"] as? String + let path = entry["appPath"] as? String + + // The same screen again (a re-render, a language change): keep the activity. + if let current, current.webpageURL == url, current.userInfo?["path"] as? String == path { + current.title = title + current.becomeCurrent() + return + } + + let activity = NSUserActivity(activityType: Self.activityType) + activity.title = title + activity.webpageURL = url + if let path { + activity.addUserInfoEntries(from: ["path": path]) + } + activity.isEligibleForHandoff = true + // Spotlight and Siri suggestions belong to their own work. + activity.isEligibleForSearch = false + activity.isEligibleForPublicIndexing = false + activity.isEligibleForPrediction = false + + current?.invalidate() + current = activity + activity.becomeCurrent() + observeActivation() + } + + func clear() { + current?.invalidate() + current = nil + } + + /// Advertises the activity again when the app returns to the foreground. + private func observeActivation() { + guard activation == nil else { return } + activation = NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.current?.becomeCurrent() + } + } + + /// Keeps a handed-over screen for JavaScript; false for activities that are not ours. + func receive(_ activity: NSUserActivity) -> Bool { + guard activity.activityType == Self.activityType else { return false } + let value = (activity.userInfo?["path"] as? String) ?? activity.webpageURL?.absoluteString + guard let value else { return true } + lock.lock() + pending = value + lock.unlock() + onContinue?() + return true + } + + func takePending() -> String? { + lock.lock() + defer { lock.unlock() } + let value = pending + pending = nil + return value + } +} diff --git a/apps/mobile/modules/store-kit/expo-module.config.json b/apps/mobile/modules/store-kit/expo-module.config.json new file mode 100644 index 000000000..a3d69760b --- /dev/null +++ b/apps/mobile/modules/store-kit/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["StoreKitModule"] + } +} diff --git a/apps/mobile/modules/store-kit/ios/StoreKitBridge.podspec b/apps/mobile/modules/store-kit/ios/StoreKitBridge.podspec new file mode 100644 index 000000000..c4f807f76 --- /dev/null +++ b/apps/mobile/modules/store-kit/ios/StoreKitBridge.podspec @@ -0,0 +1,22 @@ +Pod::Spec.new do |s| + s.name = 'StoreKitBridge' + s.version = '1.0.0' + s.summary = "DebateOwl's in-app subscriptions" + s.description = 'StoreKit 2 products, purchases, transactions and Apple subscription sheets for the DebateOwl app, called from JavaScript.' + s.author = '' + s.homepage = 'https://debateowl.com' + s.platforms = { :ios => '16.4' } + s.source = { git: '' } + s.static_framework = true + # Swift 5 mode, as expo-store-review, so StoreKit's non-Sendable types cross the async functions without strict checks. + s.swift_version = '5.9' + + s.dependency 'ExpoModulesCore' + s.frameworks = 'StoreKit' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + + s.source_files = "**/*.{h,m,mm,swift}" +end diff --git a/apps/mobile/modules/store-kit/ios/StoreKitLogic.swift b/apps/mobile/modules/store-kit/ios/StoreKitLogic.swift new file mode 100644 index 000000000..03f58e751 --- /dev/null +++ b/apps/mobile/modules/store-kit/ios/StoreKitLogic.swift @@ -0,0 +1,218 @@ +import Foundation +import StoreKit + +// StoreKit 2 for src/lib/store-kit.ts, as plain JSON dictionaries. This file +// uses only Foundation and StoreKit so it can be typechecked on its own +// (`swiftc -typecheck` against the macOS SDK); StoreKitModule.swift is the +// thin Expo glue around it. +// +// Nothing here finishes a transaction on its own. purchases.ts finishes one +// only after the app-store-purchase Edge Function has accepted it, so a +// purchase whose request is lost stays in Transaction.unfinished and is sent +// again. Unverified transactions are left out: the server would refuse them. + +/// An error with the code JavaScript reads (`error.code`); the glue turns it into an Expo Exception. +struct StoreKitFailure: Error { + let code: String + let message: String +} + +@available(iOS 15.0, macOS 12.0, *) +enum StoreKitLogic { + // MARK: - Products + + static func products(_ ids: [String]) async throws -> [[String: Any]] { + let products: [Product] + do { + products = try await Product.products(for: ids) + } catch { + throw failure(error) + } + var list: [[String: Any]] = [] + for product in products { + list.append(await describe(product)) + } + return list + } + + private static func describe(_ product: Product) async -> [String: Any] { + var entry: [String: Any] = [ + "id": product.id, + "displayName": product.displayName, + "description": product.description, + "displayPrice": product.displayPrice, + "price": NSDecimalNumber(decimal: product.price).stringValue, + "currencyCode": product.priceFormatStyle.currencyCode, + "isEligibleForIntroOffer": false, + ] + guard let subscription = product.subscription else { return entry } + entry["period"] = period(subscription.subscriptionPeriod) + if let offer = subscription.introductoryOffer { + entry["introOffer"] = [ + "displayPrice": offer.displayPrice, + "period": period(offer.period), + "paymentMode": paymentMode(offer.paymentMode), + "periodCount": offer.periodCount, + ] as [String: Any] + } + entry["isEligibleForIntroOffer"] = await subscription.isEligibleForIntroOffer + return entry + } + + private static func period(_ period: Product.SubscriptionPeriod) -> [String: Any] { + let unit: String + switch period.unit { + case .day: unit = "day" + case .week: unit = "week" + case .month: unit = "month" + case .year: unit = "year" + @unknown default: unit = "month" + } + return ["unit": unit, "value": period.value] + } + + private static func paymentMode(_ mode: Product.SubscriptionOffer.PaymentMode) -> String { + if mode == .freeTrial { return "freeTrial" } + if mode == .payUpFront { return "payUpFront" } + return "payAsYouGo" + } + + // MARK: - Purchase + + /// Shows Apple's purchase sheet for `productId`, made out to `appAccountToken` + /// (the Supabase user id). Returns the transaction unfinished. + static func purchase(_ productId: String, appAccountToken: String) async throws -> [String: Any] { + guard let token = UUID(uuidString: appAccountToken) else { + throw StoreKitFailure(code: "ERR_INVALID_ACCOUNT_TOKEN", message: "The account token is not a UUID.") + } + let result: Product.PurchaseResult + do { + guard let product = try await Product.products(for: [productId]).first else { + throw StoreKitFailure(code: "ERR_UNKNOWN_PRODUCT", message: "The App Store does not list \(productId).") + } + result = try await product.purchase(options: [.appAccountToken(token)]) + } catch { + throw failure(error) + } + switch result { + case .success(let verification): + if case .unverified(_, let error) = verification { + throw StoreKitFailure(code: "ERR_UNVERIFIED", message: error.localizedDescription) + } + guard var entry = await entry(verification) else { + throw StoreKitFailure(code: "ERR_UNVERIFIED", message: "The purchase could not be verified.") + } + entry["status"] = "purchased" + return entry + case .pending: + return ["status": "pending"] + case .userCancelled: + return ["status": "cancelled"] + @unknown default: + throw StoreKitFailure(code: "ERR_STORE_KIT", message: "Unknown purchase result.") + } + } + + /// Finishes the unfinished transaction with this id. One that is not + /// unfinished any more was finished already, so there is nothing to do. + static func finish(_ transactionId: String) async { + guard let id = UInt64(transactionId) else { return } + for await result in Transaction.unfinished { + let transaction = result.unsafePayloadValue + if transaction.id == id { + await transaction.finish() + return + } + } + } + + // MARK: - Transactions + + static func currentEntitlements() async -> [[String: Any]] { + var list: [[String: Any]] = [] + for await result in Transaction.currentEntitlements { + if let entry = await entry(result) { list.append(entry) } + } + return list + } + + static func unfinished() async -> [[String: Any]] { + var list: [[String: Any]] = [] + for await result in Transaction.unfinished { + if let entry = await entry(result) { list.append(entry) } + } + return list + } + + /// Restore purchases. May ask for the Apple ID password; dismissing it is ERR_CANCELLED. + static func sync() async throws { + do { + try await AppStore.sync() + } catch { + throw failure(error) + } + } + + /// Sends every verified Transaction.updates element (a renewal, an approved + /// Ask to Buy, an offer code, a purchase on another device) until cancelled. + static func observeUpdates(_ send: @escaping @Sendable ([String: Any]) -> Void) -> Task { + Task.detached(priority: .background) { + for await result in Transaction.updates { + if Task.isCancelled { return } + if let entry = await entry(result) { send(entry) } + } + } + } + + /// One verified transaction as store-kit.ts's StoreTransaction; nil when it is unverified. + static func entry(_ result: VerificationResult) async -> [String: Any]? { + guard case .verified(let transaction) = result else { return nil } + var entry: [String: Any] = [ + "transactionId": String(transaction.id), + "productId": transaction.productID, + "signedTransaction": result.jwsRepresentation, + "appAccountToken": transaction.appAccountToken.map { $0.uuidString.lowercased() as Any } ?? NSNull(), + "expiresAt": transaction.expirationDate.map { $0.ISO8601Format(Date.ISO8601FormatStyle(includingFractionalSeconds: true)) as Any } ?? NSNull(), + ] + if let renewal = await renewalInfo(for: transaction) { entry["signedRenewalInfo"] = renewal } + return entry + } + + /// The subscription's signed renewal info, so the server learns whether it renews. + private static func renewalInfo(for transaction: Transaction) async -> String? { + guard transaction.productType == .autoRenewable else { return nil } + if #available(iOS 17.2, macOS 14.2, *) { + return await transaction.subscriptionStatus?.renewalInfo.jwsRepresentation + } + guard let groupID = transaction.subscriptionGroupID, + let statuses = try? await Product.SubscriptionInfo.status(for: groupID) else { return nil } + for status in statuses { + if case .verified(let info) = status.renewalInfo, info.originalTransactionID == transaction.originalID { + return status.renewalInfo.jwsRepresentation + } + } + return nil + } + + // MARK: - Errors + + static func failure(_ error: Error) -> StoreKitFailure { + if let failure = error as? StoreKitFailure { return failure } + if let error = error as? StoreKitError { + switch error { + case .userCancelled: return StoreKitFailure(code: "ERR_CANCELLED", message: error.localizedDescription) + case .networkError: return StoreKitFailure(code: "ERR_NETWORK", message: error.localizedDescription) + case .notAvailableInStorefront: return StoreKitFailure(code: "ERR_UNKNOWN_PRODUCT", message: error.localizedDescription) + default: return StoreKitFailure(code: "ERR_STORE_KIT", message: error.localizedDescription) + } + } + if let error = error as? Product.PurchaseError { + switch error { + case .purchaseNotAllowed: return StoreKitFailure(code: "ERR_PURCHASE_NOT_ALLOWED", message: error.localizedDescription) + case .productUnavailable: return StoreKitFailure(code: "ERR_UNKNOWN_PRODUCT", message: error.localizedDescription) + default: return StoreKitFailure(code: "ERR_STORE_KIT", message: error.localizedDescription) + } + } + return StoreKitFailure(code: "ERR_STORE_KIT", message: error.localizedDescription) + } +} diff --git a/apps/mobile/modules/store-kit/ios/StoreKitModule.swift b/apps/mobile/modules/store-kit/ios/StoreKitModule.swift new file mode 100644 index 000000000..170ffd3c5 --- /dev/null +++ b/apps/mobile/modules/store-kit/ios/StoreKitModule.swift @@ -0,0 +1,94 @@ +import ExpoModulesCore +import StoreKit +import UIKit + +// The Expo glue for StoreKitLogic.swift, which holds the StoreKit 2 work. +// src/lib/store-kit.ts loads it as DebateOwlStoreKit; purchases.ts owns the +// flow. Errors reach JavaScript as an Exception whose code is the +// StoreKitFailure's (ERR_CANCELLED, ERR_UNVERIFIED and the rest). +// +// Transaction.updates is listened to from the moment the module is created, +// as Apple asks, so a renewal or an approved Ask to Buy that arrives at launch +// is not missed; each verified one is sent as `onTransactionUpdate`. + +public class StoreKitModule: Module { + private var updates: Task? + + public func definition() -> ModuleDefinition { + Name("DebateOwlStoreKit") + + Events("onTransactionUpdate") + + OnCreate { + self.updates = StoreKitLogic.observeUpdates { [weak self] entry in + self?.sendEvent("onTransactionUpdate", entry.mapValues { $0 as Any? }) + } + } + + OnDestroy { + self.updates?.cancel() + self.updates = nil + } + + AsyncFunction("products") { (ids: [String]) async throws -> [[String: Any]] in + try await bridged { try await StoreKitLogic.products(ids) } + } + + AsyncFunction("purchase") { (productId: String, appAccountToken: String) async throws -> [String: Any] in + try await bridged { try await StoreKitLogic.purchase(productId, appAccountToken: appAccountToken) } + } + + AsyncFunction("finish") { (transactionId: String) async in + await StoreKitLogic.finish(transactionId) + } + + AsyncFunction("currentEntitlements") { () async -> [[String: Any]] in + await StoreKitLogic.currentEntitlements() + } + + AsyncFunction("unfinished") { () async -> [[String: Any]] in + await StoreKitLogic.unfinished() + } + + AsyncFunction("sync") { () async throws in + try await bridged { try await StoreKitLogic.sync() } + } + + AsyncFunction("showManageSubscriptions") { @MainActor () async throws in + guard let scene = SceneGeometry.foregroundActiveScene() else { throw NoWindowSceneException() } + do { + try await AppStore.showManageSubscriptions(in: scene) + } catch { + throw exception(error) + } + } + + AsyncFunction("presentOfferCodeRedeemSheet") { @MainActor () async throws in + guard let scene = SceneGeometry.foregroundActiveScene() else { throw NoWindowSceneException() } + do { + try await AppStore.presentOfferCodeRedeemSheet(in: scene) + } catch { + throw exception(error) + } + } + } +} + +/// Runs StoreKit work and hands any failure to JavaScript with its code. +private func bridged(_ work: () async throws -> T) async throws -> T { + do { + return try await work() + } catch { + throw exception(error) + } +} + +private func exception(_ error: Error) -> Exception { + let failure = StoreKitLogic.failure(error) + return Exception(name: "StoreKitError", description: failure.message, code: failure.code) +} + +/// A sheet was asked for while no scene of the app is in the foreground. Its code is ERR_NO_WINDOW_SCENE, from the class name. +final class NoWindowSceneException: Exception, @unchecked Sendable { + override var reason: String { "The app is not on screen." } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 25a3ba8a6..47e36671a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -23,6 +23,7 @@ "date-fns": "^3.6.0", "docx": "^9.5.1", "expo": "~57.0.27", + "expo-apple-authentication": "~57.0.2", "expo-audio": "~57.0.5", "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.21", @@ -48,6 +49,7 @@ "expo-speech-recognition": "^57.1.1", "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", + "expo-store-review": "~57.0.3", "expo-system-ui": "~57.0.4", "expo-web-browser": "~57.0.3", "expo-widgets": "~57.0.24", diff --git a/apps/mobile/plugins/app-intents/DebateOwlAppIntents.swift b/apps/mobile/plugins/app-intents/DebateOwlAppIntents.swift new file mode 100644 index 000000000..aa1799cc9 --- /dev/null +++ b/apps/mobile/plugins/app-intents/DebateOwlAppIntents.swift @@ -0,0 +1,216 @@ +import AppIntents +import Foundation +#if canImport(UIKit) +import UIKit +#endif + +// Siri, Shortcuts and Spotlight actions for DebateOwl. plugins/with-app-intents.js +// copies this file into the app target during prebuild: Xcode extracts App +// Intents metadata only from the target that compiles the intents, so they +// cannot live in a CocoaPods library. +// +// Every intent opens the app (openAppWhenRun) and hands it a debatelab:// link, +// so routing stays in JavaScript (Expo Router; src/app/+native-intent.tsx lets +// the app's own scheme through). Nothing here reads or writes the student's data. +// +// Titles are the English wording of src/locales/en.json; the plugin checks that +// and writes the Spanish and Hungarian Localizable.strings from the same +// catalogs. The Siri phrases are translated in .lproj/AppShortcuts.strings. + +// MARK: - Opening the app's own links + +enum DebateOwlLink { + static let scheme = "debatelab" + + /// `debatelab://` with the non-empty values as query items. The query + /// is percent-encoded by URLComponents, which leaves `+` alone; Expo Router + /// reads `+` as a space, so it is encoded too ("2+2" stays "2+2"). + static func url(_ route: String, query: [(String, String?)] = []) -> URL? { + var components = URLComponents() + components.scheme = scheme + components.host = route + let items = query.compactMap { name, value -> URLQueryItem? in + guard let text = value?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil } + return URLQueryItem(name: name, value: text) + } + if !items.isEmpty { + components.queryItems = items + components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") + } + return components.url + } + + /// Opens a debatelab:// link in this app. With openAppWhenRun the system has + /// brought the app forward before perform() runs, but on a cold launch it may + /// still be becoming active, and iOS only opens URLs for an active app, so this + /// waits (up to three seconds) for it. The open goes through the app + /// delegate's application(_:open:options:): expo-linking keeps the URL as the + /// launch URL that Expo Router reads at start, or sends it to a running app. + @MainActor + static func open(_ url: URL) async { + #if canImport(UIKit) + let application = UIApplication.shared + if application.applicationState != .active { + await waitUntilActive(timeout: 3) + } + let opened = await application.open(url, options: [:]) + if !opened { + NSLog("DebateOwl: could not open %@ from an App Intent", url.absoluteString) + } + #endif + } + + #if canImport(UIKit) + @MainActor + private static func waitUntilActive(timeout seconds: Double) async { + let deadline = Date().addingTimeInterval(seconds) + while UIApplication.shared.applicationState != .active && Date() < deadline { + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + #endif +} + +// MARK: - Intents + +struct StartTimerIntent: AppIntent { + static let title: LocalizedStringResource = "Start timer" + static let description = IntentDescription("Practice speaking rounds with tournament timing controls.") + static let openAppWhenRun: Bool = true + + @MainActor + func perform() async throws -> some IntentResult { + if let url = DebateOwlLink.url("timer") { await DebateOwlLink.open(url) } + return .result() + } +} + +struct AskCoachIntent: AppIntent { + static let title: LocalizedStringResource = "Ask your coach" + static let description = IntentDescription("Get coaching on your next move.") + static let openAppWhenRun: Bool = true + + @Parameter(title: "Question") + var question: String? + + @MainActor + func perform() async throws -> some IntentResult { + if let url = DebateOwlLink.url("ask", query: [("q", question)]) { await DebateOwlLink.open(url) } + return .result() + } +} + +struct NewDebateIntent: AppIntent { + static let title: LocalizedStringResource = "New debate" + static let openAppWhenRun: Bool = true + + @MainActor + func perform() async throws -> some IntentResult { + if let url = DebateOwlLink.url("new-debate") { await DebateOwlLink.open(url) } + return .result() + } +} + +struct GenerateCaseIntent: AppIntent { + static let title: LocalizedStringResource = "Case generator" + static let description = IntentDescription("Build a complete debate case.") + static let openAppWhenRun: Bool = true + + @Parameter(title: "Motion") + var motion: String? + + @MainActor + func perform() async throws -> some IntentResult { + if let url = DebateOwlLink.url("generate", query: [("motion", motion)]) { await DebateOwlLink.open(url) } + return .result() + } +} + +struct OpenLibraryIntent: AppIntent { + static let title: LocalizedStringResource = "Library" + static let description = IntentDescription("Manage and toggle sources used for grounded responses.") + static let openAppWhenRun: Bool = true + + @MainActor + func perform() async throws -> some IntentResult { + if let url = DebateOwlLink.url("library") { await DebateOwlLink.open(url) } + return .result() + } +} + +struct StartSparringIntent: AppIntent { + static let title: LocalizedStringResource = "Start sparring" + static let description = IntentDescription("Run a timed round against AI.") + static let openAppWhenRun: Bool = true + + @MainActor + func perform() async throws -> some IntentResult { + if let url = DebateOwlLink.url("sparring") { await DebateOwlLink.open(url) } + return .result() + } +} + +// MARK: - App Shortcuts + +// Ready-made shortcuts: Siri phrases and the app's tiles in Shortcuts and +// Spotlight, with no setup by the student. Every phrase names the app +// (\(.applicationName)), as Apple requires; the translations are in +// .lproj/AppShortcuts.strings, keyed by these English phrases. +struct DebateOwlShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: StartTimerIntent(), + phrases: [ + "Start a debate timer in \(.applicationName)", + "Start the \(.applicationName) timer", + ], + shortTitle: "Start timer", + systemImageName: "timer" + ) + AppShortcut( + intent: AskCoachIntent(), + phrases: [ + "Ask \(.applicationName) a question", + "Ask \(.applicationName)", + ], + shortTitle: "Ask your coach", + systemImageName: "questionmark.bubble" + ) + AppShortcut( + intent: NewDebateIntent(), + phrases: [ + "Start a new debate in \(.applicationName)", + "New \(.applicationName) debate", + ], + shortTitle: "New debate", + systemImageName: "plus.bubble" + ) + AppShortcut( + intent: GenerateCaseIntent(), + phrases: [ + "Generate a case in \(.applicationName)", + "Build a case with \(.applicationName)", + ], + shortTitle: "Case generator", + systemImageName: "sparkles" + ) + AppShortcut( + intent: OpenLibraryIntent(), + phrases: [ + "Open my \(.applicationName) library", + "Show my sources in \(.applicationName)", + ], + shortTitle: "Library", + systemImageName: "books.vertical" + ) + AppShortcut( + intent: StartSparringIntent(), + phrases: [ + "Start sparring in \(.applicationName)", + "Spar with \(.applicationName)", + ], + shortTitle: "Start sparring", + systemImageName: "figure.fencing" + ) + } +} diff --git a/apps/mobile/plugins/app-intents/en.lproj/AppShortcuts.strings b/apps/mobile/plugins/app-intents/en.lproj/AppShortcuts.strings new file mode 100644 index 000000000..9677624c8 --- /dev/null +++ b/apps/mobile/plugins/app-intents/en.lproj/AppShortcuts.strings @@ -0,0 +1,21 @@ +/* Siri phrases for the App Shortcuts in DebateOwlAppIntents.swift (DebateOwlShortcuts). + Keys are the English phrases with ${applicationName} where the Swift has \(.applicationName). + Every phrase, in every language, names the app exactly once. */ + +"Start a debate timer in ${applicationName}" = "Start a debate timer in ${applicationName}"; +"Start the ${applicationName} timer" = "Start the ${applicationName} timer"; + +"Ask ${applicationName} a question" = "Ask ${applicationName} a question"; +"Ask ${applicationName}" = "Ask ${applicationName}"; + +"Start a new debate in ${applicationName}" = "Start a new debate in ${applicationName}"; +"New ${applicationName} debate" = "New ${applicationName} debate"; + +"Generate a case in ${applicationName}" = "Generate a case in ${applicationName}"; +"Build a case with ${applicationName}" = "Build a case with ${applicationName}"; + +"Open my ${applicationName} library" = "Open my ${applicationName} library"; +"Show my sources in ${applicationName}" = "Show my sources in ${applicationName}"; + +"Start sparring in ${applicationName}" = "Start sparring in ${applicationName}"; +"Spar with ${applicationName}" = "Spar with ${applicationName}"; diff --git a/apps/mobile/plugins/app-intents/es.lproj/AppShortcuts.strings b/apps/mobile/plugins/app-intents/es.lproj/AppShortcuts.strings new file mode 100644 index 000000000..94ce9d4eb --- /dev/null +++ b/apps/mobile/plugins/app-intents/es.lproj/AppShortcuts.strings @@ -0,0 +1,19 @@ +/* Frases de Siri para los App Shortcuts de DebateOwlAppIntents.swift. Cada frase nombra la app una vez. */ + +"Start a debate timer in ${applicationName}" = "Inicia un cronómetro de debate en ${applicationName}"; +"Start the ${applicationName} timer" = "Inicia el cronómetro de ${applicationName}"; + +"Ask ${applicationName} a question" = "Hazle una pregunta a ${applicationName}"; +"Ask ${applicationName}" = "Pregunta a ${applicationName}"; + +"Start a new debate in ${applicationName}" = "Empieza un nuevo debate en ${applicationName}"; +"New ${applicationName} debate" = "Nuevo debate en ${applicationName}"; + +"Generate a case in ${applicationName}" = "Genera un caso en ${applicationName}"; +"Build a case with ${applicationName}" = "Construye un caso con ${applicationName}"; + +"Open my ${applicationName} library" = "Abre mi biblioteca de ${applicationName}"; +"Show my sources in ${applicationName}" = "Muestra mis fuentes en ${applicationName}"; + +"Start sparring in ${applicationName}" = "Empieza un sparring en ${applicationName}"; +"Spar with ${applicationName}" = "Haz sparring con ${applicationName}"; diff --git a/apps/mobile/plugins/app-intents/hu.lproj/AppShortcuts.strings b/apps/mobile/plugins/app-intents/hu.lproj/AppShortcuts.strings new file mode 100644 index 000000000..e43af2c5b --- /dev/null +++ b/apps/mobile/plugins/app-intents/hu.lproj/AppShortcuts.strings @@ -0,0 +1,21 @@ +/* Siri-kifejezések a DebateOwlAppIntents.swift App Shortcutjaihoz. Minden kifejezés egyszer nevezi meg az appot. + A „…itt: ${applicationName}” forma elkerüli a toldalékot az app nevén. A Siri nem beszél magyarul, + ezért ezek a Parancsok appban és a Spotlightban látszanak. */ + +"Start a debate timer in ${applicationName}" = "Vitaidőmérő indítása itt: ${applicationName}"; +"Start the ${applicationName} timer" = "${applicationName} időmérő indítása"; + +"Ask ${applicationName} a question" = "Kérdés feltevése itt: ${applicationName}"; +"Ask ${applicationName}" = "Kérdezés itt: ${applicationName}"; + +"Start a new debate in ${applicationName}" = "Új vita indítása itt: ${applicationName}"; +"New ${applicationName} debate" = "Új ${applicationName} vita"; + +"Generate a case in ${applicationName}" = "Eset generálása itt: ${applicationName}"; +"Build a case with ${applicationName}" = "Vitaeset építése itt: ${applicationName}"; + +"Open my ${applicationName} library" = "Könyvtár megnyitása itt: ${applicationName}"; +"Show my sources in ${applicationName}" = "Forrásaim megjelenítése itt: ${applicationName}"; + +"Start sparring in ${applicationName}" = "Vitapárbaj indítása itt: ${applicationName}"; +"Spar with ${applicationName}" = "Vitapárbaj itt: ${applicationName}"; diff --git a/apps/mobile/plugins/with-app-intents.js b/apps/mobile/plugins/with-app-intents.js new file mode 100644 index 000000000..1f9ca83b9 --- /dev/null +++ b/apps/mobile/plugins/with-app-intents.js @@ -0,0 +1,328 @@ +// @ts-check +const { existsSync, mkdirSync, readFileSync, writeFileSync } = require('node:fs'); +const path = require('node:path'); + +const { IOSConfig, withDangerousMod, withXcodeProject } = require('expo/config-plugins'); + +/** + * Siri, Shortcuts and Spotlight actions (App Intents) for the development build. + * + * Why a config plugin and not a local Expo module: Xcode extracts App Intents + * metadata (appintentsmetadataprocessor) only for the target that compiles the + * intents, and a CocoaPods static library is not that target, so the intents and + * their AppShortcutsProvider must be compiled into the app target itself. During + * prebuild this plugin + * + * - copies plugins/app-intents/DebateOwlAppIntents.swift to ios// and adds it + * to the app target's Sources, with AppIntents.framework linked; + * - copies the Siri phrases, plugins/app-intents/.lproj/AppShortcuts.strings, + * to ios//.lproj/ and adds them as one localized resource; + * - writes ios//.lproj/Localizable.strings for Spanish and Hungarian + * with the intents' titles (keyed by their English wording, which is how + * LocalizedStringResource looks them up), translated from src/locales. + * + * Expo's own `locales` writer cannot carry those titles: it writes keys unquoted, + * so a key with a space breaks the file. Prebuild runs again and again, so every + * step checks before it adds. + * + * This file is CommonJS with JSDoc types, not TypeScript: app.config.ts is + * transpiled by Expo's config loader, but what it imports is loaded by Node + * itself, which does not resolve an extensionless import to a .ts file, and + * loads a .ts file imported with its extension as ESM, where + * `expo/config-plugins` does not resolve without `.js`. + */ + +const SOURCE_DIR = 'plugins/app-intents'; +const SWIFT_FILE = 'DebateOwlAppIntents.swift'; +const PHRASES_FILE = 'AppShortcuts.strings'; +const TITLES_FILE = 'Localizable.strings'; +/** @typedef {'en' | 'es' | 'hu'} Language */ +/** @type {readonly Language[]} */ +const PHRASE_LANGUAGES = ['en', 'es', 'hu']; +/** @type {readonly Language[]} */ +const TRANSLATED = ['es', 'hu']; + +/** + * Each English string the Swift file shows (intent titles and descriptions, + * parameter titles, short titles) and the catalog key whose wording it is. + * The English catalog must say exactly this, so the app and Siri agree. + */ +const TITLES = [ + { catalogKey: 'mobile.quickActions.startTimer', english: 'Start timer' }, + { catalogKey: 'home.timerDesc', english: 'Practice speaking rounds with tournament timing controls.' }, + { catalogKey: 'askChat.title', english: 'Ask your coach' }, + { catalogKey: 'home.askDesc', english: 'Get coaching on your next move.' }, + { catalogKey: 'export.question', english: 'Question' }, + { catalogKey: 'mobile.debates.newDebate', english: 'New debate' }, + { catalogKey: 'features.generate', english: 'Case generator' }, + { catalogKey: 'home.generateDesc', english: 'Build a complete debate case.' }, + { catalogKey: 'debate.motion', english: 'Motion' }, + { catalogKey: 'nav.library', english: 'Library' }, + { catalogKey: 'home.libraryDesc', english: 'Manage and toggle sources used for grounded responses.' }, + { catalogKey: 'historyPage.startSparring', english: 'Start sparring' }, + { catalogKey: 'home.sparringDesc', english: 'Run a timed round against AI.' }, +]; + +/** + * A .strings literal: backslashes, quotes and line breaks escaped. + * @param {string} value + */ +const quoted = (value) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`; + +/** + * @param {string} projectRoot + * @param {Language} language + * @param {string} key + * @returns {string} + */ +function catalogText(projectRoot, language, key) { + const file = path.join(projectRoot, '../../src/locales', `${language}.json`); + /** @type {unknown} */ + let node = JSON.parse(readFileSync(file, 'utf8')); + for (const part of key.split('.')) { + node = node !== null && typeof node === 'object' ? /** @type {Record} */ (node)[part] : undefined; + } + if (typeof node !== 'string' || node === '') { + throw new Error(`with-app-intents: src/locales/${language}.json has no "${key}".`); + } + return node; +} + +/** + * The phrases in AppShortcutsProvider (one-line string literals that name the app), + * in the strings files' form (`${applicationName}`). + * @param {string} swift + */ +function swiftPhrases(swift) { + const phrases = [...swift.matchAll(/"((?:[^"\\\n]|\\.)*\\\(\.applicationName\)(?:[^"\\\n]|\\.)*)"/g)]; + return phrases.map((match) => match[1].replace('\\(.applicationName)', '${applicationName}')); +} + +/** + * Fails prebuild, with the reason, when the Swift file and the strings drift + * apart: a title whose English is not the catalog's, or a phrase some language + * does not translate. + * @param {string} projectRoot + * @param {string} swift + */ +function checkSources(projectRoot, swift) { + for (const { catalogKey, english } of TITLES) { + const catalog = catalogText(projectRoot, 'en', catalogKey); + if (catalog !== english) { + throw new Error( + `with-app-intents: src/locales/en.json "${catalogKey}" is "${catalog}", but ${SWIFT_FILE} and this plugin say "${english}". Update both.`, + ); + } + if (!swift.includes(quoted(english))) { + throw new Error(`with-app-intents: ${SWIFT_FILE} no longer shows "${english}"; update TITLES in plugins/with-app-intents.js.`); + } + } + const phrases = swiftPhrases(swift); + if (phrases.length === 0) throw new Error(`with-app-intents: found no Siri phrases in ${SWIFT_FILE}.`); + for (const language of PHRASE_LANGUAGES) { + const strings = readFileSync(path.join(projectRoot, SOURCE_DIR, `${language}.lproj`, PHRASES_FILE), 'utf8'); + for (const phrase of phrases) { + if (!strings.includes(`${quoted(phrase)} =`)) { + throw new Error(`with-app-intents: ${language}.lproj/${PHRASES_FILE} does not translate "${phrase}".`); + } + } + } +} + +/** + * @param {string} projectRoot + * @param {Language} language + */ +function titlesStrings(projectRoot, language) { + const lines = TITLES.map( + ({ catalogKey, english }) => `${quoted(english)} = ${quoted(catalogText(projectRoot, language, catalogKey))};`, + ); + return `/* Written by plugins/with-app-intents.js from src/locales/${language}.json. Do not edit. */\n${lines.join('\n')}\n`; +} + +/** + * @param {string} file + * @param {string | Buffer} contents + */ +function writeIfChanged(file, contents) { + mkdirSync(path.dirname(file), { recursive: true }); + if (existsSync(file) && readFileSync(file).equals(Buffer.from(contents))) return; + writeFileSync(file, contents); +} + +/** + * Copies the Swift file and the strings into ios//, and writes the translated titles. + * @param {string} projectRoot apps/mobile + * @param {string} appDirectory ios/ + */ +function writeNativeFiles(projectRoot, appDirectory) { + const source = path.join(projectRoot, SOURCE_DIR); + const swift = readFileSync(path.join(source, SWIFT_FILE), 'utf8'); + checkSources(projectRoot, swift); + writeIfChanged(path.join(appDirectory, SWIFT_FILE), swift); + for (const language of PHRASE_LANGUAGES) { + writeIfChanged( + path.join(appDirectory, `${language}.lproj`, PHRASES_FILE), + readFileSync(path.join(source, `${language}.lproj`, PHRASES_FILE)), + ); + } + for (const language of TRANSLATED) { + writeIfChanged(path.join(appDirectory, `${language}.lproj`, TITLES_FILE), titlesStrings(projectRoot, language)); + } +} + +// --------------------------------------------------------------------------- +// The Xcode project. The `xcode` package's object model, only the parts used. + +/** @typedef {Parameters[0]['project']} XcodeProject */ +/** @typedef {{ value: string; comment?: string }} Child */ +/** @typedef {Record & { isa?: string; name?: string; children?: Child[]; files?: Child[]; buildPhases?: Child[] }} PbxObject */ +/** @typedef {Record} Section */ +/** + * @typedef {{ + * hash: { project: { objects: Record } }; + * generateUuid(): string; + * getFirstProject(): { firstProject: { mainGroup: string } }; + * getPBXGroupByKey(key: string): PbxObject | undefined; + * addKnownRegion(name: string): void; + * }} PbxProject + */ + +/** + * @param {PbxProject} project + * @param {string} isa + * @returns {Section} + */ +function section(project, isa) { + const objects = project.hash.project.objects; + return (objects[isa] ??= {}); +} + +/** + * The app's group (ios// in the navigator), found by name under the main group. + * @param {PbxProject} project + * @param {string} projectName + * @returns {PbxObject & { children: Child[] }} + */ +function appGroup(project, projectName) { + const main = project.getPBXGroupByKey(project.getFirstProject().firstProject.mainGroup); + const child = main?.children?.find((entry) => entry.comment === projectName); + const group = child ? project.getPBXGroupByKey(child.value) : undefined; + if (!group?.children) throw new Error(`with-app-intents: the Xcode project has no "${projectName}" group.`); + return /** @type {PbxObject & { children: Child[] }} */ (group); +} + +/** + * @param {PbxProject} project + * @param {string} targetUuid + * @returns {PbxObject & { files: Child[] }} + */ +function resourcesPhase(project, targetUuid) { + const target = section(project, 'PBXNativeTarget')[targetUuid]; + const phases = typeof target === 'object' ? target.buildPhases : undefined; + const resources = section(project, 'PBXResourcesBuildPhase'); + for (const phase of phases ?? []) { + const entry = resources[phase.value]; + if (typeof entry === 'object' && entry.files) return /** @type {PbxObject & { files: Child[] }} */ (entry); + } + throw new Error('with-app-intents: the app target has no Copy Bundle Resources phase.'); +} + +/** + * One localized resource, as Xcode itself stores it: a PBXVariantGroup named + * after the file in the app's group, a child per language pointing at + * /.lproj/ (the bundle's .lproj comes from that folder), + * and one entry in Copy Bundle Resources. Languages already there are left alone. + * @param {PbxProject} project + * @param {{ projectName: string; targetUuid: string; fileName: string; languages: readonly Language[] }} options + */ +function addLocalizedResource(project, { projectName, targetUuid, fileName, languages }) { + const group = appGroup(project, projectName); + const variants = section(project, 'PBXVariantGroup'); + let variantKey = group.children.find((child) => child.comment === fileName && typeof variants[child.value] === 'object')?.value; + + if (!variantKey) { + variantKey = project.generateUuid(); + variants[variantKey] = { isa: 'PBXVariantGroup', children: [], name: fileName, sourceTree: '""' }; + variants[`${variantKey}_comment`] = fileName; + group.children.push({ value: variantKey, comment: fileName }); + + const buildFile = project.generateUuid(); + const comment = `${fileName} in Resources`; + const buildFiles = section(project, 'PBXBuildFile'); + buildFiles[buildFile] = { isa: 'PBXBuildFile', fileRef: variantKey, fileRef_comment: fileName }; + buildFiles[`${buildFile}_comment`] = comment; + resourcesPhase(project, targetUuid).files.push({ value: buildFile, comment }); + } + + const variant = /** @type {PbxObject} */ (variants[variantKey]); + const children = (variant.children ??= []); + const references = section(project, 'PBXFileReference'); + for (const language of languages) { + if (children.some((child) => child.comment === language)) continue; + const reference = project.generateUuid(); + references[reference] = { + isa: 'PBXFileReference', + lastKnownFileType: 'text.plist.strings', + name: language, + path: `"${projectName}/${language}.lproj/${fileName}"`, + sourceTree: '""', + }; + references[`${reference}_comment`] = language; + children.push({ value: reference, comment: language }); + project.addKnownRegion(language); + } +} + +/** + * Adds the intents and their strings to the app target. Exported for a check + * against a bare Expo template; calling it again changes nothing. + * @param {XcodeProject} project + * @param {string} projectName + * @returns {XcodeProject} + */ +function addAppIntentsToProject(project, projectName) { + const { uuid: targetUuid } = IOSConfig.XcodeUtils.getApplicationNativeTarget({ project, projectName }); + // Skips itself when the file is already in the group. + IOSConfig.XcodeUtils.addBuildSourceFileToGroup({ + filepath: `${projectName}/${SWIFT_FILE}`, + groupName: projectName, + project, + targetUuid, + }); + // `import AppIntents` would link it anyway; the explicit dependency is what the + // metadata processor looks for before it extracts anything. Skips itself when present. + IOSConfig.XcodeUtils.addFramework({ project, projectName, framework: 'AppIntents.framework' }); + + const pbx = /** @type {PbxProject} */ (/** @type {unknown} */ (project)); + addLocalizedResource(pbx, { projectName, targetUuid, fileName: PHRASES_FILE, languages: PHRASE_LANGUAGES }); + addLocalizedResource(pbx, { projectName, targetUuid, fileName: TITLES_FILE, languages: TRANSLATED }); + return project; +} + +/** @type {import('expo/config-plugins').ConfigPlugin} */ +const withAppIntents = (config) => { + for (const language of TRANSLATED) { + const locale = config.locales?.[language]; + const ios = typeof locale === 'object' && locale !== null ? locale.ios : undefined; + if (typeof ios === 'object' && ios !== null && TITLES_FILE in ios) { + throw new Error(`with-app-intents: locales.${language}.ios already has "${TITLES_FILE}"; both would write ${language}.lproj/${TITLES_FILE}.`); + } + } + config = withDangerousMod(config, [ + 'ios', + (mod) => { + const { projectRoot, platformProjectRoot } = mod.modRequest; + const projectName = mod.modRequest.projectName ?? IOSConfig.XcodeUtils.getProjectName(projectRoot); + writeNativeFiles(projectRoot, path.join(platformProjectRoot, projectName)); + return mod; + }, + ]); + return withXcodeProject(config, (mod) => { + const projectName = mod.modRequest.projectName ?? IOSConfig.XcodeUtils.getProjectName(mod.modRequest.projectRoot); + mod.modResults = addAppIntentsToProject(mod.modResults, projectName); + return mod; + }); +}; + +module.exports = { withAppIntents, addAppIntentsToProject }; diff --git a/apps/mobile/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index 1a068ad6a..c815b29bb 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -26,6 +26,7 @@ import { shareJson } from '@/lib/export'; import { disablePush, enablePush, readPushState, type PushState } from '@/lib/push'; import { createPushSwitch, type PushSwitchView } from '@/lib/push-switch'; import { useSettings, type ThemePreference } from '@/lib/settings'; +import { storeKit } from '@/lib/store-kit'; import { fetchSubscription, type Subscription } from '@/lib/subscription'; import { supabaseEnv } from '@/lib/supabase-config'; import { websitePage } from '@/lib/website'; @@ -384,6 +385,8 @@ function PlanBadge({ state }: { state: BillingStateName }) { function SubscriptionCard({ userId }: { userId: string | undefined }) { const { t } = useTranslation(); const { colors } = useTheme(); + const router = useRouter(); + const canBuy = storeKit() !== null; const [subscription, setSubscription] = useState(null); const [failed, setFailed] = useState(false); @@ -449,6 +452,8 @@ function SubscriptionCard({ userId }: { userId: string | undefined }) { {planDescription(t, subscription)} + {/* Basic and Pro through the App Store, where the build has StoreKit (plans.tsx). */} + {canBuy ? router.push('/plans')} /> : null} )} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 1a5911499..6bfb31cb4 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -14,6 +14,8 @@ import { KeyboardDoneBar } from '@/components/app-text-input'; import { TimerRunner } from '@/components/timer-runner'; import { ToastHost, ToastProvider } from '@/components/toast'; import { AuthProvider, useAuth } from '@/lib/auth'; +import { useApplePlatform } from '@/lib/use-apple-platform'; +import { useHandoff } from '@/lib/handoff'; import { DebateProvider } from '@/lib/debate-context'; import { useFirstRun } from '@/lib/use-first-run'; import { useKeyCommand } from '@/lib/key-commands'; @@ -69,6 +71,10 @@ function Root() { // A phone that opted in registers again on launch and when the language changes (see refreshPush). const userId = session?.user.id; useFirstRun(ready ? userId ?? null : null); + // Notification taps, Live Activity cleanup, purchases and Handoff (use-apple-platform.ts). + useApplePlatform(ready, userId ?? null); + // Handoff: the open screen continues on a Mac (debateowl.com) or an iPad (development build only). + useHandoff(ready); // Home Screen quick actions (development build with expo-quick-actions; nothing in Expo Go). useQuickActions(ready); // Text and pages shared from other apps (development build with the share extension; nothing in Expo Go). @@ -133,6 +139,8 @@ function Root() { + {/* Basic and Pro through the App Store (purchases.ts). */} + {/* Ollie's welcome and the first-run tour, at full height so the owl and the buttons both fit. */} {/* Full screen above the tabs, as in mock 07. */} diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index da8811936..ab44b88df 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -79,7 +79,7 @@ export default function AskScreen() { const { t } = useTranslation(); const split = useLayoutClass() !== 'phone'; const router = useRouter(); - const { prompt, chat: resumeChatId } = useLocalSearchParams<{ prompt?: string; chat?: string }>(); + const { prompt, chat: resumeChatId, q } = useLocalSearchParams<{ prompt?: string; chat?: string; q?: string | string[] }>(); const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { session } = useAuth(); @@ -383,6 +383,16 @@ export default function AskScreen() { void actions.current?.send(prompt, []); }, [prompt, userId]); + // Siri's "Ask DebateOwl" opens Ask with the question filled in; the student sends it, so arriving spends nothing. + const prefilled = useRef(false); + useEffect(() => { + // A link may repeat the parameter; Expo Router then gives an array. + const question = (Array.isArray(q) ? q[0] : q)?.trim(); + if (prefilled.current || prompt || !question) return; + prefilled.current = true; + setInput(question); + }, [q, prompt, setInput]); + // Retrying replaces the unanswered question instead of asking it twice. const retry = (question: string, files?: AskAttachment[]) => { const last = messages[messages.length - 1]; diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx index 355ca57f6..4479c3d1d 100644 --- a/apps/mobile/src/app/attack/index.tsx +++ b/apps/mobile/src/app/attack/index.tsx @@ -70,7 +70,7 @@ export default function AttackScreen() { return next; }), []); - const liveRun = useLiveRun(); + const liveRun = useLiveRun({ tool: 'attack', parts: (draft) => attackDraftSections(draft, t).length }); const endLiveRun = liveRun.end; const abortActive = useCallback(() => { requests.current += 1; @@ -132,7 +132,7 @@ export default function AttackScreen() { language: outputLanguage, debateContext, signal: controller.signal, - live: liveRun.begin(), + live: liveRun.begin(input.caseText), }); // Review choices cover one request, as on the website. resetContextChoices('attack'); @@ -155,6 +155,7 @@ export default function AttackScreen() { if (!isCurrent()) return; const key = keepOpenResult({ kind: 'attack', savedId, title, side: 'neutral', debateId, createdAt: Date.now(), input, output: result, sources: discoveredSources }); const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + liveRun.complete(); router.replace({ pathname: '/attack/[id]', params: { @@ -169,7 +170,7 @@ export default function AttackScreen() { const kind = cause instanceof AIError ? cause.kind : 'failed'; if (!(cause instanceof AIError)) console.warn('Attack failed:', cause instanceof Error ? cause.message : cause); if (kind === 'debateMissing') void refresh(); - liveRun.end(); + liveRun.end('failed'); setPhase('form'); // A limit or model refusal opens the website's dialog as a sheet, not an inline error. if (openRefusal(router, cause, 'attack')) return; diff --git a/apps/mobile/src/app/brainstorm/index.tsx b/apps/mobile/src/app/brainstorm/index.tsx index 42f58cf0d..08344fecb 100644 --- a/apps/mobile/src/app/brainstorm/index.tsx +++ b/apps/mobile/src/app/brainstorm/index.tsx @@ -64,7 +64,7 @@ export default function BrainstormScreen() { return next; }), []); - const liveRun = useLiveRun(); + const liveRun = useLiveRun({ tool: 'brainstorm', parts: (draft) => brainstormDraftSections(draft, t).length }); const endLiveRun = liveRun.end; const abortActive = useCallback(() => { requests.current += 1; @@ -113,7 +113,7 @@ export default function BrainstormScreen() { language: outputLanguage, debateContext, signal: controller.signal, - live: liveRun.begin(), + live: liveRun.begin(input.motion), }); // Review choices cover one request, as on the website. resetContextChoices('brainstorm'); @@ -145,6 +145,7 @@ export default function BrainstormScreen() { sources: discoveredSources, }); const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + liveRun.complete(); router.replace({ pathname: '/brainstorm/[id]', params: { @@ -160,7 +161,7 @@ export default function BrainstormScreen() { if (!(cause instanceof AIError)) console.warn('Brainstorm failed:', cause instanceof Error ? cause.message : cause); // A missing debate was deleted elsewhere; reload the list so the app stops using it. if (kind === 'debateMissing') void refresh(); - liveRun.end(); + liveRun.end('failed'); setPhase('form'); // A limit or model refusal opens the website's dialog as a sheet, not an inline error. if (openRefusal(router, cause, 'brainstorm')) return; diff --git a/apps/mobile/src/app/factoids/index.tsx b/apps/mobile/src/app/factoids/index.tsx index 014e544e4..d1c72767d 100644 --- a/apps/mobile/src/app/factoids/index.tsx +++ b/apps/mobile/src/app/factoids/index.tsx @@ -70,7 +70,7 @@ export default function FactoidsScreen() { return next; }), []); - const liveRun = useLiveRun(); + const liveRun = useLiveRun({ tool: 'factoids', parts: (draft) => factoidsDraftSections(draft, t).length }); const endLiveRun = liveRun.end; const abortActive = useCallback(() => { requests.current += 1; @@ -121,7 +121,7 @@ export default function FactoidsScreen() { language: outputLanguage, debateContext, signal: controller.signal, - live: liveRun.begin(), + live: liveRun.begin(input.claim), }); // Review choices cover one request, as on the website. resetContextChoices('factoids'); @@ -153,6 +153,7 @@ export default function FactoidsScreen() { sources: discoveredSources, }); const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); + liveRun.complete(); router.replace({ pathname: '/factoids/[id]', params: { @@ -167,7 +168,7 @@ export default function FactoidsScreen() { const kind = cause instanceof AIError ? cause.kind : 'failed'; if (!(cause instanceof AIError)) console.warn('Factoids failed:', cause instanceof Error ? cause.message : cause); if (kind === 'debateMissing') void refresh(); - liveRun.end(); + liveRun.end('failed'); setPhase('form'); // A limit or model refusal opens the website's dialog as a sheet, not an inline error. if (openRefusal(router, cause, 'factoids')) return; diff --git a/apps/mobile/src/app/generate.tsx b/apps/mobile/src/app/generate.tsx index 8549ce152..309ca4158 100644 --- a/apps/mobile/src/app/generate.tsx +++ b/apps/mobile/src/app/generate.tsx @@ -51,7 +51,7 @@ const lastForm = sessionSlot(() => DEFAULT_FORM); export default function GenerateScreen() { const { t } = useTranslation(); const router = useRouter(); - const { from } = useLocalSearchParams<{ from?: string }>(); + const { from, motion: motionParam } = useLocalSearchParams<{ from?: string; motion?: string | string[] }>(); const { session } = useAuth(); const userId = session?.user.id ?? null; const { debates, current, oneOff, create, switchTo, refresh } = useDebates(); @@ -64,7 +64,7 @@ export default function GenerateScreen() { const requests = useRef(0); const abort = useRef(null); const live = useRef(true); - const liveRun = useLiveRun(); + const liveRun = useLiveRun({ tool: 'case', parts: (draft) => generateDraftSections(draft, t).length }); // Dictation fills the motion, as the website's microphone beside it does. The // ref holds words that arrived before the last ones were drawn. const motionRef = useRef(form.motion); @@ -103,6 +103,17 @@ export default function GenerateScreen() { if (shouldPrefillMotion(lastForm.get().motion, current.motion, debates.map((debate) => debate.motion))) setForm({ motion: current.motion }); }, [current, debates, oneOff, setForm]); + // A motion from Siri or Shortcuts (debatelab://generate?motion=) fills the form once, over the Current Debate's. + const motionApplied = useRef(false); + useEffect(() => { + // A link may repeat the parameter; Expo Router then gives an array. + const motion = (Array.isArray(motionParam) ? motionParam[0] : motionParam)?.trim(); + if (motionApplied.current || !motion) return; + motionApplied.current = true; + setForm({ motion: motion.slice(0, MAX_MOTION_LENGTH) }); + setMotionError(false); + }, [motionParam, setForm]); + const start = async (input: GenerateInput, debateId: string | null, forUser: string) => { abortActive(); const request = requests.current; @@ -122,7 +133,7 @@ export default function GenerateScreen() { language: outputLanguage, debateContext, signal: controller.signal, - live: liveRun.begin(), + live: liveRun.begin(input.motion), }); // Review choices cover one request, as on the website. resetContextChoices('generate'); @@ -156,6 +167,7 @@ export default function GenerateScreen() { const notice = arrivalNotices({ recordFailed, sourcesFailed: sources.failed, assessed, truncated }); // The case just made celebrates when its document opens; reopening it later does not. markCelebration(key); + liveRun.complete(); router.replace({ pathname: '/case/[id]', params: { id: key, from: from ?? 'prepare', ...(notice ? { notice } : {}), ...(sources.failed > 0 ? { failed: String(sources.failed) } : {}) }, @@ -166,7 +178,7 @@ export default function GenerateScreen() { if (!(cause instanceof AIError)) console.warn('Generate failed:', cause instanceof Error ? cause.message : cause); // A missing debate was deleted elsewhere; reload the list so the app stops using it. if (kind === 'debateMissing') void refresh(); - liveRun.end(); + liveRun.end('failed'); setPhase('form'); // A limit or model refusal opens the website's dialog as a sheet, not an inline error. if (openRefusal(router, cause, 'generate')) return; diff --git a/apps/mobile/src/app/limit.tsx b/apps/mobile/src/app/limit.tsx index 23dc35557..dead9fd22 100644 --- a/apps/mobile/src/app/limit.tsx +++ b/apps/mobile/src/app/limit.tsx @@ -14,6 +14,7 @@ import { formatDate } from '@/lib/dates'; import { isProOnlyModel } from '@/lib/feature-config'; import { LIMIT_TITLES, limitMessage, limitVariant } from '@/lib/refusal'; import { useSettings } from '@/lib/settings'; +import { storeKit } from '@/lib/store-kit'; import { fetchSubscription, type Subscription } from '@/lib/subscription'; const FEATURES = new Set(['generate', 'attack', 'brainstorm', 'factoids', 'ask', 'sparring']); @@ -21,8 +22,8 @@ const number = (value: string | undefined) => (value !== undefined && value !== /** * The website's limit-reached and model-restricted dialogs as a sheet, with - * its words and next steps. Upgrading waits for in-app purchase, so where the - * website offers Pro, the sheet opens the plan in You instead; a paid plan's + * its words and next steps. Where the website offers Pro, the sheet opens the + * App Store plans (plans.tsx), or the plan in You in a build without StoreKit; a paid plan's * daily cap and the weekly allowance only say when access returns, as there. */ export default function LimitSheet() { @@ -50,10 +51,14 @@ export default function LimitSheet() { }, [userId, params.kind]); const close = () => router.back(); + // With in-app purchase the next step is the plans themselves; otherwise the plan in You. + const canBuy = storeKit() !== null; const seePlan = () => { router.back(); - router.navigate('/you'); + if (canBuy) router.push('/plans'); + else router.navigate('/you'); }; + const seePlanLabel = canBuy ? t('mobile.plans.see') : t('mobile.ask.openYou'); if (params.kind === 'model') { const feature = FEATURES.has(params.feature as FeatureTab) ? (params.feature as FeatureTab) : null; @@ -83,7 +88,7 @@ export default function LimitSheet() { void switchToLow()} testID="limit-primary" /> - + ); @@ -112,7 +117,7 @@ export default function LimitSheet() { ) : ( <> - + diff --git a/apps/mobile/src/app/plans.tsx b/apps/mobile/src/app/plans.tsx new file mode 100644 index 000000000..cadcea712 --- /dev/null +++ b/apps/mobile/src/app/plans.tsx @@ -0,0 +1,266 @@ +import { useRouter } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { PrimaryButton, TextButton } from '@/components/buttons'; +import { Icon } from '@/components/icon'; +import { Owl } from '@/components/owl'; +import { SheetScrollView } from '@/components/sheet'; +import { SuccessCheck } from '@/components/success-check'; +import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; +import { haptics } from '@/lib/haptics'; +import { buy, loadProducts, manageSubscriptions, restore, type PurchaseOutcome, type PurchaseTier } from '@/lib/purchases'; +import type { StoreProduct, SubscriptionPeriod } from '@/lib/store-kit'; +import { websitePage } from '@/lib/website'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +type Notice = { tone: 'success' | 'info' | 'error'; text: string } | null; + +// The server's daily limits for each plan (supabase/functions/_shared/usage-limits.ts). +const DAILY_USES: Record = { basic: 20, pro: 50 }; + +/** + * Basic and Pro through the App Store (purchases.ts): prices in the + * student's own currency from StoreKit, Subscribe, Restore and Manage + * subscription, and the renewal terms App Review asks for beside the + * button. The server applies a purchase to the signed-in account; until App + * Store Connect and the server are set up (docs/apple-platform.md), the + * screen says purchases are not available yet. + */ +export default function PlansSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const { colors } = useTheme(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [products, setProducts] = useState | null | undefined>(undefined); + const [tier, setTier] = useState('pro'); + const [busy, setBusy] = useState<'buy' | 'restore' | null>(null); + const [notice, setNotice] = useState(null); + // A purchase can finish after the sheet closed; it then changes nothing on screen. + const mounted = useRef(true); + const closing = useRef | null>(null); + useEffect(() => () => { + mounted.current = false; + if (closing.current) clearTimeout(closing.current); + }, []); + + useEffect(() => { + let live = true; + loadProducts() + .then((next) => { + if (live) setProducts(next); + }) + .catch((cause: unknown) => { + console.warn('Could not load the App Store products:', cause instanceof Error ? cause.message : cause); + if (live) setProducts(null); + }); + return () => { + live = false; + }; + }, []); + + // "a month" after a price; "1 week" for how long an introductory offer lasts. + const period = (value: SubscriptionPeriod) => t(`mobile.plans.period.${value.unit}`, { count: value.value }); + const length = (value: SubscriptionPeriod, periods = 1) => t(`mobile.plans.length.${value.unit}`, { count: value.value * periods }); + + const outcomeNotice = (outcome: PurchaseOutcome): Notice => { + switch (outcome.kind) { + case 'purchased': + return { tone: 'success', text: t('mobile.plans.purchased') }; + case 'pending': + return { tone: 'info', text: t('mobile.plans.pending') }; + case 'cancelled': + return null; + case 'unavailable': + return { tone: 'info', text: t('mobile.plans.unavailable') }; + case 'failed': + return { tone: 'error', text: t(outcome.reason === 'network' ? 'mobile.plans.offline' : outcome.reason === 'accountChanged' ? 'mobile.plans.accountChanged' : 'mobile.plans.failed') }; + } + }; + + const subscribe = async () => { + if (!userId || busy) return; + setBusy('buy'); + setNotice(null); + const outcome = await buy(tier, userId); + if (!mounted.current) return; + setBusy(null); + setNotice(outcomeNotice(outcome)); + if (outcome.kind === 'purchased') { + haptics.success(); + closing.current = setTimeout(() => { + if (mounted.current) router.back(); + }, 1_400); + } else if (outcome.kind === 'failed') { + haptics.warning(); + } + }; + + const restorePurchases = async () => { + if (!userId || busy) return; + setBusy('restore'); + setNotice(null); + const outcome = await restore(userId); + if (!mounted.current) return; + setBusy(null); + if (outcome.kind === 'restored') { + if (outcome.applied > 0) haptics.success(); + setNotice( + outcome.applied > 0 + ? { tone: 'success', text: t('mobile.plans.restored') } + : { tone: 'info', text: t(outcome.otherAccount > 0 ? 'mobile.plans.restoredOtherAccount' : 'mobile.plans.nothingToRestore') }, + ); + } else if (outcome.kind === 'unavailable') { + setNotice({ tone: 'info', text: t('mobile.plans.unavailable') }); + } else if (outcome.kind === 'failed') { + setNotice({ tone: 'error', text: t(outcome.reason === 'network' ? 'mobile.plans.offline' : 'mobile.plans.failed') }); + } + }; + + const plan = (key: PurchaseTier) => { + const product = products?.[key]; + const selected = key === tier; + const features = key === 'pro' + ? [t('mobile.plans.dailyUses', { count: DAILY_USES.pro }), t('pricingModal.proFeature2'), t('pricingModal.proFeature3'), t('pricingModal.proFeature5')] + : [t('mobile.plans.dailyUses', { count: DAILY_USES.basic }), t('pricingModal.freeFeature2'), t('pricingModal.proFeature5')]; + const intro = product?.introOffer && product.isEligibleForIntroOffer ? product.introOffer : null; + return ( + { + if (!selected) haptics.select(); + setTier(key); + }} + testID={`plan-${key}`} + style={[styles.plan, { backgroundColor: colors.card, borderColor: selected ? colors.primaryFill : colors.border }]}> + + + {product?.displayName || t(key === 'pro' ? 'settings.tierPro' : 'settings.tierBasic')} + + {key === 'pro' ? ( + + + {t('pricingModal.recommended')} + + + ) : null} + + + {selected ? : null} + + + {product ? ( + + {product.period ? `${product.displayPrice} ${period(product.period)}` : product.displayPrice} + + ) : null} + {intro ? ( + + {intro.paymentMode === 'freeTrial' + ? t('mobile.plans.freeTrial', { period: length(intro.period, intro.periodCount) }) + : t('mobile.plans.introPrice', { + // Pay as you go is a price per period; pay up front is one price for the whole offer. + price: intro.paymentMode === 'payAsYouGo' ? `${intro.displayPrice} ${period(intro.period)}` : intro.displayPrice, + period: length(intro.period, intro.periodCount), + })} + + ) : null} + + {features.map((feature) => ( + + {/* Mint with a check mark, as success always is. */} + + {feature} + + ))} + + + ); + }; + + const selected = products?.[tier]; + return ( + + + + + {t('mobile.plans.title')} + + + {t('pricingModal.subtitle')} + + + + {plan('pro')} + {plan('basic')} + + {products === null ? ( + + {t('mobile.plans.unavailable')} + + ) : null} + + void subscribe()} + testID="plans-subscribe" + /> + + {notice ? ( + + {notice.tone === 'success' ? : null} + + {notice.text} + + + ) : null} + + {/* What App Review asks to see beside an auto-renewing subscription. */} + + {t('mobile.plans.renewalTerms')} + + + + void restorePurchases()} /> + void manageSubscriptions()} /> + + void WebBrowser.openBrowserAsync(websitePage('/terms'))} /> + void WebBrowser.openBrowserAsync(websitePage('/privacy'))} /> + + + + ); +} + +const styles = StyleSheet.create({ + head: { alignItems: 'center', gap: 8, paddingTop: 4 }, + title: { fontSize: 24, lineHeight: 30, textAlign: 'center', letterSpacing: -0.3 }, + body: { fontSize: 16, lineHeight: 23, textAlign: 'center' }, + plan: { borderWidth: 2, borderRadius: radius.card, padding: 16, gap: 6 }, + planHead: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + planName: { fontSize: 20, lineHeight: 25 }, + badge: { borderRadius: 999, paddingHorizontal: 9, paddingVertical: 3 }, + badgeText: { fontSize: 12 }, + radio: { width: 24, height: 24, borderRadius: 12, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + price: { fontSize: 17, lineHeight: 23 }, + features: { gap: 6, marginTop: 4 }, + feature: { flexDirection: 'row', alignItems: 'flex-start', gap: 8 }, + small: { fontSize: 14.5, lineHeight: 20 }, + flex: { flex: 1 }, + center: { textAlign: 'center' }, + notice: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + terms: { fontSize: 12.5, lineHeight: 17, textAlign: 'center' }, + links: { alignItems: 'center', gap: 0 }, + legal: { flexDirection: 'row', gap: 12 }, +}); diff --git a/apps/mobile/src/app/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx index 230575a64..3da8064da 100644 --- a/apps/mobile/src/app/sign-in.tsx +++ b/apps/mobile/src/app/sign-in.tsx @@ -1,34 +1,37 @@ +import * as AppleAuthentication from 'expo-apple-authentication'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View, useWindowDimensions, type TextInput } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { SuccessCheck } from '@/components/success-check'; -import { MainActionButton, TextButton } from '@/components/buttons'; +import { MainActionButton, SecondaryButton, TextButton } from '@/components/buttons'; import { Field } from '@/components/field'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { signInErrorKey } from '@/lib/sign-in-errors'; +import { appleSignInAvailable, googleSignInAvailable, signInWithApple, signInWithGoogle } from '@/lib/social-sign-in'; import { Icon } from '@/components/icon'; import { OwlMark } from '@/components/owl'; import { useTheme } from '@/theme/use-theme'; type Mode = 'email' | 'demo'; -type Status = 'idle' | 'sending' | 'code-sent' | 'verifying' | 'signing-in' | 'error'; +type Status = 'idle' | 'sending' | 'code-sent' | 'verifying' | 'signing-in' | 'social' | 'error'; // The website's checks (src/pages/LoginPage.tsx). const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const DEMO_CODE_PATTERN = /^DEMO-[A-Z0-9]{4}-[A-Z0-9]{4}$/; /** - * Email code and demo code, as on the website. Google sign-in needs an OAuth - * redirect back into the app, which Expo Go cannot do, so it arrives with the - * first development build; Sign in with Apple comes after that. + * Email code and demo code, as on the website, then Sign in with Apple and + * Google where the build can finish them (social-sign-in.ts). Apple's button + * comes first and is Apple's own, as App Review asks of an app that offers + * Google too. */ export default function SignInScreen() { const { t } = useTranslation(); - const { colors } = useTheme(); + const { colors, scheme } = useTheme(); const insets = useSafeAreaInsets(); // On iPad the form sits in the middle of the screen instead of under the status bar. const centred = useWindowDimensions().width >= 700; @@ -43,7 +46,19 @@ export default function SignInScreen() { const [error, setError] = useState(''); const otpRef = useRef(null); - const busy = status === 'sending' || status === 'verifying' || status === 'signing-in'; + const busy = status === 'sending' || status === 'verifying' || status === 'signing-in' || status === 'social'; + const [apple, setApple] = useState(false); + const google = googleSignInAvailable(); + + useEffect(() => { + let live = true; + void appleSignInAvailable().then((available) => { + if (live) setApple(available); + }); + return () => { + live = false; + }; + }, []); useEffect(() => { if (codeSent) otpRef.current?.focus(); @@ -101,6 +116,18 @@ export default function SignInScreen() { } }; + const social = async (provider: () => Promise<'signed-in' | 'cancelled'>) => { + setError(''); + setStatus('social'); + try { + // Signed in, the auth provider moves on from this screen. + if ((await provider()) === 'cancelled') setStatus('idle'); + } catch (cause) { + console.warn('Social sign-in failed:', cause instanceof Error ? cause.message : cause); + fail(signInErrorKey(cause, 'mobile.signIn.socialError')); + } + }; + return ( ) : null} + {(apple || google) && !codeSent ? ( + + + + + {t('login.orContinueWith')} + + + + {apple ? ( + + void social(signInWithApple)} + /> + + ) : null} + {google ? void social(signInWithGoogle)} testID="google-sign-in" /> : null} + + ) : null} + {sessionExpired && status !== 'code-sent' ? ( // The website's login notice after the server ended the session; it goes once a sign-in succeeds. @@ -264,4 +315,9 @@ const styles = StyleSheet.create({ form: { gap: 18 }, secondary: { gap: 2 }, message: { fontSize: 15, lineHeight: 21 }, + divider: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + rule: { flex: 1, height: 2, borderRadius: 1 }, + dividerText: { fontSize: 14 }, + appleButton: { height: 52 }, + dimmed: { opacity: 0.5 }, }); diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 36d1494e4..2ab012f49 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -40,6 +40,7 @@ import { Text } from '@/components/text'; import { TimerRing } from '@/components/timer-ring'; import { ToolTitle, WorkingIn } from '@/components/tool-form'; import { appendDictation, OpponentVoiceBar, VoiceMicControl, VoiceModeSwitch } from '@/components/voice'; +import { noteFinishedResult } from '@/lib/review-prompt'; import { useFocusedKeyCommand } from '@/lib/key-commands'; import { useLatest } from '@/lib/live-value'; import { AIError, isAbortError, untilAborted } from '@/lib/ai-request'; @@ -194,6 +195,7 @@ export default function SparringScreen() { if (!completed) return; commit(completed); setCelebrating(completed.id); + noteFinishedResult(); // The server saved the judged round before it answered; file it in its debate. if (completed.debateId) { attachSparring(forUser, completed.id, completed.debateId, completed.userSide).catch((cause: unknown) => { diff --git a/apps/mobile/src/app/timer.tsx b/apps/mobile/src/app/timer.tsx index 46bfe6581..127067297 100644 --- a/apps/mobile/src/app/timer.tsx +++ b/apps/mobile/src/app/timer.tsx @@ -28,6 +28,7 @@ import { confirmDestructive } from '@/lib/confirm'; import { haptics } from '@/lib/haptics'; import { useLayoutClass } from '@/lib/layout'; import { formatFor, formatName, sideNameFor, speechStatus, type FormatChoice, type TimerSession, type Translate } from '@/lib/timer'; +import { askForTimerAlerts } from '@/lib/timer-alerts'; import { useKeepAwakeWhile } from '@/lib/timer-signals'; import { deleteSavedFormat, updateSession, useTimerStore } from '@/lib/timer-store'; import { useTheme } from '@/theme/use-theme'; @@ -86,6 +87,7 @@ export default function TimerScreen() { }; const toggle = () => { haptics.tap(); + if (!speechRunning) void askForTimerAlerts(); change((current) => ({ ...current, round: toggleSpeech(current.round, Date.now()) })); }; const reset = () => change((current) => ({ ...current, round: resetSpeech(current.round) })); @@ -95,6 +97,7 @@ export default function TimerScreen() { }; const prep = (side: PrepSide) => { haptics.tap(); + if (!prepRunning) void askForTimerAlerts(); change((current) => ({ ...current, round: togglePrep(current.round, side, Date.now()) })); }; const choose = (next: FormatChoice) => change((current) => ({ ...current, choice: next, round: initialRoundState(formatFor(next)) })); diff --git a/apps/mobile/src/components/live-run.tsx b/apps/mobile/src/components/live-run.tsx index a8503e4c8..4555e93c7 100644 --- a/apps/mobile/src/components/live-run.tsx +++ b/apps/mobile/src/components/live-run.tsx @@ -10,6 +10,9 @@ import { OwlAvatar, useReduceMotion, type OwlPose } from './owl'; import { Text } from './text'; import { ToolProgress } from './tool-form'; +import { beginGeneratingActivity, type GeneratingActivityHandle } from '@/lib/generating-activity'; +import type { GeneratingTool } from '@/lib/generating-activity-state'; +import { noteFinishedResult } from '@/lib/review-prompt'; import { motionSetting, SPRING } from '@/theme/motion'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -27,35 +30,72 @@ export interface LiveRunState { draft?: Record; } -/** One screen's live run: `begin` hands the request its updates; a later `begin` or `end` silences them. */ -export function useLiveRun() { +/** What a tool tells its Live Activity: which tool it is and how many parts a draft has. */ +export interface LiveRunActivity { + tool: GeneratingTool; + parts: (draft: Record) => number; +} + +/** + * One screen's live run: `begin` hands the request its updates; a later + * `begin` or `end` silences them. With `activity`, the run also shows on the + * Lock Screen and in the Dynamic Island (generating-activity.ts): `complete` + * marks it ready, `end('failed')` as failed, and any other end takes it away. + */ +export function useLiveRun(activity?: LiveRunActivity) { const [state, setState] = useState({}); const run = useRef(0); const live = useRef(true); + const lockScreen = useRef(null); + const activityRef = useRef(activity); + activityRef.current = activity; useEffect(() => () => { live.current = false; + lockScreen.current?.finish('cancelled'); }, []); - const begin = useCallback((): LiveGeneration => { + const begin = useCallback((title?: string): LiveGeneration => { const id = ++run.current; setState({}); + lockScreen.current?.finish('cancelled'); + const tool = activityRef.current; + const handle = tool && title ? beginGeneratingActivity(tool.tool, title) : null; + lockScreen.current = handle; + let phase: LivePhase | undefined; + let parts = 0; const current = () => live.current && run.current === id; return { onDraft: (draft) => { - if (current()) setState((previous) => ({ ...previous, phase: 'writing', draft })); + if (!current()) return; + setState((previous) => ({ ...previous, phase: 'writing', draft })); + phase = 'writing'; + parts = activityRef.current?.parts(draft) ?? parts; + handle?.update({ phase, parts }); }, - onPhase: (phase) => { - if (current()) setState((previous) => ({ ...previous, phase })); + onPhase: (next) => { + if (!current()) return; + setState((previous) => ({ ...previous, phase: next })); + phase = next; + handle?.update({ phase, parts }); }, }; }, []); - const end = useCallback(() => { + const end = useCallback((outcome: 'failed' | 'cancelled' = 'cancelled') => { run.current += 1; + lockScreen.current?.finish(outcome); + lockScreen.current = null; if (live.current) setState({}); }, []); - return { state, begin, end }; + /** The answer arrived: the Lock Screen says it is ready. The screen moves on to the document itself. */ + const complete = useCallback(() => { + lockScreen.current?.finish('done'); + lockScreen.current = null; + noteFinishedResult(); + }, []); + + return { state, begin, end, complete }; } /** Whole seconds since the run started, as the website's useElapsedSeconds. */ diff --git a/apps/mobile/src/components/timer-runner.tsx b/apps/mobile/src/components/timer-runner.tsx index cba9edd80..a1ebcfe9f 100644 --- a/apps/mobile/src/components/timer-runner.tsx +++ b/apps/mobile/src/components/timer-runner.tsx @@ -1,14 +1,17 @@ import { cueBetween, elapsedMs, isSpeechRunning, settlePrep } from '@debatelab/core/free-tools/round-timer'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { AppState } from 'react-native'; +import { AppState, Platform } from 'react-native'; import { syncTimerActivity } from '@/lib/live-activity'; import { formatFor, isActive, sideNameFor, timerActivityState, type TimerSession } from '@/lib/timer'; +import { useTimerAlerts } from '@/lib/timer-alerts'; import { useTimerSignals } from '@/lib/timer-signals'; import { loadTimer, updateSession, useTimerStore } from '@/lib/timer-store'; const TICK_MS = 250; +/** iOS runs a Live Activity's buttons from iOS 17; before that they would draw and do nothing. */ +const LIVE_ACTIVITY_BUTTONS = Platform.OS === 'ios' && Number.parseInt(String(Platform.Version), 10) >= 17; /** A longer gap between readings means the app was in the background. */ const GAP_MS = 2_000; /** How long the signals stay after the last clock stops, so a time-up signal plays to its end. */ @@ -44,12 +47,19 @@ export function TimerRunner() { paused: tools('timer.paused'), overtime: tools('timer.overtime'), prep: tools('timer.prepGroup'), + pause: tools('timer.pause'), + resume: t('mobile.liveActivity.resume'), + next: t('mobile.liveActivity.next'), ...idle, }, + controls: LIVE_ACTIVITY_BUTTONS, }); syncTimerActivity(state, idle); }, [format, i18n.language, loaded, session, t]); + // A clock that runs out while the phone is locked still says so (timer-alerts.ts). + useTimerAlerts(session, loaded); + const active = loaded && isActive(session.round); const [lingering, setLingering] = useState(false); useEffect(() => { diff --git a/apps/mobile/src/lib/ai-request.ts b/apps/mobile/src/lib/ai-request.ts index 3debe3c66..2f6705c12 100644 --- a/apps/mobile/src/lib/ai-request.ts +++ b/apps/mobile/src/lib/ai-request.ts @@ -113,6 +113,17 @@ export type FunctionResponse = Awaited>; const settledListeners = new Set<() => void>(); +let readyInstallId: string | null = null; + +/** + * This install's id (device-storage.ts's getInstallId), set once at launch by + * use-apple-platform.ts. Live runs ask for a "ready" push only once it is + * known, so the push goes to this phone and not every device on the account. + */ +export function setReadyPushInstall(installId: string): void { + readyInstallId = installId; +} + /** Called after every AI request ends, however it ends, as the website refreshes its usage count (refreshAIUsage). */ export function onAIRequestSettled(listener: () => void): () => void { settledListeners.add(listener); @@ -201,7 +212,12 @@ export function postAI( const startedAt = Date.now(); const resultKey = `req_${startedAt}_${Math.random().toString(36).slice(2, 8)}`; const feature = String(body.feature); - return postFunction('ai', { ...body, stream: true, resultKey }, options.signal, async (response, signal) => { + // notifyWhenReady: iOS suspends the app soon after it leaves the screen, so the + // server pushes "ready" once the answer is stored (Generate, Attack, Brainstorm + // and Factoids; it ignores the flag for the rest), to this install's phone + // only. push.ts hides that push while the app is on screen. + const notify = readyInstallId ? { notifyWhenReady: true, notifyInstallId: readyInstallId } : {}; + return postFunction('ai', { ...body, stream: true, resultKey, ...notify }, options.signal, async (response, signal) => { // A server from before live generation answers with the whole JSON body. if (!(response.headers.get('content-type') ?? '').includes('text/event-stream') || !response.body) { return parse(await functionOutput(response)); diff --git a/apps/mobile/src/lib/app-intent-links.test.ts b/apps/mobile/src/lib/app-intent-links.test.ts new file mode 100644 index 000000000..1692d4839 --- /dev/null +++ b/apps/mobile/src/lib/app-intent-links.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { continuedRoute, handoffTarget } from './app-intent-links'; + +describe('handoffTarget', () => { + it('continues a tool on its website page', () => { + expect(handoffTarget('/')).toEqual({ webPath: '/app', titleKey: 'nav.currentDebate' }); + expect(handoffTarget('/generate')?.webPath).toBe('/app/generate'); + expect(handoffTarget('/library/')?.webPath).toBe('/app/library'); + expect(handoffTarget('/you')?.webPath).toBe('/app/settings'); + expect(handoffTarget('/timer')?.webPath).toBe('/tools/debate-timer'); + expect(handoffTarget('/flowchart/claim')?.webPath).toBe('/app/flowchart'); + }); + + it('sends a saved document to its History tab and keeps share codes', () => { + expect(handoffTarget('/case/3f2a-9c')?.webPath).toBe('/app/history?tab=cases'); + expect(handoffTarget('/crossfire-round/abc')?.webPath).toBe('/app/history?tab=crossfire'); + expect(handoffTarget('/source/abc')?.webPath).toBe('/app/library'); + expect(handoffTarget('/share/Ab3dEf7hIj9kLm0n')?.webPath).toBe('/share/Ab3dEf7hIj9kLm0n'); + }); + + it('offers nothing for sign-in, unknown screens or odd ids', () => { + expect(handoffTarget('/sign-in')).toBeNull(); + expect(handoffTarget('/nowhere')).toBeNull(); + expect(handoffTarget('/case/a/b')).toBeNull(); + expect(handoffTarget('/case/a%2Fb')).toBeNull(); + expect(handoffTarget('/share/has space')).toBeNull(); + }); +}); + +describe('continuedRoute', () => { + it('opens routes Handoff offers and website links the app opens', () => { + expect(continuedRoute('/case/abc')).toBe('/case/abc'); + expect(continuedRoute('https://debateowl.com/app/library')).toBe('/library'); + expect(continuedRoute('https://debateowl.com/tools/debate-timer')).toBe('/timer'); + }); + + it('opens History for a result only the other device has', () => { + expect(continuedRoute('/case/local-1730000000000-1')).toBe('/history'); + expect(continuedRoute('/attack/local-1730000000000-2')).toBe('/history'); + }); + + it('refuses anything else', () => { + expect(continuedRoute('/sign-in')).toBeNull(); + expect(continuedRoute('//evil.example/case/abc')).toBeNull(); + expect(continuedRoute('debatelab://timer')).toBeNull(); + expect(continuedRoute('https://evil.example/app/library')).toBeNull(); + expect(continuedRoute(42)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/app-intent-links.ts b/apps/mobile/src/lib/app-intent-links.ts new file mode 100644 index 000000000..8ae9bf42b --- /dev/null +++ b/apps/mobile/src/lib/app-intent-links.ts @@ -0,0 +1,125 @@ +import { appPathForUrl, isShareCode } from './deep-links'; + +/** + * Where a screen of the app continues on another device (Handoff), and which + * handed-over screens the app opens. Like deep-links.ts, this file has no React + * Native or Expo imports, so it is unit-tested in Node. + * + * Siri and Shortcuts (plugins/app-intents/DebateOwlAppIntents.swift) need nothing + * here: they open the app's own debatelab:// links (timer, ask?q=, new-debate, + * generate?motion=, library, sparring), which Expo Router routes as it does any + * other. + */ + +export const WEBSITE_ORIGIN = 'https://debateowl.com'; + +export type HandoffTarget = { + /** A path on debateowl.com: a page of the website's src/App.tsx. */ + webPath: string; + /** The i18n key of the activity's title. */ + titleKey: string; +}; + +const target = (webPath: string, titleKey: string): HandoffTarget => ({ webPath, titleKey }); + +const DEBATE = target('/app', 'nav.currentDebate'); +const LIBRARY = target('/app/library', 'nav.library'); +const SETTINGS = target('/app/settings', 'nav.settings'); +const FLOWCHART = target('/app/flowchart', 'nav.flowchart'); +const TIMER = target('/tools/debate-timer', 'timer.title'); + +/** + * Expo Router pathnames (route groups left out) and their website page. The + * timer, speech time calculator and motion generator are the website's free + * tools, which the app mirrors, not its signed-in TimerPage. A sheet continues + * on the page it belongs to. + */ +const SCREENS: Record = { + '/': DEBATE, + '/prepare': target('/app', 'home.prepare'), + '/practice': target('/app', 'home.practice'), + '/debates': DEBATE, + '/new-debate': DEBATE, + '/edit-debate': DEBATE, + '/debate-check': DEBATE, + '/context-review': DEBATE, + '/welcome': DEBATE, + '/library': LIBRARY, + '/add-link': LIBRARY, + '/new-note': LIBRARY, + '/add-file': LIBRARY, + '/you': SETTINGS, + '/delete-account': SETTINGS, + '/delete-data': SETTINGS, + '/limit': SETTINGS, + '/history': target('/app/history', 'nav.history'), + '/save-case': target('/app/history?tab=cases', 'nav.history'), + '/ask': target('/app/ask', 'nav.ask'), + '/generate': target('/app/generate', 'nav.generate'), + '/attack': target('/app/attack', 'nav.attack'), + '/brainstorm': target('/app/brainstorm', 'nav.brainstorm'), + '/factoids': target('/app/factoids', 'nav.factoids'), + '/crossfire': target('/app/crossfire', 'nav.crossfire'), + '/sparring': target('/app/sparring', 'nav.sparring'), + '/flowchart': FLOWCHART, + '/flowchart/card': FLOWCHART, + '/flowchart/claim': FLOWCHART, + '/flowchart/maps': FLOWCHART, + '/timer': TIMER, + '/timer-format': TIMER, + '/speech-time': target('/tools/speech-time-calculator', 'guideHub.links.tools/speech-time-calculator'), + '/motions': target('/tools/debate-motion-generator', 'guideHub.links.tools/debate-motion-generator'), +}; + +/** + * Saved documents by id. The website cannot open one from its address, so they + * continue on History, on the tab that lists them (HistoryPage's `tab`). + */ +const DOCUMENTS: Record = { + case: 'cases', + attack: 'attacks', + brainstorm: 'brainstorms', + factoids: 'factoids', + sparring: 'sparring', + 'crossfire-round': 'crossfire', +}; + +const SEGMENT = /^[A-Za-z0-9_-]{1,128}$/; +const MAX_PATH_LENGTH = 512; + +/** + * The website page that continues `pathname`, or null when nothing should be + * offered: sign-in, and any screen this table does not know. + */ +export function handoffTarget(pathname: string): HandoffTarget | null { + if (pathname.length > MAX_PATH_LENGTH) return null; + const path = pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; + if (Object.prototype.hasOwnProperty.call(SCREENS, path)) return SCREENS[path]; + + const segments = path.split('/').slice(1); + if (segments.length !== 2 || !SEGMENT.test(segments[1])) return null; + const [first, id] = segments; + if (Object.prototype.hasOwnProperty.call(DOCUMENTS, first)) { + return target(`/app/history?tab=${DOCUMENTS[first]}`, 'nav.history'); + } + if (first === 'source') return LIBRARY; + if (first === 'soon') return target('/app', 'home.practice'); + if (first === 'share' && isShareCode(id)) return target(`/share/${id}`, 'sharedPage.title'); + return null; +} + +/** + * The route to open for a screen another device handed over (its activity's + * app route, or its web address when it had none). Only a route Handoff would + * offer, or a website link the app already opens, is accepted. + */ +export function continuedRoute(value: unknown): string | null { + if (typeof value !== 'string' || value.length > MAX_PATH_LENGTH) return null; + if (/^https:/i.test(value)) return appPathForUrl(value); + if (!value.startsWith('/') || value.includes('//')) return null; + if (!handoffTarget(value)) return null; + // A result kept only on the device that made it (open-results.ts's local- keys) is not here; its History is. + const [, first, id] = value.split('/'); + if (Object.prototype.hasOwnProperty.call(DOCUMENTS, first) && id?.startsWith('local-')) return '/history'; + return value; +} diff --git a/apps/mobile/src/lib/debate-widget.ts b/apps/mobile/src/lib/debate-widget.ts index 15c9f1d09..06c280386 100644 --- a/apps/mobile/src/lib/debate-widget.ts +++ b/apps/mobile/src/lib/debate-widget.ts @@ -1,13 +1,10 @@ -import { requireOptionalNativeModule } from 'expo'; -import { File } from 'expo-file-system'; -import { Image } from 'react-native'; - import { palette } from '@/theme/colors'; import type { CurrentDebateWidgetProps, WidgetColors } from '@/widgets/current-debate'; import type { DebateCounts } from './debates'; import i18n from './i18n'; import { nativeCapabilities } from './native-capabilities'; +import { widgetOwl } from './widget-images'; /** * What the Current Debate's Home Screen widget shows: the motion and the @@ -19,15 +16,10 @@ import { nativeCapabilities } from './native-capabilities'; */ export type CurrentDebateSnapshot = { debateId?: string; motion: string; counts: DebateCounts | null } | null; -type WidgetsModule = { widgetsDirectory?: string | null }; type CurrentDebateWidget = { updateSnapshot: (props: CurrentDebateWidgetProps) => void }; -const OWL_FILE = 'owl-mark.png'; -const MARK = require('@/assets/owl/mark.png'); - let enabled: boolean | undefined; let widget: Promise | undefined; -let owl: Promise | undefined; let latest: CurrentDebateSnapshot | undefined; let known: { debateId?: string; motion: string; counts: DebateCounts } | null = null; let written: string | null = null; @@ -68,8 +60,7 @@ function sameDebate(a: { debateId?: string; motion: string }, b: { debateId?: st async function write(snapshot: CurrentDebateSnapshot): Promise { try { widget ??= loadWidget(); - owl ??= copyOwl(); - const [target, owlUri] = await Promise.all([widget, owl]); + const [target, owlUri] = await Promise.all([widget, widgetOwl('mark')]); // A newer publish landed while this one waited; it writes instead. if (!target || snapshot !== latest) return; const props = widgetProps(snapshot, owlUri); @@ -93,31 +84,6 @@ async function loadWidget(): Promise { } } -/** - * The widget extension cannot read the app's bundle, so the owl mark is - * copied into the App Group folder once. Without it the widget shows a symbol. - */ -async function copyOwl(): Promise { - try { - const directory = requireOptionalNativeModule('ExpoWidgets')?.widgetsDirectory; - if (!directory) return null; - const target = new File(directory, OWL_FILE); - if (target.exists) return target.uri; - const source = Image.resolveAssetSource(MARK)?.uri; - if (!source) return null; - // A development build serves assets from Metro; a release build has them on disk. - if (/^https?:/i.test(source)) { - await File.downloadFileAsync(source, target, { idempotent: true }); - } else { - new File(source).copy(target); - } - return target.uri; - } catch (cause) { - console.warn('Could not give the widget the owl mark:', cause instanceof Error ? cause.message : cause); - return null; - } -} - function colorsFor(scheme: 'light' | 'dark'): WidgetColors { const colors = palette[scheme]; return { diff --git a/apps/mobile/src/lib/deep-links.test.ts b/apps/mobile/src/lib/deep-links.test.ts index d0fb42281..44d45034f 100644 --- a/apps/mobile/src/lib/deep-links.test.ts +++ b/apps/mobile/src/lib/deep-links.test.ts @@ -67,6 +67,14 @@ describe('appPathForUrl', () => { expect(appPathForUrl('https://debateowl.com')).toBeNull(); expect(appPathForUrl('https://debateowl.com/privacy')).toBeNull(); expect(appPathForUrl('https://debateowl.com/application')).toBeNull(); + expect(appPathForUrl('https://debateowl.com/tools/flow-sheet')).toBeNull(); + expect(appPathForUrl('https://debateowl.com/tools/debate-timer/extra')).toBeNull(); + }); + + it('opens the free tools the app has, for Handoff from Safari', () => { + expect(appPathForUrl('https://debateowl.com/tools/debate-timer')).toBe('/timer'); + expect(appPathForUrl('https://www.debateowl.com/tools/speech-time-calculator?lang=es')).toBe('/speech-time'); + expect(appPathForUrl('https://debateowl.com/tools/debate-motion-generator/')).toBe('/motions'); }); }); diff --git a/apps/mobile/src/lib/deep-links.ts b/apps/mobile/src/lib/deep-links.ts index c23ba49aa..b5113238e 100644 --- a/apps/mobile/src/lib/deep-links.ts +++ b/apps/mobile/src/lib/deep-links.ts @@ -31,6 +31,17 @@ const APP_PAGES: Record = { billing: '/you', }; +/** + * The website's free tools the app has as its own screens (the round timer, + * speech time calculator and motion generator), so Handoff from Safari opens + * them. Universal links do not include /tools; only Handoff brings these here. + */ +const TOOL_PAGES: Record = { + 'debate-timer': '/timer', + 'speech-time-calculator': '/speech-time', + 'debate-motion-generator': '/motions', +}; + /** A share code as the share screen accepts it, from a universal link or the app's own scheme. */ export function isShareCode(code: unknown): code is string { return typeof code === 'string' && SHARE_CODE.test(code); @@ -64,5 +75,9 @@ export function appPathForUrl(url: unknown): string | null { const page = (rest[0] ?? '').toLowerCase(); return Object.prototype.hasOwnProperty.call(APP_PAGES, page) ? APP_PAGES[page] : '/'; } + if (first?.toLowerCase() === 'tools' && rest.length === 1) { + const tool = rest[0].toLowerCase(); + return Object.prototype.hasOwnProperty.call(TOOL_PAGES, tool) ? TOOL_PAGES[tool] : null; + } return null; } diff --git a/apps/mobile/src/lib/generating-activity-state.ts b/apps/mobile/src/lib/generating-activity-state.ts new file mode 100644 index 000000000..803442fb1 --- /dev/null +++ b/apps/mobile/src/lib/generating-activity-state.ts @@ -0,0 +1,97 @@ +import type { LivePhase } from '@debatelab/core/ai/live'; + +/** + * What the generating Live Activity shows (src/widgets/generating-activity.tsx). + * The layout cannot reach i18n or the app's assets, so every word and each + * owl's file comes in here, in the app's language. + */ + +/** The four Prepare tools that write live. */ +export type GeneratingTool = 'case' | 'attack' | 'brainstorm' | 'factoids'; + +export type GeneratingStatus = 'working' | 'done' | 'failed'; + +export interface GeneratingActivityState { + /** The tool's name: "Generate", "Attack", "Brainstorm", "Factoids". */ + tool: string; + /** The motion or case it works on, as the student wrote it. */ + title: string; + status: GeneratingStatus; + /** The step the run is on. Before the server names one it is research, the step every run starts with. */ + phase: LivePhase; + /** The app's own line for that step ("Researching sources…", "Writing your case…"). */ + phaseLabel: string; + /** "3 parts written", or null before the first part. */ + partsLabel: string | null; + /** When the run started, in epoch ms: the layout counts the time since then. */ + startedAt: number; + labels: { done: string; failed: string; waiting: string }; + /** Ollie's pose for each step and outcome, as file URIs in the App Group folder; null draws a symbol. */ + owls: Record; +} + +type Translate = (key: string, options?: Record) => string; + +const TOOL_NAMES: Record = { + case: 'nav.generate', + attack: 'nav.attack', + brainstorm: 'nav.brainstorm', + factoids: 'nav.factoids', +}; + +// The tool's own line while it works, as the screen shows before the model names a step. +const WORKING: Record = { + case: 'generate.working', + attack: 'attack.working', + brainstorm: 'brainstorm.working', + factoids: 'factoids.working', +}; + +const READY: Record = { + case: 'mobile.liveActivity.caseReady', + attack: 'mobile.liveActivity.attackReady', + brainstorm: 'mobile.liveActivity.brainstormReady', + factoids: 'mobile.liveActivity.factoidsReady', +}; + +/** The same lines as the screen's live draft (components/live-run.tsx), so the Lock Screen never says something else. */ +export function phaseLabel(tool: GeneratingTool, phase: LivePhase | undefined, t: Translate): string { + if (phase === 'research') return t('tools.live.researching'); + if (phase === 'checking') return t('tools.live.checking'); + if (phase === 'writing') return t('tools.live.writing'); + return t(WORKING[tool]); +} + +export interface GeneratingProgress { + status: GeneratingStatus; + phase?: LivePhase; + /** Sections of the draft written so far. */ + parts: number; +} + +/** The activity's state for one moment of a run. */ +export function generatingActivityState( + tool: GeneratingTool, + title: string, + startedAt: number, + progress: GeneratingProgress, + owls: GeneratingActivityState['owls'], + t: Translate, +): GeneratingActivityState { + return { + tool: t(TOOL_NAMES[tool]), + // The Lock Screen has one line for it; the layout truncates the rest. + title: title.replace(/\s+/g, ' ').trim().slice(0, 160), + status: progress.status, + phase: progress.phase ?? 'research', + phaseLabel: phaseLabel(tool, progress.phase, t), + partsLabel: progress.parts > 0 ? t('mobile.liveActivity.parts', { count: progress.parts }) : null, + startedAt, + labels: { + done: t(READY[tool]), + failed: t('mobile.liveActivity.failed'), + waiting: t('mobile.liveActivity.waiting'), + }, + owls, + }; +} diff --git a/apps/mobile/src/lib/generating-activity.ts b/apps/mobile/src/lib/generating-activity.ts new file mode 100644 index 000000000..c2ea6c5d8 --- /dev/null +++ b/apps/mobile/src/lib/generating-activity.ts @@ -0,0 +1,193 @@ +import type { LivePhase } from '@debatelab/core/ai/live'; +import type { LiveActivity, LiveActivityDismissalPolicy } from 'expo-widgets'; +import { AppState } from 'react-native'; + +import { + generatingActivityState, + type GeneratingActivityState, + type GeneratingProgress, + type GeneratingTool, +} from './generating-activity-state'; +import i18n from './i18n'; +import { nativeCapabilities } from './native-capabilities'; +import { widgetOwls } from './widget-images'; + +// The Live Activity for a case, attack, brainstorm or factoids run (Lock +// Screen and Dynamic Island). Like the timer's (live-activity.ts) it needs the +// development build with an App Group, so everywhere else, Expo Go included, +// every call here does nothing and the layout module is never loaded. +// +// iOS suspends the app a few seconds after it leaves the screen, and the +// answer's stream stops with it; the run itself goes on on the server, which +// keeps the answer in ai_run_results for the app to read back when it is +// opened again (ai-request.ts). So when the app leaves the screen during a run +// the activity is given a stale date shortly after: if the app is still +// suspended then, the layout says to open DebateOwl instead of a step that is +// no longer moving. + +type LayoutModule = typeof import('@/widgets/generating-activity'); + +/** How long after leaving the screen the activity stops claiming progress. iOS gives a backgrounded app about this long. */ +const STALE_AFTER_BACKGROUND_MS = 30_000; +/** How long a finished or failed run stays on the Lock Screen when the student is not in the app to see it. */ +const KEEP_FINISHED_MS = 15 * 60_000; + +const POSES = { research: 'reading', writing: 'point', checking: 'skeptical', done: 'celebrating', failed: 'surprised' } as const; + +let layouts: LayoutModule | null | undefined; +let current: Run | null = null; +let watchingAppState = false; + +function load(): LayoutModule | null { + if (layouts !== undefined) return layouts; + layouts = null; + if (!nativeCapabilities().liveActivity) return null; + try { + // Loaded on first use: its top level registers the layout with the extension. + // eslint-disable-next-line @typescript-eslint/no-require-imports + layouts = require('@/widgets/generating-activity') as LayoutModule; + } catch (cause) { + console.warn('The generating Live Activity is unavailable:', cause instanceof Error ? cause.message : cause); + } + return layouts; +} + +function warn(what: string, cause: unknown) { + console.warn(`Could not ${what} the generating Live Activity:`, cause instanceof Error ? cause.message : cause); +} + +export interface GeneratingActivityHandle { + /** The step and how many parts are written. Unchanged values are not sent again. */ + update(progress: { phase?: LivePhase; parts: number }): void; + /** + * Ends the activity. A finished or failed run stays on the Lock Screen for a + * while when the app is not on screen, so the student sees it is ready; + * otherwise, and after a cancel, it goes at once. + */ + finish(outcome: 'done' | 'failed' | 'cancelled'): void; +} + +const NOOP: GeneratingActivityHandle = { update: () => {}, finish: () => {} }; + +class Run implements GeneratingActivityHandle { + private activity: LiveActivity | null = null; + private progress: GeneratingProgress = { status: 'working', parts: 0 }; + private sent = ''; + private ended = false; + private readonly startedAt = Date.now(); + private owls: GeneratingActivityState['owls'] | null = null; + + constructor( + private readonly module: LayoutModule, + private readonly tool: GeneratingTool, + private readonly title: string, + ) { + void widgetOwls(Object.values(POSES)).then((uris) => { + this.owls = { + research: uris.reading, + writing: uris.point, + checking: uris.skeptical, + done: uris.celebrating, + failed: uris.surprised, + }; + this.start(); + }); + } + + update(progress: { phase?: LivePhase; parts: number }) { + if (this.ended) return; + this.progress = { status: 'working', phase: progress.phase ?? this.progress.phase, parts: progress.parts }; + this.send(); + } + + finish(outcome: 'done' | 'failed' | 'cancelled') { + if (this.ended) return; + this.ended = true; + if (current === this) current = null; + const activity = this.activity; + if (!activity) return; + const away = AppState.currentState !== 'active'; + if (outcome === 'cancelled' || !away || !this.owls) { + activity.end('immediate').catch((cause: unknown) => warn('end', cause)); + return; + } + this.progress = { ...this.progress, status: outcome }; + const policy: LiveActivityDismissalPolicy = { after: new Date(Date.now() + KEEP_FINISHED_MS) }; + activity.end(policy, this.state()).catch((cause: unknown) => warn('end', cause)); + } + + /** The app left the screen or came back: claim progress only while it can still be making it. */ + appStateChanged() { + // Off screen when the owls arrived: iOS only starts an activity for an app on screen, so it starts now. + if (!this.activity) { + this.start(); + return; + } + this.sent = ''; + this.send(); + } + + private state(): GeneratingActivityState { + return generatingActivityState(this.tool, this.title, this.startedAt, this.progress, this.owls!, i18n.t.bind(i18n)); + } + + private start() { + if (this.ended || !this.owls || this.activity || AppState.currentState !== 'active') return; + try { + // No link: tapping the activity opens the app where the student left it, on the tool's screen. + const state = this.state(); + this.activity = this.module.generatingActivity.start(state, undefined, this.staleDate()); + this.sent = JSON.stringify(state); + } catch (cause) { + // Live Activities may be off in Settings; the run goes on without one. + warn('start', cause); + } + } + + private send() { + if (this.ended || !this.activity || !this.owls) return; + const state = this.state(); + const key = JSON.stringify(state); + if (key === this.sent) return; + this.sent = key; + this.activity.update(state, this.staleDate()).catch((cause: unknown) => warn('update', cause)); + } + + private staleDate(): Date | undefined { + return AppState.currentState === 'active' ? undefined : new Date(Date.now() + STALE_AFTER_BACKGROUND_MS); + } +} + +/** + * Starts the Lock Screen activity for a live run, ending the one before it, so + * only the newest run shows. Returns a handle that does nothing where Live + * Activities are not available. + */ +export function beginGeneratingActivity(tool: GeneratingTool, title: string): GeneratingActivityHandle { + const module = load(); + if (!module) return NOOP; + current?.finish('cancelled'); + if (!watchingAppState) { + watchingAppState = true; + AppState.addEventListener('change', () => current?.appStateChanged()); + } + current = new Run(module, tool, title); + return current; +} + +/** + * Ends activities a previous launch left behind. A run does not survive the + * app being closed, so nothing can bring them up to date. Called once from + * the root layout. + */ +export function endLeftoverGeneratingActivities() { + const module = load(); + if (!module) return; + try { + for (const instance of module.generatingActivity.getInstances()) { + instance.end('immediate').catch((cause: unknown) => warn('end', cause)); + } + } catch (cause) { + warn('find', cause); + } +} diff --git a/apps/mobile/src/lib/generating-activity.web.ts b/apps/mobile/src/lib/generating-activity.web.ts new file mode 100644 index 000000000..045809416 --- /dev/null +++ b/apps/mobile/src/lib/generating-activity.web.ts @@ -0,0 +1,39 @@ +import type { LivePhase } from '@debatelab/core/ai/live'; + +import type { GeneratingTool } from './generating-activity-state'; + +/** + * The web preview has no Live Activities. It keeps the last run's steps on + * `window`, so the screenshot script can check what the Lock Screen would + * show. The phone app uses generating-activity.ts. + */ + +type Recorded = { tool: GeneratingTool; title: string; phase?: LivePhase; parts: number; outcome: 'working' | 'done' | 'failed' | 'cancelled' }; + +export interface GeneratingActivityHandle { + update(progress: { phase?: LivePhase; parts: number }): void; + finish(outcome: 'done' | 'failed' | 'cancelled'): void; +} + +function record(next: Recorded) { + (window as unknown as { __debateowlGeneratingActivity?: Recorded }).__debateowlGeneratingActivity = next; +} + +export function beginGeneratingActivity(tool: GeneratingTool, title: string): GeneratingActivityHandle { + let state: Recorded = { tool, title, parts: 0, outcome: 'working' }; + record(state); + return { + update: (progress) => { + if (state.outcome !== 'working') return; + state = { ...state, phase: progress.phase ?? state.phase, parts: progress.parts }; + record(state); + }, + finish: (outcome) => { + if (state.outcome !== 'working') return; + state = { ...state, outcome }; + record(state); + }, + }; +} + +export function endLeftoverGeneratingActivities() {} diff --git a/apps/mobile/src/lib/handoff.ts b/apps/mobile/src/lib/handoff.ts new file mode 100644 index 000000000..f4a3615bd --- /dev/null +++ b/apps/mobile/src/lib/handoff.ts @@ -0,0 +1,107 @@ +import { requireOptionalNativeModule } from 'expo'; +import { usePathname, useRouter, type Href } from 'expo-router'; +import i18n from 'i18next'; +import { useEffect, useState } from 'react'; + +import { continuedRoute, handoffTarget, WEBSITE_ORIGIN } from './app-intent-links'; +import { useAuth } from './auth'; +import { nativeCapabilities } from './native-capabilities'; + +/** + * Handoff through the app's own module, modules/handoff (NSUserActivity). The + * open screen is offered to the student's other devices: a Mac continues on its + * debateowl.com page (app-intent-links.ts), an iPad with DebateOwl opens the same + * route. The module is in development builds only; Expo Go and a build without + * it do nothing. The web preview has handoff.web.ts. + */ + +/** Also in modules/handoff/ios/HandoffModule.swift and Info.plist's NSUserActivityTypes. */ +const ACTIVITY_TYPE = 'com.pistack.debateowl.browsing'; + +type HandoffModule = { + setActivity: (activity: { type: string; title: string; webpageURL: string; appPath: string }) => void; + clearActivity: () => void; + takeContinuedPath: () => string | null; + addListener: (event: 'onContinue', listener: () => void) => { remove: () => void }; +}; + +let cached: HandoffModule | null | undefined; + +function handoffModule(): HandoffModule | null { + if (cached === undefined) { + cached = nativeCapabilities().handoff ? requireOptionalNativeModule('DebateOwlHandoff') : null; + } + return cached; +} + +/** + * Offers `pathname` (an Expo Router pathname) to the student's other devices, + * or withdraws the offer when it is null or has no website page (sign-in). + */ +export function syncHandoff(pathname: string | null): void { + const native = handoffModule(); + if (!native) return; + const target = pathname ? handoffTarget(pathname) : null; + try { + if (!pathname || !target) { + native.clearActivity(); + return; + } + native.setActivity({ + type: ACTIVITY_TYPE, + title: i18n.t(target.titleKey), + webpageURL: `${WEBSITE_ORIGIN}${target.webPath}`, + appPath: pathname, + }); + } catch (cause) { + console.warn('Could not update Handoff:', cause instanceof Error ? cause.message : cause); + } +} + +/** + * Keeps Handoff on the screen shown, and opens a screen another device handed + * over. Call it in the root layout's Root component, before its early return, + * with that component's `ready`. A handed-over screen waits until then, and, + * except a share link, until someone is signed in. + */ +export function useHandoff(ready: boolean): void { + const pathname = usePathname(); + const router = useRouter(); + const { session } = useAuth(); + const signedIn = session !== null; + const [language, setLanguage] = useState(i18n.language); + const [pending, setPending] = useState(null); + + // The title follows the app's language. + useEffect(() => { + const onChange = (next: string) => setLanguage(next); + i18n.on('languageChanged', onChange); + return () => i18n.off('languageChanged', onChange); + }, []); + + useEffect(() => { + syncHandoff(ready ? pathname : null); + }, [pathname, ready, language]); + + useEffect(() => { + const native = handoffModule(); + if (!native) return; + const take = () => { + const route = continuedRoute(native.takeContinuedPath()); + if (route) setPending(route); + }; + take(); + const subscription = native.addListener('onContinue', take); + return () => subscription.remove(); + }, []); + + useEffect(() => { + if (!pending || !ready || (!signedIn && !pending.startsWith('/share/'))) return; + // After the stack has committed its first screens, as useQuickActions does. + const timer = setTimeout(() => { + setPending(null); + router.navigate(pending as Href); + }, 0); + return () => clearTimeout(timer); + }, [pending, ready, signedIn, router]); +} diff --git a/apps/mobile/src/lib/handoff.web.ts b/apps/mobile/src/lib/handoff.web.ts new file mode 100644 index 000000000..a64e2fef2 --- /dev/null +++ b/apps/mobile/src/lib/handoff.web.ts @@ -0,0 +1,5 @@ +/** The web preview has no Handoff; the same calls as handoff.ts, doing nothing. */ + +export function syncHandoff(_pathname: string | null): void {} + +export function useHandoff(_ready: boolean): void {} diff --git a/apps/mobile/src/lib/live-activity.ts b/apps/mobile/src/lib/live-activity.ts index 48beb240f..26f19f88b 100644 --- a/apps/mobile/src/lib/live-activity.ts +++ b/apps/mobile/src/lib/live-activity.ts @@ -1,7 +1,8 @@ import type { LiveActivity } from 'expo-widgets'; import { nativeCapabilities } from './native-capabilities'; -import type { TimerActivityState } from './timer'; +import { applyTimerActivityAction, type TimerActivityAction, type TimerActivityState } from './timer'; +import { updateSession } from './timer-store'; import type { TimerWidgetProps } from '@/widgets/timer-widgets'; @@ -11,6 +12,10 @@ import type { TimerWidgetProps } from '@/widgets/timer-widgets'; // here does nothing and the widgets module is never loaded. type WidgetsModule = typeof import('@/widgets/timer-widgets'); +type ExpoWidgets = typeof import('expo-widgets'); + +/** The Live Activity's buttons (src/widgets/timer-widgets.tsx) by their fixed targets. */ +const BUTTONS: Record = { 'timer-toggle': 'toggle', 'timer-next': 'next' }; let widgets: WidgetsModule | null | undefined; let activity: LiveActivity | null = null; @@ -25,12 +30,33 @@ function load(): WidgetsModule | null { // Loaded on first use: its top level registers the layouts with the extension. // eslint-disable-next-line @typescript-eslint/no-require-imports widgets = require('@/widgets/timer-widgets') as WidgetsModule; + listenForButtons(); } catch (cause) { console.warn('Timer widgets are unavailable:', cause instanceof Error ? cause.message : cause); } return widgets; } +/** + * Pause, Resume and Next on the Lock Screen and in the Dynamic Island. iOS + * runs the button's intent in the app's process, waking it in the background + * if it has to; expo-widgets passes the press on here, and the round changes + * as if the student had pressed the timer screen's own button. The change + * reaches the activity through TimerRunner, like any other. + */ +function listenForButtons() { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { addUserInteractionListener } = require('expo-widgets') as ExpoWidgets; + addUserInteractionListener((event) => { + const action = event.source === 'DebateTimerActivity' ? BUTTONS[event.target] : undefined; + if (action) updateSession((current) => applyTimerActivityAction(current, action, Date.now())); + }); + } catch (cause) { + console.warn("Could not listen for the timer's Live Activity buttons:", cause instanceof Error ? cause.message : cause); + } +} + function warn(what: string, cause: unknown) { console.warn(`Could not ${what} the timer's Live Activity:`, cause instanceof Error ? cause.message : cause); } diff --git a/apps/mobile/src/lib/native-capabilities.test.ts b/apps/mobile/src/lib/native-capabilities.test.ts index 26c7e5ae7..facf1f449 100644 --- a/apps/mobile/src/lib/native-capabilities.test.ts +++ b/apps/mobile/src/lib/native-capabilities.test.ts @@ -31,10 +31,11 @@ describe('nativeCapabilities', () => { ExpoWidgets: { widgetsDirectory: 'file:///group/ExpoWidgets/' }, ExpoQuickActions: {}, ExpoCoreSpotlight: {}, + DebateOwlHandoff: {}, DebateOwlKeyCommands: {}, ExpoShareIntentModule: {}, }; - expect(nativeCapabilities()).toEqual({ speechRecognition: false, liveActivity: false, widgets: false, quickActions: false, spotlight: false, keyCommands: false, shareIntent: false }); + expect(nativeCapabilities()).toEqual({ speechRecognition: false, liveActivity: false, widgets: false, quickActions: false, spotlight: false, handoff: false, keyCommands: false, shareIntent: false }); expect(requireOptionalNativeModule).not.toHaveBeenCalled(); }); @@ -47,6 +48,7 @@ describe('nativeCapabilities', () => { widgets: false, quickActions: false, spotlight: false, + handoff: false, keyCommands: false, shareIntent: false, }); @@ -61,6 +63,7 @@ describe('nativeCapabilities', () => { widgets: true, quickActions: false, spotlight: false, + handoff: false, keyCommands: false, shareIntent: false, }); @@ -75,6 +78,7 @@ describe('nativeCapabilities', () => { widgets: false, quickActions: true, spotlight: true, + handoff: false, keyCommands: true, shareIntent: true, }); @@ -83,6 +87,6 @@ describe('nativeCapabilities', () => { it('reports nothing where the modules are missing, as in the web preview', () => { env.executionEnvironment = 'bare'; env.modules = {}; - expect(nativeCapabilities()).toEqual({ speechRecognition: false, liveActivity: false, widgets: false, quickActions: false, spotlight: false, keyCommands: false, shareIntent: false }); + expect(nativeCapabilities()).toEqual({ speechRecognition: false, liveActivity: false, widgets: false, quickActions: false, spotlight: false, handoff: false, keyCommands: false, shareIntent: false }); }); }); diff --git a/apps/mobile/src/lib/native-capabilities.ts b/apps/mobile/src/lib/native-capabilities.ts index 111f79840..0ca03ab43 100644 --- a/apps/mobile/src/lib/native-capabilities.ts +++ b/apps/mobile/src/lib/native-capabilities.ts @@ -22,13 +22,15 @@ export type NativeCapabilities = { keyCommands: boolean; /** expo-share-intent is linked, so text and links shared from other apps reach the app (src/lib/share-intent.ts). */ shareIntent: boolean; + /** modules/handoff is linked, so the open screen is offered to other devices (src/lib/handoff.ts). */ + handoff: boolean; }; type WidgetsModule = { widgetsDirectory?: string | null }; export function nativeCapabilities(): NativeCapabilities { if (Constants.executionEnvironment === ExecutionEnvironment.StoreClient) { - return { speechRecognition: false, liveActivity: false, widgets: false, quickActions: false, spotlight: false, keyCommands: false, shareIntent: false }; + return { speechRecognition: false, liveActivity: false, widgets: false, quickActions: false, spotlight: false, handoff: false, keyCommands: false, shareIntent: false }; } // expo-widgets links into every development build, but without the App // Group (DEBATELAB_APP_GROUP) it has no extension and no container. @@ -40,6 +42,7 @@ export function nativeCapabilities(): NativeCapabilities { widgets: widgetContainer, quickActions: requireOptionalNativeModule('ExpoQuickActions') != null, spotlight: requireOptionalNativeModule('ExpoCoreSpotlight') != null, + handoff: requireOptionalNativeModule('DebateOwlHandoff') != null, keyCommands: requireOptionalNativeModule('DebateOwlKeyCommands') != null, shareIntent: requireOptionalNativeModule('ExpoShareIntentModule') != null, }; diff --git a/apps/mobile/src/lib/notification-routes.ts b/apps/mobile/src/lib/notification-routes.ts new file mode 100644 index 000000000..e3e132668 --- /dev/null +++ b/apps/mobile/src/lib/notification-routes.ts @@ -0,0 +1,16 @@ +import type { Href } from 'expo-router'; + +/** + * Where tapping a notification goes. Pushes carry ids only, never debate + * content (supabase/functions/_shared/push.ts), and the timer's time-up alert + * (timer-alerts.ts) carries its kind; anything else opens the app as it was. + */ +export function notificationHref(data: unknown): Href | null { + if (typeof data !== 'object' || data === null) return null; + const { kind, sessionId } = data as Record; + if (kind === 'timer') return '/timer'; + if (kind === 'sparring_scored' && typeof sessionId === 'string' && /^[0-9a-f-]{36}$/i.test(sessionId)) { + return { pathname: '/sparring/[id]', params: { id: sessionId } }; + } + return null; +} diff --git a/apps/mobile/src/lib/oauth-callback.test.ts b/apps/mobile/src/lib/oauth-callback.test.ts new file mode 100644 index 000000000..f70cc8d9b --- /dev/null +++ b/apps/mobile/src/lib/oauth-callback.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { oauthCallback } from './oauth-callback'; + +const REDIRECT = 'debatelab://auth/callback'; + +describe('OAuth callback', () => { + it('reads the PKCE code', () => { + expect(oauthCallback(`${REDIRECT}?code=abc-123`, REDIRECT)).toEqual({ kind: 'code', code: 'abc-123' }); + }); + + it("reads the provider's error from the query or the fragment", () => { + expect(oauthCallback(`${REDIRECT}?error=access_denied&error_description=User+cancelled`, REDIRECT)).toEqual({ + kind: 'error', + code: 'access_denied', + message: 'User cancelled', + }); + expect(oauthCallback(`${REDIRECT}#error=server_error&error_description=Bad%20state`, REDIRECT)).toMatchObject({ code: 'server_error', message: 'Bad state' }); + }); + + it('refuses another address, and a callback with neither code nor error', () => { + expect(oauthCallback('debatelab://auth/callbackx?code=abc', REDIRECT)).toMatchObject({ kind: 'error', code: 'invalid_callback' }); + expect(oauthCallback('https://evil.example/auth/callback?code=abc', REDIRECT)).toMatchObject({ kind: 'error', code: 'invalid_callback' }); + expect(oauthCallback(REDIRECT, REDIRECT)).toMatchObject({ kind: 'error', code: 'invalid_callback' }); + // A code in the fragment is not the PKCE flow's answer. + expect(oauthCallback(`${REDIRECT}#code=abc`, REDIRECT)).toMatchObject({ kind: 'error', code: 'invalid_callback' }); + }); +}); diff --git a/apps/mobile/src/lib/oauth-callback.ts b/apps/mobile/src/lib/oauth-callback.ts new file mode 100644 index 000000000..d3f8ca6f1 --- /dev/null +++ b/apps/mobile/src/lib/oauth-callback.ts @@ -0,0 +1,25 @@ +/** + * The redirect an OAuth sign-in comes back to, read: a PKCE `code`, or the + * error the provider or Supabase Auth gives. Supabase puts errors in the + * query or, for some, the fragment; a callback for another address is refused, + * so a stray link can never be taken for a sign-in. + */ +export type OAuthCallback = { kind: 'code'; code: string } | { kind: 'error'; code: string; message: string }; + +export function oauthCallback(url: string, expected: string): OAuthCallback { + const refused = (message: string): OAuthCallback => ({ kind: 'error', code: 'invalid_callback', message }); + const [address, rest = ''] = url.split('?', 2); + const [base] = address.split('#', 1); + if (base !== expected) return refused('The sign-in came back to an unexpected address.'); + const hashAt = rest.indexOf('#'); + const query = new URLSearchParams(hashAt === -1 ? rest : rest.slice(0, hashAt)); + const fragmentAt = url.indexOf('#'); + const fragment = new URLSearchParams(fragmentAt === -1 ? '' : url.slice(fragmentAt + 1)); + const read = (name: string) => query.get(name) ?? fragment.get(name); + + const error = read('error'); + if (error) return { kind: 'error', code: error, message: read('error_description') ?? error }; + const code = query.get('code'); + if (!code) return refused('The sign-in came back without a code.'); + return { kind: 'code', code }; +} diff --git a/apps/mobile/src/lib/purchases.test.ts b/apps/mobile/src/lib/purchases.test.ts new file mode 100644 index 000000000..f4f4595cc --- /dev/null +++ b/apps/mobile/src/lib/purchases.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { StorePurchaseResult, StoreTransaction } from './store-kit'; + +type Answer = { status: number; body: Record }; +type Session = { user: { id: string }; access_token: string } | null; + +const env = vi.hoisted(() => ({ + native: null as unknown, + session: null as Session, + respond: (): Promise => Promise.resolve({ status: 200, body: {} }), + posts: [] as { token: string; body: Record }[], +})); + +vi.mock('expo', () => ({ requireOptionalNativeModule: () => null })); +vi.mock('./store-kit', async (original) => ({ ...(await original()), storeKit: () => env.native })); +vi.mock('./supabase', () => ({ supabase: { auth: { getSession: async () => ({ data: { session: env.session } }) } } })); +vi.mock('./supabase-config', () => ({ supabaseProject: { url: 'https://project.example', anonKey: 'anon' } })); +vi.mock('react-native', () => ({ AppState: { addEventListener: () => ({ remove: () => {} }) } })); +vi.mock('expo/fetch', () => ({ + fetch: async (_url: string, init: { headers: Record; body: string }) => { + env.posts.push({ token: init.headers.Authorization, body: JSON.parse(init.body) as Record }); + const answer = await env.respond(); + return { ok: answer.status >= 200 && answer.status < 300, status: answer.status, json: async () => answer.body }; + }, +})); + +const { buy, deliverPending, restore, startPurchaseListener } = await import('./purchases'); + +const USER_A = 'aaaaaaaa-0000-4000-8000-000000000001'; +const USER_B = 'bbbbbbbb-0000-4000-8000-000000000002'; +const ACCOUNT_A = { user: { id: USER_A }, access_token: 'token-a' }; +const ACCOUNT_B = { user: { id: USER_B }, access_token: 'token-b' }; +const APPLIED: Answer = { status: 200, body: { result: 'applied', entitlement: { tier: 'pro', state: 'active', dailyLimit: 50, canAccessProModels: true } } }; +const NOT_CONFIGURED: Answer = { status: 503, body: { error: 'In-app purchases are not available yet.', code: 'not_configured' } }; + +// Transaction ids stay unique across tests: purchases.ts remembers what it finished in this run. +let nextId = 0; +function transaction(userId: string): StoreTransaction { + nextId += 1; + return { transactionId: `t${nextId}`, productId: 'com.pistack.debateowl.pro.monthly', signedTransaction: `jws-${nextId}`, appAccountToken: userId, expiresAt: null }; +} + +/** StoreKit as the module behaves: a purchase stays in unfinished() until finish(). */ +function fakeStore() { + const store = { + unfinishedList: [] as StoreTransaction[], + finished: [] as string[], + listeners: [] as ((transaction: StoreTransaction) => void)[], + /** What Apple's sheet answers; by default a completed purchase. */ + sheet: (_token: string): Promise => Promise.resolve({ status: 'cancelled' }), + purchase: async (_productId: string, token: string) => { + const result = await store.sheet(token); + if (result.status === 'purchased') store.unfinishedList.push(result); + return result; + }, + finish: async (id: string) => { + store.finished.push(id); + store.unfinishedList = store.unfinishedList.filter((entry) => entry.transactionId !== id); + }, + unfinished: async () => [...store.unfinishedList], + currentEntitlements: async () => [], + sync: async () => {}, + addListener: (_event: string, listener: (transaction: StoreTransaction) => void) => (store.listeners.push(listener), { remove: () => {} }), + }; + store.sheet = (token) => Promise.resolve({ status: 'purchased', ...transaction(token) }); + return store; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => (resolve = res)); + return { promise, resolve }; +} + +let store: ReturnType; + +beforeEach(() => { + store = fakeStore(); + env.native = store; + env.session = ACCOUNT_A; + env.posts.length = 0; + env.respond = () => Promise.resolve(APPLIED); +}); + +describe('buying a subscription', () => { + it('finishes the transaction only after the server accepted it', async () => { + const answer = deferred(); + env.respond = () => answer.promise; + const bought = buy('pro', USER_A); + await vi.waitFor(() => expect(env.posts).toHaveLength(1)); + expect(env.posts[0]).toMatchObject({ token: 'Bearer token-a', body: { signedTransaction: expect.stringMatching(/^jws-/) } }); + expect(store.finished).toEqual([]); + + answer.resolve(APPLIED); + await expect(bought).resolves.toEqual({ kind: 'purchased', plan: { tier: 'pro', state: 'active', dailyLimit: 50, canAccessProModels: true } }); + expect(store.finished).toHaveLength(1); + expect(store.unfinishedList).toEqual([]); + }); + + it('keeps the transaction while the server is not configured, and delivers it once it is', async () => { + env.respond = () => Promise.resolve(NOT_CONFIGURED); + await expect(buy('basic', USER_A)).resolves.toEqual({ kind: 'unavailable' }); + expect(store.finished).toEqual([]); + expect(store.unfinishedList).toHaveLength(1); + + env.respond = () => Promise.resolve(APPLIED); + await expect(deliverPending(USER_A)).resolves.toBe(1); + expect(store.unfinishedList).toEqual([]); + }); + + it('keeps the transaction when the server fails', async () => { + env.respond = () => Promise.resolve({ status: 500, body: { code: 'storage_failed' } }); + await expect(buy('pro', USER_A)).resolves.toEqual({ kind: 'failed', reason: 'server', code: 'storage_failed' }); + expect(store.finished).toEqual([]); + }); + + it('sends nothing for Ask to Buy or a cancelled sheet', async () => { + store.sheet = () => Promise.resolve({ status: 'pending' }); + await expect(buy('pro', USER_A)).resolves.toEqual({ kind: 'pending' }); + store.sheet = () => Promise.resolve({ status: 'cancelled' }); + await expect(buy('pro', USER_A)).resolves.toEqual({ kind: 'cancelled' }); + expect(env.posts).toEqual([]); + }); + + it('is unavailable without the native module', async () => { + env.native = null; + await expect(buy('pro', USER_A)).resolves.toEqual({ kind: 'unavailable' }); + await expect(restore(USER_A)).resolves.toEqual({ kind: 'unavailable' }); + await expect(deliverPending(USER_A)).resolves.toBe(0); + expect(() => startPurchaseListener(() => USER_A)()).not.toThrow(); + }); +}); + +describe('delivering unfinished transactions', () => { + it('sends each one once when launch and an update event both deliver', async () => { + const bought = transaction(USER_A); + store.unfinishedList.push(bought); + const answer = deferred(); + env.respond = () => answer.promise; + + startPurchaseListener(() => USER_A); + store.listeners.forEach((listener) => listener(bought)); + const third = deliverPending(USER_A); + await vi.waitFor(() => expect(env.posts).toHaveLength(1)); + answer.resolve(APPLIED); + await third; + + expect(env.posts).toHaveLength(1); + expect(store.finished).toHaveLength(1); + }); + + it('sends a new signed version of a transaction it already delivered, such as a refund', async () => { + const bought = transaction(USER_A); + store.unfinishedList.push(bought); + env.respond = () => Promise.resolve(APPLIED); + await deliverPending(USER_A); + expect(env.posts).toHaveLength(1); + + startPurchaseListener(() => USER_A); + const refunded = { ...bought, signedTransaction: `${bought.signedTransaction}-revoked` }; + store.listeners.forEach((listener) => listener(refunded)); + await vi.waitFor(() => expect(env.posts).toHaveLength(2)); + // The same version again is not. + store.listeners.forEach((listener) => listener(refunded)); + await deliverPending(USER_A); + expect(env.posts).toHaveLength(2); + }); + + it('sends and finishes a new unfinished version of a transaction it already delivered', async () => { + const bought = transaction(USER_A); + store.unfinishedList.push(bought); + env.respond = () => Promise.resolve(APPLIED); + await expect(deliverPending(USER_A)).resolves.toBe(1); + + // The same id, signed again (a refund or a new expiry), waiting unfinished. + store.unfinishedList.push({ ...bought, signedTransaction: `${bought.signedTransaction}-v2` }); + await expect(deliverPending(USER_A)).resolves.toBe(1); + expect(env.posts).toHaveLength(2); + expect(store.unfinishedList).toHaveLength(0); + }); + + it('finishes on the next delivery a transaction whose finish failed, without sending it again', async () => { + const bought = transaction(USER_A); + store.unfinishedList.push(bought); + env.respond = () => Promise.resolve(APPLIED); + const finish = store.finish; + store.finish = () => Promise.reject(new Error('StoreKit busy')); + await deliverPending(USER_A); + expect(store.unfinishedList).toHaveLength(1); + + store.finish = finish; + await deliverPending(USER_A); + expect(env.posts).toHaveLength(1); + expect(store.unfinishedList).toHaveLength(0); + }); + + it('does not send a purchase under the account that signed in while the sheet was open', async () => { + const sheet = deferred(); + let opened = false; + store.sheet = () => ((opened = true), sheet.promise); + const bought = buy('pro', USER_A); + await vi.waitFor(() => expect(opened).toBe(true)); + + env.session = ACCOUNT_B; + sheet.resolve({ status: 'purchased', ...transaction(USER_A) }); + await expect(bought).resolves.toEqual({ kind: 'failed', reason: 'accountChanged' }); + // The new account's own deliveries skip it too. + await expect(deliverPending(USER_B)).resolves.toBe(0); + expect(env.posts).toEqual([]); + expect(store.finished).toEqual([]); + + // Back on the buyer's account, it goes through. + env.session = ACCOUNT_A; + await expect(deliverPending(USER_A)).resolves.toBe(1); + expect(env.posts).toMatchObject([{ token: 'Bearer token-a' }]); + }); +}); diff --git a/apps/mobile/src/lib/purchases.ts b/apps/mobile/src/lib/purchases.ts new file mode 100644 index 000000000..5ecaddb70 --- /dev/null +++ b/apps/mobile/src/lib/purchases.ts @@ -0,0 +1,311 @@ +import { fetch } from 'expo/fetch'; +import { AppState } from 'react-native'; + +import { payloadOf, untilAborted } from './ai-request'; +import { createSerialQueue } from './serial-queue'; +import { storeKit, storeKitErrorCode, type StoreProduct, type StorePurchaseResult, type StoreTransaction } from './store-kit'; +import { supabase } from './supabase'; +import { supabaseProject } from './supabase-config'; + +/** + * In-app subscriptions through StoreKit 2. Apple takes the money; the + * app-store-purchase Edge Function verifies the signed transaction and + * applies it to the account, the same way App Store Server Notifications do. + * + * A transaction is finished only after the server has accepted it. Until + * then StoreKit keeps it in Transaction.unfinished, and deliverPending sends + * it again on launch, on return to the foreground and on every transaction + * update, so a purchase whose request was lost (offline, the app killed, the + * server not configured yet) still reaches the account. The server is + * idempotent, so sending one twice is harmless; sending it while the server + * cannot take it and then finishing it would lose it. + * + * Every purchase carries the buyer's user id as its appAccountToken, and the + * server refuses a transaction whose token is not the caller's. So a + * transaction is only ever sent for the account in its token, and only while + * that account is still the one signed in: Apple's sheet can stay open for + * minutes, long enough for someone to sign out and another to sign in. + */ + +/** The App Store Connect product ids, one subscription group. */ +export const PRODUCT_IDS = { + basic: 'com.pistack.debateowl.basic.monthly', + pro: 'com.pistack.debateowl.pro.monthly', +} as const; + +export type PurchaseTier = keyof typeof PRODUCT_IDS; + +/** The plan the server reports after applying a purchase (getSubscriptionInfo in supabase/functions/_shared/entitlements.ts). */ +export interface PurchasedPlan { + tier: string; + state: string; + dailyLimit: number; + canAccessProModels: boolean; +} + +/** + * Why a purchase did not reach the account: + * - accountChanged: another account is signed in now; the transaction waits for its own. + * - network: no answer in time. It stays unfinished and is sent again later. + * - rejected: the server refused it (verification, another account's purchase, an unknown product). + * - server: the server failed; it is sent again later. + * - store: StoreKit itself failed; `code` says how (see store-kit.ts). + */ +export type PurchaseFailure = 'accountChanged' | 'network' | 'rejected' | 'server' | 'store'; + +export type PurchaseOutcome = + | { kind: 'purchased'; plan: PurchasedPlan | null } + /** Ask to Buy, or a payment that needs action: the transaction arrives later through deliverPending. */ + | { kind: 'pending' } + | { kind: 'cancelled' } + /** No StoreKit in this build (Expo Go, the web preview), or the server is not configured for the App Store yet. */ + | { kind: 'unavailable' } + | { kind: 'failed'; reason: PurchaseFailure; code?: string }; + +export type RestoreOutcome = + /** + * `applied` counts this account's subscriptions the server took. `otherAccount` + * counts those this Apple ID holds for another account, or for none (an offer + * code redeemed in the App Store app, Family Sharing): they are never sent. + */ + | { kind: 'restored'; applied: number; otherAccount: number; plan: PurchasedPlan | null } + | { kind: 'cancelled' } + | { kind: 'unavailable' } + | { kind: 'failed'; reason: PurchaseFailure; code?: string }; + +type Delivery = { ok: true; plan: PurchasedPlan | null } | { ok: false; reason: PurchaseFailure | 'unavailable'; code?: string }; + +// One request may take this long before it counts as a network failure; the +// transaction stays unfinished and is sent again. +const REQUEST_BUDGET_MS = 20_000; + +// One delivery at a time, so a purchase's own request, a launch's +// deliverPending and an update event never send the same transaction at once. +const serial = createSerialQueue(); + +// What the server answered for each signed transaction it took in this run of +// the app, so a version a queued deliverPending already sent is not sent again. +// Keyed by the signed version, not the id: StoreKit signs the same transaction +// again when it changes (a refund, a new expiry), and that version must be sent. +const delivered = new Map(); + +function planOf(payload: Record | null): PurchasedPlan | null { + const plan = payload?.entitlement; + if (!plan || typeof plan !== 'object') return null; + const { tier, state, dailyLimit, canAccessProModels } = plan as Record; + if (typeof tier !== 'string' || typeof state !== 'string' || typeof dailyLimit !== 'number') return null; + return { tier, state, dailyLimit, canAccessProModels: canAccessProModels === true }; +} + +/** Whether this transaction was bought for `userId`. One without a token, or with another account's, is never sent. */ +function belongsTo(transaction: StoreTransaction, userId: string): boolean { + return transaction.appAccountToken !== null && transaction.appAccountToken === userId.toLowerCase(); +} + +/** Sends one transaction as `userId`, only if that account is still the one signed in. */ +async function post(transaction: StoreTransaction, userId: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_BUDGET_MS); + try { + const { data } = await untilAborted(supabase.auth.getSession(), controller.signal); + const session = data.session; + if (!session || session.user.id !== userId) return { ok: false, reason: 'accountChanged' }; + const body: Record = { signedTransaction: transaction.signedTransaction }; + if (transaction.signedRenewalInfo) body.signedRenewalInfo = transaction.signedRenewalInfo; + const response = await fetch(`${supabaseProject.url}/functions/v1/app-store-purchase`, { + method: 'POST', + headers: { Authorization: `Bearer ${session.access_token}`, apikey: supabaseProject.anonKey, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + }); + const payload = await untilAborted(payloadOf(response), controller.signal); + if (response.ok) return { ok: true, plan: planOf(payload) }; + const code = typeof payload?.code === 'string' ? payload.code : undefined; + if (response.status === 503 && code === 'not_configured') return { ok: false, reason: 'unavailable' }; + // 401 too: the session read above was the account's, so its token was refused; it is sent again once it refreshes. + if (response.status >= 500 || response.status === 401) return { ok: false, reason: 'server', code }; + return { ok: false, reason: 'rejected', code }; + } catch { + // Offline, or past the budget: the transaction stays unfinished either way. + return { ok: false, reason: 'network' }; + } finally { + clearTimeout(timeout); + } +} + +/** + * Sends one transaction and finishes it once the server has it. Runs inside + * the serial queue. A finish that fails leaves it in Transaction.unfinished, + * and sending it again is harmless. + */ +async function deliver(transaction: StoreTransaction, userId: string): Promise { + const version = transaction.signedTransaction; + // The server already has this version: only finish it, in case the last finish failed. + const result: Delivery = delivered.has(version) ? { ok: true, plan: delivered.get(version) ?? null } : await post(transaction, userId); + if (!result.ok) return result; + delivered.set(version, result.plan); + await storeKit() + ?.finish(transaction.transactionId) + .catch((cause: unknown) => console.warn('Could not finish an App Store transaction:', storeKitErrorCode(cause) ?? cause)); + return result; +} + +async function signedInAs(userId: string): Promise { + const { data } = await supabase.auth.getSession(); + return data.session?.user.id === userId; +} + +/** The store's products for both tiers, or null without StoreKit or when the App Store does not list both. */ +export async function loadProducts(): Promise | null> { + const native = storeKit(); + if (!native) return null; + const products = await native.products(Object.values(PRODUCT_IDS)); + const basic = products.find((product) => product.id === PRODUCT_IDS.basic); + const pro = products.find((product) => product.id === PRODUCT_IDS.pro); + return basic && pro ? { basic, pro } : null; +} + +/** + * Buys `tier` for `userId`: Apple's sheet, then the server, then finish. The + * sheet waits for the student, so only the request and finish wait in the + * queue. A cancelled sheet is quiet; anything that keeps the transaction + * unfinished is sent again later by deliverPending. + */ +export async function buy(tier: PurchaseTier, userId: string): Promise { + const native = storeKit(); + if (!native) return { kind: 'unavailable' }; + // The purchase is made out to `userId`, so it must be the account on screen. + if (!(await signedInAs(userId))) return { kind: 'failed', reason: 'accountChanged' }; + let result: StorePurchaseResult; + try { + result = await native.purchase(PRODUCT_IDS[tier], userId); + } catch (cause) { + return { kind: 'failed', reason: 'store', code: storeKitErrorCode(cause) }; + } + if (result.status === 'cancelled') return { kind: 'cancelled' }; + if (result.status === 'pending') return { kind: 'pending' }; + const transaction: StoreTransaction = result; + const delivery = await serial(() => deliver(transaction, userId)); + if (delivery.ok) return { kind: 'purchased', plan: delivery.plan }; + if (delivery.reason === 'unavailable') return { kind: 'unavailable' }; + return { kind: 'failed', reason: delivery.reason, code: delivery.code }; +} + +/** + * Sends every unfinished transaction bought for `userId`, one at a time, and + * finishes those the server took. Returns how many it applied. Stops at the + * first failure that would fail the rest too (offline, server not configured, + * account changed); a refused one is left and the next is tried. + */ +export function deliverPending(userId: string): Promise { + const native = storeKit(); + if (!native) return Promise.resolve(0); + return serial(async () => { + let applied = 0; + // Read inside the queue, so a run queued behind another sees what that one finished. + for (const transaction of await native.unfinished()) { + if (!belongsTo(transaction, userId)) continue; + const result = await deliver(transaction, userId); + if (result.ok) applied += 1; + else if (result.reason !== 'rejected') break; + } + return applied; + }); +} + +/** + * Restore purchases: AppStore.sync() (it may ask for the Apple ID password), + * then sends each current subscription bought for `userId`. + */ +export async function restore(userId: string): Promise { + const native = storeKit(); + if (!native) return { kind: 'unavailable' }; + try { + await native.sync(); + } catch (cause) { + const code = storeKitErrorCode(cause); + if (code === 'ERR_CANCELLED') return { kind: 'cancelled' }; + return { kind: 'failed', reason: 'store', code }; + } + return serial(async (): Promise => { + let entitlements: StoreTransaction[]; + try { + entitlements = await native.currentEntitlements(); + } catch (cause) { + return { kind: 'failed', reason: 'store', code: storeKitErrorCode(cause) }; + } + let applied = 0; + let plan: PurchasedPlan | null = null; + const own = entitlements.filter((transaction) => belongsTo(transaction, userId)); + for (const transaction of own) { + // Not deliver(): a restore sends even what this run already sent, since the server may have lost it. + const result = await post(transaction, userId); + if (!result.ok) { + if (result.reason === 'rejected') continue; + return result.reason === 'unavailable' ? { kind: 'unavailable' } : { kind: 'failed', reason: result.reason, code: result.code }; + } + applied += 1; + plan = result.plan ?? plan; + await native.finish(transaction.transactionId).catch(() => {}); + } + // Only a purchase made out to another account counts as theirs; one without a token belongs to no account yet. + const otherAccount = entitlements.filter((transaction) => transaction.appAccountToken !== null && !belongsTo(transaction, userId)).length; + return { kind: 'restored', applied, otherAccount, plan }; + }); +} + +/** Apple's Manage Subscriptions sheet. False without StoreKit or when it could not be shown. */ +export async function manageSubscriptions(): Promise { + const native = storeKit(); + if (!native) return false; + return native.showManageSubscriptions().then( + () => true, + () => false, + ); +} + +/** + * A transaction StoreKit reports as updated: sent even when this run already + * delivered its id, because an update can be a new signed version of the same + * transaction (a refund, a revocation, a changed expiry), which the server + * should have before App Store Server Notifications bring it. + */ +function deliverUpdate(transaction: StoreTransaction, userId: string): Promise { + if (!belongsTo(transaction, userId)) return Promise.resolve(); + return serial(async () => { + // A version the server already took this run was finished then; an update repeating it needs nothing. + if (delivered.has(transaction.signedTransaction)) return; + await deliver(transaction, userId); + }); +} + +/** + * For the root layout: delivers unfinished transactions now and whenever the + * app returns to the foreground, and each transaction update as it comes (a + * renewal, an approved Ask to Buy, a refund, a purchase on another device), + * for whoever `getUserId` says is signed in. Returns the unsubscribe. Updates + * that arrive while nobody is signed in stay unfinished until the buyer signs + * in again; a finished one comes back as a current entitlement on Restore. + */ +export function startPurchaseListener(getUserId: () => string | null): () => void { + const native = storeKit(); + if (!native) return () => {}; + const run = () => { + const userId = getUserId(); + if (!userId) return; + deliverPending(userId).catch((cause: unknown) => console.warn('Could not deliver App Store purchases:', storeKitErrorCode(cause) ?? cause)); + }; + const updates = native.addListener('onTransactionUpdate', (transaction) => { + const userId = getUserId(); + if (!userId) return; + deliverUpdate(transaction, userId).catch((cause: unknown) => console.warn('Could not deliver an App Store update:', storeKitErrorCode(cause) ?? cause)); + }); + const foreground = AppState.addEventListener('change', (state) => { + if (state === 'active') run(); + }); + run(); + return () => { + updates.remove(); + foreground.remove(); + }; +} diff --git a/apps/mobile/src/lib/push.ts b/apps/mobile/src/lib/push.ts index 40b1406f2..ce3fe9f22 100644 --- a/apps/mobile/src/lib/push.ts +++ b/apps/mobile/src/lib/push.ts @@ -55,9 +55,13 @@ type Registration = { userId: string; token: string; id: string; confirmed: bool // flight instead of before it, and the next account never inherits the row. const serial = createSerialQueue(); -// Shown while the app is open too; without a handler iOS drops it. +// Shown while the app is open too; without a handler iOS drops it. A "ready" +// push for a live run is not: on screen, the answer itself has arrived. Notifications.setNotificationHandler({ - handleNotification: async () => ({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: false, shouldSetBadge: false }), + handleNotification: async (notification) => { + const show = (notification.request.content.data as { kind?: unknown } | undefined)?.kind !== 'result_ready'; + return { shouldShowBanner: show, shouldShowList: show, shouldPlaySound: false, shouldSetBadge: false }; + }, }); function projectId(): string | undefined { diff --git a/apps/mobile/src/lib/review-prompt.ts b/apps/mobile/src/lib/review-prompt.ts new file mode 100644 index 000000000..d5210bb98 --- /dev/null +++ b/apps/mobile/src/lib/review-prompt.ts @@ -0,0 +1,50 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as StoreReview from 'expo-store-review'; + +/** + * Asks for an App Store rating through Apple's own prompt, at a good moment: + * a few seconds after a finished result has opened, never in the middle of + * work. Only from the third finished result on, and at most every 120 days. + * iOS shows the prompt at most three times a year and not at all in Expo Go + * or TestFlight, and decides by itself whether to show it, so this only + * offers. + */ + +const KEY = 'debateowl.review-prompt'; +const FIRST_AFTER = 3; +const MIN_GAP_MS = 120 * 24 * 60 * 60_000; +const DELAY_MS = 2_500; + +type Stored = { wins: number; askedAt: number | null }; + +async function read(): Promise { + try { + const value: unknown = JSON.parse((await AsyncStorage.getItem(KEY)) ?? 'null'); + const { wins, askedAt } = (value ?? {}) as Record; + return { wins: typeof wins === 'number' ? wins : 0, askedAt: typeof askedAt === 'number' ? askedAt : null }; + } catch { + return { wins: 0, askedAt: null }; + } +} + +/** Whether to offer the prompt now, after counting this result. */ +export function shouldAskForReview(stored: Stored, now: number): boolean { + if (stored.wins < FIRST_AFTER) return false; + return stored.askedAt === null || now - stored.askedAt >= MIN_GAP_MS; +} + +/** Counts a finished result (a case, an attack, a brainstorm, factoids, a judged round) and may offer the prompt. */ +export function noteFinishedResult(): void { + void (async () => { + try { + const stored = await read(); + const next = { ...stored, wins: stored.wins + 1 }; + const now = Date.now(); + const ask = shouldAskForReview(next, now) && (await StoreReview.hasAction()); + await AsyncStorage.setItem(KEY, JSON.stringify(ask ? { ...next, askedAt: now } : next)); + if (ask) setTimeout(() => void StoreReview.requestReview().catch(() => {}), DELAY_MS); + } catch (cause) { + console.warn('Could not count the result for the review prompt:', cause instanceof Error ? cause.message : cause); + } + })(); +} diff --git a/apps/mobile/src/lib/social-sign-in.ts b/apps/mobile/src/lib/social-sign-in.ts new file mode 100644 index 000000000..584634c65 --- /dev/null +++ b/apps/mobile/src/lib/social-sign-in.ts @@ -0,0 +1,103 @@ +import * as AppleAuthentication from 'expo-apple-authentication'; +import Constants from 'expo-constants'; +import * as Crypto from 'expo-crypto'; +import * as WebBrowser from 'expo-web-browser'; +import { Platform } from 'react-native'; + +import { oauthCallback } from './oauth-callback'; +import { supabase } from './supabase'; + +/** + * Sign in with Apple and with Google, into the same Supabase accounts the + * website signs in to. + * + * Apple: the system sheet gives an identity token for the app's bundle id, + * which Supabase checks against the Apple provider's client IDs + * (`signInWithIdToken`). The nonce is sent hashed to Apple and raw to + * Supabase, which hashes it again to match, so a token cannot be replayed. + * Apple tells the app the student's name once, on the first sign-in only, so + * it is saved to the account then. + * + * Google: the website's OAuth flow in an ASWebAuthenticationSession, which + * hands the redirect straight back to the app (no other app can catch it), and + * PKCE, so the code is useless without the verifier this client keeps. + * + * Each turns on from app.config.ts (`extra.signInWithApple`, + * `extra.googleSignIn`), which sets them only for builds that can finish the + * flow; Expo Go's config never has them. The Supabase providers and the + * redirect URL are set up as docs/apple-platform.md says. + */ + +export type SocialOutcome = 'signed-in' | 'cancelled'; + +/** The redirect Google sign-in returns to; it must be in Supabase Auth's redirect allow list. */ +export const OAUTH_REDIRECT = 'debatelab://auth/callback'; + +const extra = () => (Constants.expoConfig?.extra ?? {}) as { signInWithApple?: unknown; googleSignIn?: unknown }; + +export async function appleSignInAvailable(): Promise { + if (Platform.OS !== 'ios' || extra().signInWithApple !== true) return false; + try { + return await AppleAuthentication.isAvailableAsync(); + } catch { + return false; + } +} + +export function googleSignInAvailable(): boolean { + return Platform.OS === 'ios' && extra().googleSignIn === true; +} + +function isCancel(cause: unknown): boolean { + return typeof cause === 'object' && cause !== null && (cause as { code?: unknown }).code === 'ERR_REQUEST_CANCELED'; +} + +export async function signInWithApple(): Promise { + const rawNonce = Crypto.randomUUID(); + const hashedNonce = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, rawNonce); + let credential: AppleAuthentication.AppleAuthenticationCredential; + try { + credential = await AppleAuthentication.signInAsync({ + requestedScopes: [AppleAuthentication.AppleAuthenticationScope.FULL_NAME, AppleAuthentication.AppleAuthenticationScope.EMAIL], + nonce: hashedNonce, + }); + } catch (cause) { + if (isCancel(cause)) return 'cancelled'; + throw cause; + } + if (!credential.identityToken) throw new Error('Apple did not return an identity token.'); + const { error } = await supabase.auth.signInWithIdToken({ provider: 'apple', token: credential.identityToken, nonce: rawNonce }); + if (error) throw error; + + // Only the first sign-in carries the name; a failure to save it does not undo the sign-in. + const given = credential.fullName?.givenName?.trim(); + const family = credential.fullName?.familyName?.trim(); + if (given || family) { + const fullName = [given, family].filter(Boolean).join(' '); + const { error: nameError } = await supabase.auth.updateUser({ + data: { full_name: fullName, ...(given ? { given_name: given } : {}), ...(family ? { family_name: family } : {}) }, + }); + if (nameError) console.warn('Could not save the name from Apple:', nameError.message); + } + return 'signed-in'; +} + +export async function signInWithGoogle(): Promise { + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { redirectTo: OAUTH_REDIRECT, skipBrowserRedirect: true }, + }); + if (error) throw error; + if (!data.url) throw new Error('Google sign-in did not start.'); + const result = await WebBrowser.openAuthSessionAsync(data.url, OAUTH_REDIRECT); + if (result.type !== 'success') return 'cancelled'; + const callback = oauthCallback(result.url, OAUTH_REDIRECT); + if (callback.kind === 'error') { + // The student pressed Cancel on Google's own page. + if (callback.code === 'access_denied') return 'cancelled'; + throw new Error(callback.message); + } + const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(callback.code); + if (exchangeError) throw exchangeError; + return 'signed-in'; +} diff --git a/apps/mobile/src/lib/store-kit.ts b/apps/mobile/src/lib/store-kit.ts new file mode 100644 index 000000000..8d094f725 --- /dev/null +++ b/apps/mobile/src/lib/store-kit.ts @@ -0,0 +1,90 @@ +import { requireOptionalNativeModule } from 'expo'; + +/** + * The app's StoreKit 2 module (modules/store-kit), as plain JSON. It links + * into every native build; Expo Go and a build from before it have no module, + * so storeKit() is null there and nothing here is ever called. purchases.ts + * owns the flow; screens go through it, not through this file. + * + * Errors the module throws carry a `code`: ERR_UNKNOWN_PRODUCT, + * ERR_INVALID_ACCOUNT_TOKEN, ERR_UNVERIFIED (StoreKit could not verify the + * transaction's signature), ERR_PURCHASE_NOT_ALLOWED (Screen Time or a + * parent turned purchases off), ERR_NETWORK, ERR_CANCELLED (the App Store + * sign-in that sync() shows was dismissed), ERR_NO_WINDOW_SCENE (a sheet was + * asked for while the app was not on screen) and ERR_STORE_KIT for the rest. + */ + +export type PeriodUnit = 'day' | 'week' | 'month' | 'year'; + +export interface SubscriptionPeriod { + unit: PeriodUnit; + value: number; +} + +export interface IntroOffer { + displayPrice: string; + period: SubscriptionPeriod; + paymentMode: 'freeTrial' | 'payAsYouGo' | 'payUpFront'; + periodCount: number; +} + +export interface StoreProduct { + id: string; + displayName: string; + description: string; + /** The localized price, ready to show, e.g. "4,99 €". */ + displayPrice: string; + /** The price as a decimal string, for arithmetic only. */ + price: string; + currencyCode: string; + /** Missing for a product that is not a subscription. */ + period?: SubscriptionPeriod; + introOffer?: IntroOffer; + isEligibleForIntroOffer: boolean; +} + +/** One verified transaction, as the app-store-purchase function takes it. */ +export interface StoreTransaction { + transactionId: string; + productId: string; + /** The transaction's JWS (jwsRepresentation). */ + signedTransaction: string; + /** The subscription's renewal info JWS, when StoreKit has it. */ + signedRenewalInfo?: string; + /** The account the purchase was made for, lowercased; null when it set none (an offer code redeemed in the App Store app, Family Sharing). */ + appAccountToken: string | null; + /** ISO 8601; null for a product that does not expire. */ + expiresAt: string | null; +} + +export type StorePurchaseResult = + | ({ status: 'purchased' } & StoreTransaction) + | { status: 'pending' } + | { status: 'cancelled' }; + +export interface StoreKitModule { + products(ids: string[]): Promise; + /** Shows Apple's purchase sheet. The transaction is left unfinished: finish() it once the server has it. */ + purchase(productId: string, appAccountToken: string): Promise; + finish(transactionId: string): Promise; + currentEntitlements(): Promise; + unfinished(): Promise; + /** AppStore.sync(): may ask for the Apple ID password. */ + sync(): Promise; + showManageSubscriptions(): Promise; + presentOfferCodeRedeemSheet(): Promise; + addListener(event: 'onTransactionUpdate', listener: (transaction: StoreTransaction) => void): { remove: () => void }; +} + +let loaded: StoreKitModule | null | undefined; + +export function storeKit(): StoreKitModule | null { + if (loaded === undefined) loaded = requireOptionalNativeModule('DebateOwlStoreKit'); + return loaded; +} + +/** The code a native StoreKit error carries, or undefined for anything else. */ +export function storeKitErrorCode(error: unknown): string | undefined { + const code = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined; + return typeof code === 'string' ? code : undefined; +} diff --git a/apps/mobile/src/lib/store-kit.web.ts b/apps/mobile/src/lib/store-kit.web.ts new file mode 100644 index 000000000..dfa392e0d --- /dev/null +++ b/apps/mobile/src/lib/store-kit.web.ts @@ -0,0 +1,116 @@ +import type { StoreKitModule, StoreProduct, StorePurchaseResult, StoreTransaction } from './store-kit'; + +export type { + IntroOffer, + PeriodUnit, + StoreKitModule, + StoreProduct, + StorePurchaseResult, + StoreTransaction, + SubscriptionPeriod, +} from './store-kit'; + +/** + * The web preview has no StoreKit. It acts as a store with placeholder + * prices (not the App Store's) and records every call on + * `window.__debatelabStoreKit.calls`, so the screenshot script can show the + * plan screens and check what the app asked for. A purchase answers + * `window.__debatelabStoreKit.outcome`, 'pending' (Ask to Buy) unless the + * script sets another, so by default nothing is sent to the server. A + * 'purchased' one carries no signature: the server refuses it, and it is + * never kept as unfinished, so it is not sent again. The phone app uses + * store-kit.ts. + */ + +type StoreKitProbe = { + __debatelabStoreKit?: { calls: { name: string; args: unknown[] }[]; outcome?: StorePurchaseResult['status'] }; +}; + +function probe() { + const target = window as unknown as StoreKitProbe; + target.__debatelabStoreKit ??= { calls: [] }; + return target.__debatelabStoreKit; +} + +function record(name: string, ...args: unknown[]) { + probe().calls.push({ name, args }); +} + +const PLACEHOLDER_PRODUCTS: Record> = { + 'com.pistack.debateowl.basic.monthly': { + displayName: 'Basic', + description: 'Basic monthly', + displayPrice: '$4.99', + price: '4.99', + currencyCode: 'USD', + period: { unit: 'month', value: 1 }, + introOffer: { displayPrice: '$0.00', period: { unit: 'week', value: 1 }, paymentMode: 'freeTrial', periodCount: 1 }, + isEligibleForIntroOffer: true, + }, + 'com.pistack.debateowl.pro.monthly': { + displayName: 'Pro', + description: 'Pro monthly', + displayPrice: '$9.99', + price: '9.99', + currencyCode: 'USD', + period: { unit: 'month', value: 1 }, + isEligibleForIntroOffer: false, + }, +}; + +let nextId = 1; + +const preview: StoreKitModule = { + async products(ids) { + record('products', ids); + return ids.flatMap((id) => { + const product = PLACEHOLDER_PRODUCTS[id]; + return product ? [{ id, ...product }] : []; + }); + }, + async purchase(productId, appAccountToken): Promise { + record('purchase', productId, appAccountToken); + const outcome = probe().outcome ?? 'pending'; + if (outcome !== 'purchased') return { status: outcome }; + const transaction: StoreTransaction = { + transactionId: `preview-${nextId++}`, + productId, + signedTransaction: 'preview', + appAccountToken: appAccountToken.toLowerCase(), + expiresAt: null, + }; + return { status: 'purchased', ...transaction }; + }, + async finish(transactionId) { + record('finish', transactionId); + }, + async currentEntitlements() { + record('currentEntitlements'); + return []; + }, + async unfinished() { + record('unfinished'); + return []; + }, + async sync() { + record('sync'); + }, + async showManageSubscriptions() { + record('showManageSubscriptions'); + }, + async presentOfferCodeRedeemSheet() { + record('presentOfferCodeRedeemSheet'); + }, + addListener() { + return { remove: () => {} }; + }, +}; + +export function storeKit(): StoreKitModule | null { + return preview; +} + +export function storeKitErrorCode(error: unknown): string | undefined { + const code = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined; + return typeof code === 'string' ? code : undefined; +} diff --git a/apps/mobile/src/lib/supabase.ts b/apps/mobile/src/lib/supabase.ts index c582bda65..844acdc9a 100644 --- a/apps/mobile/src/lib/supabase.ts +++ b/apps/mobile/src/lib/supabase.ts @@ -15,6 +15,9 @@ export const supabase = createClient(supabaseProject.url, supabaseProject.anonKe autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, + // Google sign-in (social-sign-in.ts) exchanges a code with the verifier kept + // in this storage. Email and demo codes verify directly and are unaffected. + flowType: 'pkce', }, }); diff --git a/apps/mobile/src/lib/timer-alerts.ts b/apps/mobile/src/lib/timer-alerts.ts new file mode 100644 index 000000000..72ef29701 --- /dev/null +++ b/apps/mobile/src/lib/timer-alerts.ts @@ -0,0 +1,89 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Notifications from 'expo-notifications'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AppState, type AppStateStatus } from 'react-native'; + +import { timerAlertAt, type TimerSession } from './timer'; + +/** + * Time up while the app is away. The timer's own signals (timer-signals.ts) + * need the app running, and iOS suspends it a few seconds after the phone + * locks, so a speech or prep bank that runs out on a locked phone would pass + * in silence. When the app leaves the screen with a clock running, a local + * notification is set for the moment it runs out, with sound unless the timer + * is muted; coming back, or any change to the round, takes it away again. + * Local notifications need no server and work in Expo Go. + * + * It is marked time-sensitive, so it can break through a Focus. That needs the + * Time Sensitive Notifications capability on the App ID; without it iOS + * delivers it as an ordinary notification. + */ + +const ALERT_ID = 'debateowl-timer-up'; +const ASKED_KEY = 'debateowl.timer-alerts-asked'; + +/** + * Asks for notification permission the first time the student starts a clock, + * which is when the reason is plain. Asked once per install; afterwards iOS + * Settings decides. + */ +export async function askForTimerAlerts(): Promise { + try { + if (await AsyncStorage.getItem(ASKED_KEY)) return; + await AsyncStorage.setItem(ASKED_KEY, '1'); + const current = await Notifications.getPermissionsAsync(); + if (current.status !== 'undetermined') return; + await Notifications.requestPermissionsAsync({ ios: { allowAlert: true, allowSound: true, allowBadge: false } }); + } catch (cause) { + console.warn('Could not ask for timer alerts:', cause instanceof Error ? cause.message : cause); + } +} + +async function allowed(): Promise { + const permissions = await Notifications.getPermissionsAsync(); + return permissions.granted || permissions.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; +} + +/** Keeps the time-up alert in step with the round and with whether the app is on screen. Used by TimerRunner. */ +export function useTimerAlerts(session: TimerSession, loaded: boolean) { + const { t } = useTranslation(); + const [appState, setAppState] = useState(AppState.currentState); + useEffect(() => { + const subscription = AppState.addEventListener('change', setAppState); + return () => subscription.remove(); + }, []); + + useEffect(() => { + if (!loaded) return; + let cancelled = false; + void (async () => { + try { + await Notifications.cancelScheduledNotificationAsync(ALERT_ID); + // On screen the timer sounds its own signals. + if (appState === 'active' || cancelled) return; + const alert = timerAlertAt(session, Date.now()); + if (!alert || !(await allowed()) || cancelled) return; + const content = + alert.kind === 'prep' + ? { title: t('mobile.timerAlert.prepTitle'), body: t('mobile.timerAlert.prepBody') } + : { title: t('mobile.timerAlert.speechTitle', { speech: alert.segment.short }), body: t('mobile.timerAlert.speechBody', { speech: alert.segment.label }) }; + await Notifications.scheduleNotificationAsync({ + identifier: ALERT_ID, + content: { + ...content, + sound: session.muted ? false : 'default', + interruptionLevel: 'timeSensitive', + data: { kind: 'timer' }, + }, + trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: new Date(alert.at) }, + }); + } catch (cause) { + console.warn('Could not set the time-up alert:', cause instanceof Error ? cause.message : cause); + } + })(); + return () => { + cancelled = true; + }; + }, [appState, loaded, session, t]); +} diff --git a/apps/mobile/src/lib/timer-alerts.web.ts b/apps/mobile/src/lib/timer-alerts.web.ts new file mode 100644 index 000000000..bfe6cfc7b --- /dev/null +++ b/apps/mobile/src/lib/timer-alerts.web.ts @@ -0,0 +1,6 @@ +import type { TimerSession } from './timer'; + +/** The web preview has no local notifications; the phone app uses timer-alerts.ts. */ +export async function askForTimerAlerts(): Promise {} + +export function useTimerAlerts(_session: TimerSession, _loaded: boolean) {} diff --git a/apps/mobile/src/lib/timer-store.ts b/apps/mobile/src/lib/timer-store.ts index ff8391630..0e3073ec7 100644 --- a/apps/mobile/src/lib/timer-store.ts +++ b/apps/mobile/src/lib/timer-store.ts @@ -1,7 +1,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { useSyncExternalStore } from 'react'; -import { initialSession, parseSavedFormats, parseSession, serializeSession, upsertSavedFormat, type SavedFormat, type TimerSession } from './timer'; +import { initialSession, parseSavedFormats, parseSession, serializeSession, settleHeld, upsertSavedFormat, type SavedFormat, type TimerSession } from './timer'; // The timer's round and the custom formats saved on this device, as the // website keeps its round in localStorage. Not tied to an account: a timer @@ -64,7 +64,8 @@ export function getTimer(): TimerStoreState { } export function updateSession(change: (current: TimerSession) => TimerSession) { - const next = change(state.session); + // Which prep bank was last paused, whichever button stopped it (timer.ts's settleHeld). + const next = settleHeld(state.session, change(state.session)); if (next === state.session) return; if (!state.loaded) changedBeforeLoad.session = true; state = { ...state, session: next }; diff --git a/apps/mobile/src/lib/timer.test.ts b/apps/mobile/src/lib/timer.test.ts index 8d681bb1f..5748d2cd4 100644 --- a/apps/mobile/src/lib/timer.test.ts +++ b/apps/mobile/src/lib/timer.test.ts @@ -1,13 +1,23 @@ -import { togglePrep, toggleSpeech } from '@debatelab/core/free-tools/round-timer'; +import { settlePrep, togglePrep, toggleSpeech } from '@debatelab/core/free-tools/round-timer'; import { describe, expect, it } from 'vitest'; -import { initialSession, parseSavedFormats, parseSession, serializeSession, timerActivityState, type TimerSession } from './timer'; +import { + applyTimerActivityAction, + initialSession, + parseSavedFormats, + parseSession, + serializeSession, + settleHeld, + timerActivityState, + timerAlertAt, + type TimerSession, +} from './timer'; const T0 = 1_000_000; const text = { sideName: (side: string) => side, prepLine: (side: string) => `prep ${side}`, - labels: { speaking: 'Speaking', paused: 'Paused', overtime: 'Over time', prep: 'Prep', idleTitle: 'Timer', idleLine: 'Idle' }, + labels: { speaking: 'Speaking', paused: 'Paused', overtime: 'Over time', prep: 'Prep', idleTitle: 'Timer', idleLine: 'Idle', pause: 'Pause', resume: 'Resume', next: 'Next' }, }; const custom = { name: 'Club', prepSeconds: 120, segments: [{ label: 'Opening', seconds: 300, kind: 'speech' as const, side: 'a' as const, poi: true }] }; @@ -53,3 +63,66 @@ describe('Live Activity clock', () => { expect(prep).toMatchObject({ kind: 'prep', line: 'prep b', side: 'b', endsAt: T0 + 780_000, pausedAt: null }); }); }); + +describe('Live Activity buttons and the time-up alert', () => { + it('pauses the clock the activity shows: a running prep bank before the speech', () => { + let session = initialSession({ id: 'custom', custom }); + session = { ...session, round: toggleSpeech(session.round, T0) }; + session = { ...session, round: togglePrep(session.round, 'b', T0 + 10_000) }; + const paused = applyTimerActivityAction(session, 'toggle', T0 + 20_000); + expect(paused.round.prep.b.deadline).toBeNull(); + expect(paused.round.speech.startedAt).toBeNull(); + // The paused bank stays on the Lock Screen, so the same button restarts it, not the speech. + const resumed = applyTimerActivityAction(paused, 'toggle', T0 + 30_000); + expect(resumed.round.prep.b.deadline).not.toBeNull(); + expect(resumed.round.speech).toEqual({ elapsedMs: 10_000, startedAt: null }); + // With no bank held, it resumes the speech where it stopped. + const speech = applyTimerActivityAction({ ...paused, held: null }, 'toggle', T0 + 30_000); + expect(speech.round.speech).toEqual({ elapsedMs: 10_000, startedAt: T0 + 30_000 }); + }); + + it('keeps a prep bank paused from the Lock Screen on it, so Resume restarts that prep', () => { + let session = initialSession({ id: 'custom', custom }); + session = { ...session, round: togglePrep(session.round, 'b', T0) }; + const paused = applyTimerActivityAction(session, 'toggle', T0 + 30_000); + const shown = timerActivityState(paused, T0 + 40_000, { ...text, controls: true }); + expect(shown).toMatchObject({ kind: 'prep', side: 'b', pausedAt: T0 + 40_000, endsAt: T0 + 40_000 + 90_000 }); + const resumed = applyTimerActivityAction(paused, 'toggle', T0 + 50_000); + expect(resumed.round.prep.b.deadline).toBe(T0 + 50_000 + 90_000); + expect(resumed.round.speech.startedAt).toBeNull(); + }); + + it('lets go of a paused bank once another clock starts, it runs out, or the format changes', () => { + let session = initialSession({ id: 'custom', custom }); + session = { ...session, round: togglePrep(session.round, 'a', T0) }; + const paused = applyTimerActivityAction(session, 'toggle', T0 + 10_000); + expect(paused.held).toBe('a'); + expect(settleHeld(paused, { ...paused, round: toggleSpeech(paused.round, T0 + 20_000) }).held).toBeUndefined(); + expect(settleHeld(paused, { ...paused, choice: { id: 'bp' }, round: initialSession({ id: 'bp' }).round }).held).toBeUndefined(); + const emptied = { ...session, round: settlePrep(session.round, T0 + 121_000) }; + expect(settleHeld(session, emptied).held).toBeUndefined(); + // Stored and read back with the round. + expect(parseSession(JSON.parse(JSON.stringify(serializeSession(paused))))?.held).toBe('a'); + }); + + it('moves on to a fresh, paused next speech, and not past the last one', () => { + let session = initialSession({ id: 'custom', custom }); + expect(applyTimerActivityAction(session, 'next', T0)).toBe(session); + session = initialSession({ id: 'bp' }); + session = { ...session, round: toggleSpeech(session.round, T0) }; + const next = applyTimerActivityAction(session, 'next', T0 + 5_000); + expect(next.round.segmentIndex).toBe(1); + expect(next.round.speech).toEqual({ elapsedMs: 0, startedAt: null }); + expect(timerActivityState(next, T0 + 5_000, { ...text, controls: true })).toBeNull(); + }); + + it('alerts at the limit of the running clock, and never for a paused or finished one', () => { + let session = initialSession({ id: 'custom', custom }); + expect(timerAlertAt(session, T0)).toBeNull(); + session = { ...session, round: toggleSpeech(session.round, T0) }; + expect(timerAlertAt(session, T0 + 60_000)).toMatchObject({ at: T0 + 300_000, kind: 'speech' }); + expect(timerAlertAt(session, T0 + 301_000)).toBeNull(); + session = { ...session, round: togglePrep(session.round, 'a', T0 + 1_000) }; + expect(timerAlertAt(session, T0 + 1_000)).toMatchObject({ at: T0 + 121_000, kind: 'prep' }); + }); +}); diff --git a/apps/mobile/src/lib/timer.ts b/apps/mobile/src/lib/timer.ts index 94a6e200a..2007be479 100644 --- a/apps/mobile/src/lib/timer.ts +++ b/apps/mobile/src/lib/timer.ts @@ -11,12 +11,15 @@ import { customToRoundFormat, parseCustomFormat, type CustomFormat } from '@deba import { FORMATS, isBuiltInFormatId, type BuiltInFormatId, type RoundFormat, type RoundSegment } from '@debatelab/core/free-tools/formats'; import { elapsedMs, + goToSegment, initialRoundState, isPrepRunning, isSpeechRunning, parseRoundState, prepRemainingMs, segmentStatus, + togglePrep, + toggleSpeech, type PrepSide, type RoundState, type TimerCue, @@ -31,6 +34,12 @@ export interface TimerSession { choice: FormatChoice; round: RoundState; muted: boolean; + /** + * The prep bank last paused while it ran, with time left, while no clock + * runs: the clock the student just stopped, which the Lock Screen keeps + * showing so its Resume restarts it. Kept in step by `settleHeld`. + */ + held?: PrepSide | null; } /** A custom format the student saved on this device. */ @@ -88,13 +97,14 @@ export function serializeSession(session: TimerSession): unknown { ...(choice.id === 'custom' ? { custom: choice.custom, ...(choice.savedId ? { savedId: choice.savedId } : {}) } : {}), round: session.round, muted: session.muted, + ...(session.held ? { held: session.held } : {}), }; } /** A stored session read back, or null when it does not fit (an old version, a format that changed). */ export function parseSession(value: unknown): TimerSession | null { if (typeof value !== 'object' || value === null) return null; - const { v, format, custom, savedId, round, muted } = value as Record; + const { v, format, custom, savedId, round, muted, held } = value as Record; if (v !== 1) return null; let choice: FormatChoice | null = null; if (format === 'custom') { @@ -105,7 +115,8 @@ export function parseSession(value: unknown): TimerSession | null { } if (!choice) return null; const parsedRound = parseRoundState(round, formatFor(choice)); - return parsedRound ? { choice, round: parsedRound, muted: muted === true } : null; + if (!parsedRound) return null; + return { choice, round: parsedRound, muted: muted === true, ...(held === 'a' || held === 'b' ? { held } : {}) }; } /** Saved custom formats read back; anything malformed is dropped, the rest kept. */ @@ -147,6 +158,10 @@ export interface TimerActivityLabels { prep: string; idleTitle: string; idleLine: string; + /** The Live Activity's buttons. */ + pause: string; + resume: string; + next: string; } /** @@ -169,6 +184,14 @@ export interface TimerActivityState { /** Side colour: `a`, `b` or `both`. */ side: RoundSegment['side']; labels: TimerActivityLabels; + /** + * Pause or Resume, and Next speech, on the Lock Screen and in the Dynamic + * Island. iOS runs a Live Activity's buttons from iOS 17, so the app turns + * them off before that (the layout cannot ask which iOS it runs on). + */ + controls: boolean; + /** A later speech exists to move on to (never while prep runs). */ + canAdvance: boolean; } /** @@ -179,8 +202,9 @@ export interface TimerActivityState { export function timerActivityState( session: TimerSession, now: number, - text: { sideName: (side: RoundSegment['side']) => string; prepLine: (side: PrepSide) => string; labels: TimerActivityLabels }, + text: { sideName: (side: RoundSegment['side']) => string; prepLine: (side: PrepSide) => string; labels: TimerActivityLabels; controls?: boolean }, ): TimerActivityState | null { + const controls = text.controls === true; const format = formatFor(session.choice); const { round } = session; for (const side of ['a', 'b'] as PrepSide[]) { @@ -196,6 +220,26 @@ export function timerActivityState( pausedAt: null, side, labels: text.labels, + controls, + canAdvance: false, + }; + } + // The bank the student just paused stays on the Lock Screen, paused, until another clock starts. + const held = !isSpeechRunning(round) ? session.held : null; + if (held && round.prep[held].remainingMs > 0) { + const left = prepRemainingMs(round, held, now); + const total = format.prepSeconds * 1000; + return { + kind: 'prep', + title: text.labels.prep, + line: text.prepLine(held), + startedAt: now - (total - left), + endsAt: now + left, + pausedAt: now, + side: held, + labels: text.labels, + controls, + canAdvance: false, }; } const segment = format.segments[round.segmentIndex] ?? format.segments[0]; @@ -212,9 +256,66 @@ export function timerActivityState( pausedAt: running ? null : now, side: segment.side, labels: text.labels, + controls, + canAdvance: round.segmentIndex < format.segments.length - 1, }; } +const runningPrep = (round: RoundState): PrepSide | null => (['a', 'b'] as PrepSide[]).find((side) => isPrepRunning(round, side)) ?? null; + +/** + * `next` with `held` brought up to date after a change from `previous`: set + * when a running prep bank stopped with time left and nothing else started, + * cleared when any clock runs, the format changes or the held bank is empty. + * Every change goes through it (timer-store.ts's updateSession), so the timer + * screen's buttons and the Lock Screen's keep it the same way. + */ +export function settleHeld(previous: TimerSession, next: TimerSession): TimerSession { + const { round } = next; + let held: PrepSide | null; + if (isActive(round) || next.choice !== previous.choice) held = null; + else held = runningPrep(previous.round) ?? previous.held ?? null; + if (held && round.prep[held].remainingMs <= 0) held = null; + if ((next.held ?? null) === held) return next; + const settled: TimerSession = { ...next, held }; + if (!held) delete settled.held; + return settled; +} + +/** A button on the timer's Live Activity: Pause or Resume, or Next speech. */ +export type TimerActivityAction = 'toggle' | 'next'; + +/** + * What a Live Activity button does to the round, at `now`. Toggle acts on the + * clock the activity shows: a running prep bank, else the speech. Next moves + * to the next speech with a fresh, paused clock, as the timer screen's Next does. + */ +export function applyTimerActivityAction(session: TimerSession, action: TimerActivityAction, now: number): TimerSession { + const { round } = session; + if (action === 'next') { + const next = goToSegment(round, formatFor(session.choice), round.segmentIndex + 1, now); + return next === round ? session : settleHeld(session, { ...session, round: next }); + } + // A running bank pauses; a bank held on the Lock Screen resumes; otherwise the speech toggles. + const prepSide = runningPrep(round) ?? (!isSpeechRunning(round) ? session.held ?? null : null); + return settleHeld(session, { ...session, round: prepSide ? togglePrep(round, prepSide, now) : toggleSpeech(round, now) }); +} + +/** When the clock on screen reaches its limit while the app is away, for the time-up alert; null when nothing will. */ +export function timerAlertAt(session: TimerSession, now: number): { at: number; kind: 'speech' | 'prep'; segment: RoundSegment } | null { + const format = formatFor(session.choice); + const { round } = session; + const segment = format.segments[round.segmentIndex] ?? format.segments[0]; + for (const side of ['a', 'b'] as PrepSide[]) { + if (!isPrepRunning(round, side)) continue; + const left = prepRemainingMs(round, side, now); + return left > 0 ? { at: now + left, kind: 'prep', segment } : null; + } + if (!isSpeechRunning(round)) return null; + const left = segment.seconds * 1000 - elapsedMs(round.speech, now); + return left > 0 ? { at: now + left, kind: 'speech', segment } : null; +} + /** The speech's status for the screen: time left (negative in overtime), POI window and grace. */ export function speechStatus(session: TimerSession, now: number) { const format = formatFor(session.choice); diff --git a/apps/mobile/src/lib/use-apple-platform.ts b/apps/mobile/src/lib/use-apple-platform.ts new file mode 100644 index 000000000..31cc22f9b --- /dev/null +++ b/apps/mobile/src/lib/use-apple-platform.ts @@ -0,0 +1,52 @@ +import * as Notifications from 'expo-notifications'; +import { useRouter } from 'expo-router'; +import { useEffect, useRef } from 'react'; + +import { setReadyPushInstall } from './ai-request'; +import { getInstallId } from './device-storage'; +import { endLeftoverGeneratingActivities } from './generating-activity'; +import { notificationHref } from './notification-routes'; +import { deliverPending, startPurchaseListener } from './purchases'; + +/** + * The iOS integrations that run for the whole app, from the root layout: + * - activities a previous launch left behind are ended; + * - App Store transactions (a renewal, an approved Ask to Buy, a redeemed + * offer code, a purchase the server has not confirmed yet) reach the + * signed-in account at launch, on sign-in, on every update and when the app + * comes back to the foreground (purchases.ts); + * - a tapped notification (a judged Sparring round, the timer's time-up alert) + * opens its screen once the app is ready and signed in, including when the + * tap launched it. + */ +export function useApplePlatform(ready: boolean, userId: string | null) { + const router = useRouter(); + + useEffect(() => { + endLeftoverGeneratingActivities(); + // A live run's "ready" push goes to this phone only (ai-request.ts). + getInstallId() + .then(setReadyPushInstall) + .catch((cause: unknown) => console.warn('Could not read the install id:', cause instanceof Error ? cause.message : cause)); + }, []); + + const currentUser = useRef(userId); + currentUser.current = userId; + useEffect(() => startPurchaseListener(() => currentUser.current), []); + useEffect(() => { + if (!userId) return; + deliverPending(userId).catch((cause: unknown) => console.warn('Could not deliver App Store purchases:', cause instanceof Error ? cause.message : cause)); + }, [userId]); + + const response = Notifications.useLastNotificationResponse(); + const handled = useRef(null); + useEffect(() => { + if (!ready || !userId || !response || response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return; + const { request, date } = response.notification; + const key = `${request.identifier}:${date}`; + if (handled.current === key) return; + handled.current = key; + const href = notificationHref(request.content.data); + if (href) router.push(href); + }, [ready, response, router, userId]); +} diff --git a/apps/mobile/src/lib/use-apple-platform.web.ts b/apps/mobile/src/lib/use-apple-platform.web.ts new file mode 100644 index 000000000..bdbb8f3d7 --- /dev/null +++ b/apps/mobile/src/lib/use-apple-platform.web.ts @@ -0,0 +1,2 @@ +/** The web preview has no notifications or Live Activities; the phone app uses use-apple-platform.ts. */ +export function useApplePlatform(_ready: boolean, _userId: string | null) {} diff --git a/apps/mobile/src/lib/widget-images.ts b/apps/mobile/src/lib/widget-images.ts new file mode 100644 index 000000000..571f86202 --- /dev/null +++ b/apps/mobile/src/lib/widget-images.ts @@ -0,0 +1,67 @@ +import { requireOptionalNativeModule } from 'expo'; +import { File } from 'expo-file-system'; +import { Image } from 'react-native'; + +/** + * Ollie for the widget extension. The extension cannot read the app's + * bundle, so each owl pose it shows (the Current Debate widget's mark, the + * generating Live Activity's poses) is copied into the App Group folder once + * and drawn from there with `uiImage`. Without the folder (Expo Go, a build + * without DEBATELAB_APP_GROUP) every pose is null and the layouts show a symbol. + */ + +type WidgetsModule = { widgetsDirectory?: string | null }; + +const POSES = { + mark: require('@/assets/owl/mark.png'), + thinking: require('@/assets/owl/thinking.png'), + reading: require('@/assets/owl/reading.png'), + skeptical: require('@/assets/owl/skeptical.png'), + point: require('@/assets/owl/point.png'), + celebrating: require('@/assets/owl/celebrating.png'), + surprised: require('@/assets/owl/surprised.png'), +} as const; + +export type WidgetOwlPose = keyof typeof POSES; + +const copies = new Map>(); + +/** The pose's file URI in the App Group folder, copied there on first use; null when there is no folder or the copy fails. */ +export function widgetOwl(pose: WidgetOwlPose): Promise { + let copy = copies.get(pose); + if (!copy) { + copy = copyPose(pose); + copies.set(pose, copy); + } + return copy; +} + +/** Every pose the layouts may switch between, so a Live Activity never waits on a copy mid-run. */ +export async function widgetOwls

(poses: readonly P[]): Promise> { + const uris = await Promise.all(poses.map((pose) => widgetOwl(pose))); + return Object.fromEntries(poses.map((pose, index) => [pose, uris[index]])) as Record; +} + +async function copyPose(pose: WidgetOwlPose): Promise { + try { + const directory = requireOptionalNativeModule('ExpoWidgets')?.widgetsDirectory; + if (!directory) return null; + // The mark keeps the name the Current Debate widget has always used. + const target = new File(directory, pose === 'mark' ? 'owl-mark.png' : `owl-${pose}.png`); + if (target.exists) return target.uri; + const source = Image.resolveAssetSource(POSES[pose])?.uri; + if (!source) return null; + // A development build serves assets from Metro; a release build has them on disk. + if (/^https?:/i.test(source)) { + await File.downloadFileAsync(source, target, { idempotent: true }); + } else { + new File(source).copy(target); + } + return target.uri; + } catch (cause) { + // Forget the failure, so the next caller tries again. + copies.delete(pose); + console.warn(`Could not give the widgets the ${pose} owl:`, cause instanceof Error ? cause.message : cause); + return null; + } +} diff --git a/apps/mobile/src/widgets/generating-activity.tsx b/apps/mobile/src/widgets/generating-activity.tsx new file mode 100644 index 000000000..b67c47adc --- /dev/null +++ b/apps/mobile/src/widgets/generating-activity.tsx @@ -0,0 +1,97 @@ +import { Capsule, HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; +import { font, foregroundStyle, frame, lineLimit, monospacedDigit, padding, resizable } from '@expo/ui/swift-ui/modifiers'; +import { createLiveActivity, type LiveActivityEnvironment } from 'expo-widgets'; + +import type { GeneratingActivityState } from '@/lib/generating-activity-state'; + +// A case, attack, brainstorm or factoids run on the Lock Screen and in the +// Dynamic Island while the model writes it: Ollie in the pose the app's own +// live draft uses, the step it is on, how many parts are written, and the time +// since it started (a SwiftUI timer text, so it counts without the app). +// Like timer-widgets.tsx, the layout runs in the widget extension's own runtime +// ('widget' directive): no imports or outside names, every string in the props. +// +// The app is suspended soon after it leaves the screen, and the stream stops +// with it. The activity then goes stale (generating-activity.ts sets the date), +// and says to open DebateOwl, which picks the answer up from the server. +// +// Only generating-activity.ts loads this file, and only in a build that has +// the widget extension (nativeCapabilities().liveActivity). Expo Go never does. + +const GeneratingActivity = (props: GeneratingActivityState, environment: LiveActivityEnvironment) => { + 'widget'; + const BLUE = '#4CA6FD'; + const MINT = '#2FD39A'; + const CORAL = '#FF6B70'; + const TRACK = '#8E9FB666'; + const waiting = props.status === 'working' && environment.isStale === true; + const accent = props.status === 'done' ? MINT : props.status === 'failed' ? CORAL : BLUE; + const headline = + props.status === 'done' ? props.labels.done : props.status === 'failed' ? props.labels.failed : waiting ? props.labels.waiting : props.phaseLabel; + const symbol = + props.status === 'done' ? 'checkmark.circle.fill' : props.status === 'failed' ? 'exclamationmark.circle.fill' : waiting ? 'hand.tap.fill' : 'sparkles'; + const owlUri = props.status === 'done' ? props.owls.done : props.status === 'failed' ? props.owls.failed : props.owls[props.phase]; + const owl = (size: number) => + owlUri ? ( + + ) : ( + + ); + const elapsed = (size: number) => + props.status === 'working' ? ( + + ) : ( + + ); + // Research, write, check: filled up to the step the run is on; all three when it is done. + const reached = props.status === 'done' ? 3 : props.phase === 'checking' ? 3 : props.phase === 'writing' ? 2 : 1; + const steps = (width: number) => ( + + = 1 ? accent : TRACK)]} /> + = 2 ? accent : TRACK)]} /> + = 3 ? accent : TRACK)]} /> + + ); + const detail = props.status === 'working' && props.partsLabel && !waiting ? `${props.title} · ${props.partsLabel}` : props.title; + + return { + banner: ( + + {owl(52)} + + + {props.tool} + + {elapsed(13)} + + {headline} + {detail} + {props.status === 'failed' || waiting ? null : steps(40)} + + + ), + compactLeading: owlUri ? owl(22) : , + compactTrailing: props.status === 'working' && !waiting ? elapsed(14) : , + minimal: , + expandedLeading: {owl(44)}, + expandedCenter: ( + + {props.tool} + {headline} + + ), + expandedTrailing: {elapsed(15)}, + expandedBottom: ( + + {detail} + {props.status === 'failed' || waiting ? null : steps(88)} + + ), + }; +}; + +export const generatingActivity = createLiveActivity('DebateOwlGenerating', GeneratingActivity); diff --git a/apps/mobile/src/widgets/timer-widgets.tsx b/apps/mobile/src/widgets/timer-widgets.tsx index 6af63baec..f4a35eb1d 100644 --- a/apps/mobile/src/widgets/timer-widgets.tsx +++ b/apps/mobile/src/widgets/timer-widgets.tsx @@ -1,5 +1,5 @@ -import { HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; -import { font, foregroundStyle, lineLimit, monospacedDigit, padding } from '@expo/ui/swift-ui/modifiers'; +import { Button, HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; +import { buttonStyle, controlSize, font, foregroundStyle, lineLimit, monospacedDigit, padding, tint } from '@expo/ui/swift-ui/modifiers'; import { createLiveActivity, createWidget, type LiveActivityEnvironment, type WidgetEnvironment } from 'expo-widgets'; import type { TimerActivityState } from '@/lib/timer'; @@ -47,6 +47,28 @@ const DebateTimerActivity = (props: TimerActivityState, environment: LiveActivit /> ); const icon = props.kind === 'prep' ? 'hourglass' : props.pausedAt !== null ? 'pause.fill' : 'timer'; + // iOS 17's interactive Live Activities: the app runs the press (live-activity.ts) and sends the new clock back. + // Fixed targets, so the app knows which button it was whatever the layout around them. + const paused = props.pausedAt !== null; + const controls = (spacing: number) => + props.controls ? ( + +