diff --git a/.agents/skills/mobile-android-verification/SKILL.md b/.agents/skills/mobile-android-verification/SKILL.md new file mode 100644 index 00000000000..e6ec4ad83e1 --- /dev/null +++ b/.agents/skills/mobile-android-verification/SKILL.md @@ -0,0 +1,88 @@ +--- +name: mobile-android-verification +description: Run and verify apps/mobile on a local Android emulator against this worktree's API and relay, with adb and Maestro for input. Use when asked to run, screenshot, verify, or reproduce a mobile change on Android. For iOS use mobile-sim-verification; with no local toolchain (a cloud sandbox) use mobile-eas-dev, which is iOS only. +--- + +# Mobile Android Verification + +The Android counterpart to `mobile-sim-verification`. Its Rules apply here unchanged: use the real +screen, drive state through real services, run the edge pass, never show "unknown" as a negative, +record flashes, attribute failures correctly. This file is the Android mechanics. + +`apps/mobile/scripts/android-dev.sh` does the setup. Read its header for the flags. + +## Setup + +```bash +apps/mobile/scripts/android-dev.sh --doctor # what is missing on this machine +apps/mobile/scripts/android-dev.sh # boot, build if needed, Metro, open the app +apps/mobile/scripts/android-dev.sh --user maya@local.test # also sign in as a dev account +``` + +- **Toolchain.** Android Studio's SDK (`~/Library/Android/sdk`) with an AVD, and JDK 17. Gradle for + RN 0.88 fails on the Java 8 that macOS often has on `PATH`; `brew install openjdk@17`. The script + finds both without `ANDROID_HOME` or `JAVA_HOME` set. For Google sign-in, the AVD needs a Google + Play system image and a Google account added on the device. +- **Services.** Start the API, relay and realtime as in `mobile-sim-verification` (each in a + Paradev terminal). The script reverses Metro, `API_PORT`, `RELAY_PORT`, `REALTIME_PORT` and + `WEB_PORT` with `adb reverse`, so `localhost` in the app reaches this machine. A port it does not + reverse fails in the app as a network error. +- **Build.** `android/` is generated by `expo prebuild` and gitignored. The first Gradle build takes + about 10 minutes; later ones reuse the daemon. The script builds only for the emulator's ABI. + JS/TS changes need no rebuild: `android-dev.sh --reload`. + +## One emulator, many worktrees + +Every worktree installs the same package, `sh.superset.mobile`, so the last install wins. + +- The script writes the worktree path and native fingerprint of the installed APK to + `/data/local/tmp/superset-mobile-owner` on the device. A mismatch rebuilds, so another worktree's + APK is never tested by mistake. Check it before you report: `adb shell cat + /data/local/tmp/superset-mobile-owner`. +- Agents in parallel workspaces take turns. Ask with `superset agents` before you install or + launch, and say when you hand it back. Do not boot a second emulator next to a Gradle build: the + machine runs out of memory. +- Each worktree's Metro gets its own port (saved in `.tmp/android-dev/metro.port`). Stop it with + `--stop` when you are done. + +## Traps + +- **Stale Metro cache.** The transform cache is shared across worktrees. A stale entry fails the + Android bundle with a 500: ENOENT on `react-native-worklets/.worklets/.js`. The script + starts Metro with `--clear`; if you start Metro yourself, do the same. +- **Dev auto sign-in.** `EXPO_PUBLIC_DEV_ONE_TIME_TOKEN` in `.env` signs the app in at launch even + after `pm clear`. Blank it and restart Metro to see the sign-in screen. +- **Notification permission.** Nothing in the app asks for `POST_NOTIFICATIONS` yet; the script + grants it so the agent card can show on Android 13+. +- **iOS-only header items.** SF Symbol `Stack.Toolbar` buttons and `Stack.SearchBar` do not render + on Android, and a form sheet draws no native bar there (see the sheet convention in + `apps/mobile/AGENTS.md`). An empty header on Android is not always a regression. +- **The iOS fingerprint.** `app.config.ts`, `eas.json`, `apps/mobile/.gitignore` and + `packages/shared/src/app-identity.ts` feed the iOS runtime version even for Android-only edits. + Changing them rebuilds and resubmits iOS on merge. Check with + `MOBILE_EAS_BUILD=1 node_modules/.bin/fingerprint fingerprint:generate . --platform ios` in + `apps/mobile`, and keep such a change in its own commit. +- **The Mobile pane** in the desktop app shows the same emulator (local hub, scrcpy stream). It + releases an emulator it booted 60 s after its pane closes, which ends an `adb` session on it. If + you booted the emulator yourself, the pane only shows it. + +## Input and evidence + +```bash +adb exec-out screencap -p > shot.png # or: android-dev.sh --shot shot.png +adb shell screenrecord /sdcard/run.mp4 # stop with Ctrl-C, then adb pull +adb shell uiautomator dump /sdcard/ui.xml && adb pull /sdcard/ui.xml # element bounds for taps +adb shell input tap X Y; adb shell input text 'hello'; adb shell input keyevent 4 # 4 = back +adb shell am start -a android.intent.action.VIEW -d 'superset://settings' sh.superset.mobile +adb logcat -d -s ReactNativeJS:V # JS console +adb logcat -d -b crash # native crashes +adb shell cmd statusbar expand-notifications # the agent card lives here +adb shell cmd locale set-app-locales sh.superset.mobile --locales de-DE # per-app locale +adb shell settings put system font_scale 1.3 # large text; reset to 1.0 +``` + +Maestro runs on Android too: `maestro --device emulator-5554 test `, with `JAVA_HOME` on JDK 17. +`apps/mobile/.maestro/flows/dev-sign-in.yml` is the sign-in flow `--user` uses. + +Reset the locale and font scale, stop Metro (`--stop`) and the services, and remove test data when +done. Leave the emulator running if another agent is waiting for it. diff --git a/.env.example b/.env.example index 177cdc8cadb..8782b79b110 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,7 @@ DESKTOP_DISPLAY_NAME= EXPO_PUBLIC_API_URL= EXPO_PUBLIC_ROOT_DOMAIN= EXPO_PUBLIC_POSTHOG_KEY= +EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID= EXPO_PUBLIC_SENTRY_DSN_MOBILE= EXPO_PUBLIC_SENTRY_ENVIRONMENT= # App Review demo account, read by apps/mobile/store.config.js for `eas metadata:push` diff --git a/.env.local.example b/.env.local.example index c60a9b9f33a..91e1ab3b48a 100644 --- a/.env.local.example +++ b/.env.local.example @@ -49,6 +49,7 @@ DESKTOP_DISPLAY_NAME= EXPO_PUBLIC_API_URL=http://localhost:3001 EXPO_PUBLIC_ROOT_DOMAIN= EXPO_PUBLIC_POSTHOG_KEY=phc_local_dev_disabled +EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID= # A syntactically valid but inert DSN: @sentry/react-native's useNativeInit # crashes the app on launch (SIGSEGV, before any JS runs) if this fails to # parse as a URL, which an empty or missing value does. Needs a well-formed diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index 677f9db036c..08d14119852 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -44,6 +44,8 @@ yarn-error.* # generated native folders /ios /android +# Gradle output of a local module's android/ library +modules/*/android/build/ # storybook (generated on start) .rnstorybook/storybook.requires.ts diff --git a/apps/mobile/.maestro/flows/dev-sign-in.yml b/apps/mobile/.maestro/flows/dev-sign-in.yml new file mode 100644 index 00000000000..348369cbfdf --- /dev/null +++ b/apps/mobile/.maestro/flows/dev-sign-in.yml @@ -0,0 +1,28 @@ +# Signs in through the dev email prompt as ${EMAIL}, signing out first if needed. +# Run by scripts/android-dev.sh --user; works on the iOS simulator too. +appId: sh.superset.mobile +--- +- extendedWaitUntil: + visible: "Sign in with email \\(dev\\)" + timeout: 15000 + optional: true +- runFlow: + when: + notVisible: "Sign in with email \\(dev\\)" + commands: + - openLink: "superset://settings" + - scrollUntilVisible: + element: "Sign out" + - tapOn: "Sign out" + - tapOn: "Log out" + - extendedWaitUntil: + visible: "Sign in with email \\(dev\\)" + timeout: 15000 +- tapOn: "Sign in with email \\(dev\\)" +- eraseText: 80 +- inputText: ${EMAIL} +- tapOn: "Next" +- tapOn: "Sign in" +- extendedWaitUntil: + notVisible: "Sign in with email \\(dev\\)" + timeout: 20000 diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 63ed571ced4..ea5fee33989 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -63,20 +63,38 @@ export default function AuthenticatedLayout() { ## Conventions -- **`apps/mobile` is iOS-only.** No Android fallbacks or platform guards for iOS-only APIs, and - Android incompatibility isn't a blocker until Android is explicitly in scope. -- **Bottom sheets are expo-router `formSheet` routes** — `...glassHeaderOptions` gives the native - title and ✕, the body is RN + uniwind (never `@expo/ui` SwiftUI content, which can't be themed to - match ours), and the list stays the screen's only layout child or it cold-mounts at zero height. - Copy `PullRequestsSheet` and its route entry. +- **iOS ships; Android is a seam, not a target yet.** Every iOS-only API sits behind a typed + contract, picked by Metro's platform extensions, never by `Platform.OS` checks in screens: + - `X.types.ts` is the contract: props, handle and function types, with no SF Symbol names or + other iOS words in what callers pass. Screens import only the folder's `index.ts`. + - `X.ios.tsx` is the native binding: an Expo module in `modules//ios`, `@expo/ui/swift-ui`, + or an iOS-only Expo package. It is the only file that may import those. + - `X.tsx` is the fallback for every other platform and for `bun test`. It does nothing or + degrades to plain RN, and it must not crash. + - Both implementations annotate their exports with the contract type, so `tsc` checks the + `.ios` file against the same shape as the fallback that callers resolve to. + - To port to Android: add `X.android.tsx`, or for a module, add `android/` and `"android"` to + `expo-module.config.json` and rename the binding `X.native.tsx` so both platforms use it. + Examples: `modules/composer/src`, `ShareAccess/components/ValueMenu`, + `sign-in/utils/signInWithApple`. Android-only the other way round: `sign-in/utils/signInWithGoogle` + (`.android.ts` on `modules/google-credential`, `.ts` everywhere else). +- **Bottom sheets are expo-router `formSheet` routes** — `...sheetHeaderOptions` gives the native + title and ✕ on iOS and a JS drag handle and title on Android, where a form sheet draws no native + bar (so `Stack.SearchBar` and SF Symbol `Stack.Toolbar` buttons do not show there). The body is + RN + uniwind (never `@expo/ui` SwiftUI content, which can't be themed to match ours), and the + list stays the screen's only layout child or it cold-mounts at zero height. Copy + `PullRequestsSheet` and its route entry. - **Hermes ships a partial `Intl`.** `lib/intl-polyfills` lists what is missing and what is polyfilled. A `@superset/i18n/format` helper that reaches for an API outside that list throws, and no mobile screen has an error boundary, so the throw takes the screen down. Add the polyfill, its - per-locale data, and a case in the polyfill test. + per-locale data, and a case in the polyfill test. Android's Hermes also lacks `zh-CN`/`zh-TW` in + `NumberFormat`/`DateTimeFormat` and formats them as `en-US`; `scriptSubtagFallback.android.ts` + passes the script-qualified tag instead. - **Pending and failed are not answers.** A query with no data yet has not said "offline" or "empty". Keep loading while it is pending and say you could not check when it failed; Home once painted "is offline" on every cold start because presence defaulted to `false`. - **Verifying in the real app:** on a machine with Xcode, `.agents/skills/mobile-sim-verification/SKILL.md`. + On Android, `apps/mobile/scripts/android-dev.sh` and `.agents/skills/mobile-android-verification/SKILL.md`. On a cloud sandbox (no local Xcode/simulator), run `apps/mobile/scripts/eas-dev.sh`; see `.agents/skills/mobile-eas-dev/SKILL.md`. The script encodes what was found getting the app running on an EAS simulator from a sandbox (2026-10-09), so nobody has to rediscover it: diff --git a/apps/mobile/RELEASE.md b/apps/mobile/RELEASE.md index aea619242ef..a1148edc8ff 100644 --- a/apps/mobile/RELEASE.md +++ b/apps/mobile/RELEASE.md @@ -1,6 +1,8 @@ # Mobile App Release Process -iOS only. Builds and submissions go through EAS; the App Store listing and the +iOS only; Android has not shipped yet, and what it still needs is in +[`plans/20261029-android-launch-checklist.md`](./plans/20261029-android-launch-checklist.md). +Builds and submissions go through EAS; the App Store listing and the App Review notes live in [`store.config.js`](./store.config.js) and are pushed with `eas metadata:push`. This file is the runbook for shipping a build and for getting unstuck when App Review rejects or stalls it. diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 61419417f0a..58d4777f949 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { SUPPORTED_LOCALES } from "@superset/i18n/locales"; -import { IOS_APP } from "@superset/shared/app-identity"; +import { ANDROID_APP, IOS_APP } from "@superset/shared/app-identity"; import { BRAND } from "@superset/shared/brand"; import { config } from "dotenv"; import type { ConfigContext } from "expo/config"; @@ -21,8 +21,21 @@ if (process.env.MOBILE_EAS_BUILD !== "1") { const webUrl = new URL( process.env.EXPO_PUBLIC_WEB_URL || "https://app.superset.sh", ); -const associatedDomains = - webUrl.protocol === "https:" ? [`applinks:${webUrl.hostname}`] : undefined; +const appLinksHost = webUrl.protocol === "https:" ? webUrl.hostname : undefined; +const associatedDomains = appLinksHost && [`applinks:${appLinksHost}`]; +// Same paths as apple-app-site-association in apps/web. +const appLinkIntentFilters = appLinksHost && [ + { + action: "VIEW", + autoVerify: true, + data: ["/page/", "/workspaces/"].map((pathPrefix) => ({ + scheme: "https", + host: appLinksHost, + pathPrefix, + })), + category: ["BROWSABLE", "DEFAULT"], + }, +]; const SIGNED_BUILD_PROFILES = ["preview", "production"]; const signedUpdates = process.env.MOBILE_SIGNED_UPDATES === "1"; @@ -90,10 +103,11 @@ export default ({ config }: ConfigContext) => ({ android: { adaptiveIcon: { foregroundImage: "./assets/adaptive-icon.png", - backgroundColor: "#ffffff", + backgroundColor: "#151110", }, - package: "sh.superset.mobile", + package: ANDROID_APP.PACKAGE, predictiveBackGestureEnabled: false, + ...(appLinkIntentFilters && { intentFilters: appLinkIntentFilters }), }, web: { favicon: "./assets/favicon.png", @@ -138,7 +152,12 @@ export default ({ config }: ConfigContext) => ({ ], [ "expo-localization", - { supportedLocales: { ios: [...SUPPORTED_LOCALES] } }, + { + supportedLocales: { + ios: [...SUPPORTED_LOCALES], + android: [...SUPPORTED_LOCALES], + }, + }, ], "expo-apple-authentication", [ diff --git a/apps/mobile/app/(authenticated)/_layout.tsx b/apps/mobile/app/(authenticated)/_layout.tsx index 21356f4fbe7..d37adc58f3a 100644 --- a/apps/mobile/app/(authenticated)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/_layout.tsx @@ -4,7 +4,11 @@ import { View } from "react-native"; import { usePrimeRelayUrl } from "@/hooks/usePrimeRelayUrl"; import { useRealtimeNudges } from "@/hooks/useRealtimeNudges"; import { useSession } from "@/lib/auth/client"; -import { glassHeaderOptions, sheetDetents } from "@/lib/navigation"; +import { + glassHeaderOptions, + sheetDetents, + sheetHeaderOptions, +} from "@/lib/navigation"; import { VoiceLayer } from "@/screens/(authenticated)/voice/VoiceLayer"; export const unstable_settings = { anchor: "(home)" }; @@ -63,7 +67,7 @@ export default function AuthenticatedLayout() { presentation: "formSheet", sheetAllowedDetents: sheetDetents([0.6, 1.0]), sheetGrabberVisible: true, - ...glassHeaderOptions, + ...sheetHeaderOptions, title: t({ message: "Voice" }), }} /> @@ -73,7 +77,7 @@ export default function AuthenticatedLayout() { presentation: "formSheet", sheetAllowedDetents: sheetDetents([0.6]), sheetGrabberVisible: true, - ...glassHeaderOptions, + ...sheetHeaderOptions, title: t({ message: "Voice settings" }), }} /> @@ -96,7 +100,7 @@ export default function AuthenticatedLayout() { title: "", sheetAllowedDetents: sheetDetents([0.6, 1.0]), sheetGrabberVisible: true, - ...glassHeaderOptions, + ...sheetHeaderOptions, }} /> @@ -194,7 +198,7 @@ export default function AuthenticatedLayout() { presentation: "formSheet", sheetAllowedDetents: sheetDetents([0.75]), sheetGrabberVisible: true, - ...glassHeaderOptions, + ...sheetHeaderOptions, title: t({ message: "Add comment", }), @@ -206,7 +210,7 @@ export default function AuthenticatedLayout() { presentation: "formSheet", sheetAllowedDetents: sheetDetents([0.75]), sheetGrabberVisible: true, - ...glassHeaderOptions, + ...sheetHeaderOptions, title: t({ message: "Finish review", }), @@ -220,7 +224,7 @@ export default function AuthenticatedLayout() { sheetGrabberVisible: true, // The workspace name is the sheet's own centred headline, so // the bar carries no title — only the native close button. - ...glassHeaderOptions, + ...sheetHeaderOptions, title: "", }} /> @@ -230,7 +234,7 @@ export default function AuthenticatedLayout() { presentation: "formSheet", sheetAllowedDetents: sheetDetents([0.5]), sheetGrabberVisible: true, - ...glassHeaderOptions, + ...sheetHeaderOptions, title: t({ message: "Sessions" }), }} /> @@ -240,7 +244,7 @@ export default function AuthenticatedLayout() { presentation: "formSheet", sheetAllowedDetents: sheetDetents([0.5]), sheetGrabberVisible: true, - ...glassHeaderOptions, + ...sheetHeaderOptions, title: t({ message: "New session", }), @@ -252,7 +256,7 @@ export default function AuthenticatedLayout() { presentation: "formSheet", sheetAllowedDetents: sheetDetents([0.5, 1.0]), sheetGrabberVisible: true, - ...glassHeaderOptions, + ...sheetHeaderOptions, }} /> + isLiquidGlassAvailable() ? ( + + {children} + + ) : ( + + {children} + + ); diff --git a/apps/mobile/components/GlassSurface/GlassSurface.tsx b/apps/mobile/components/GlassSurface/GlassSurface.tsx new file mode 100644 index 00000000000..d29bffe3091 --- /dev/null +++ b/apps/mobile/components/GlassSurface/GlassSurface.tsx @@ -0,0 +1,26 @@ +import { StyleSheet, View } from "react-native"; +import type { GlassSurfaceComponent } from "./GlassSurface.types"; + +const SURFACE = "hsl(0 0% 12%)"; +const OUTLINE = "rgba(255,255,255,0.08)"; + +export const GlassSurface: GlassSurfaceComponent = ({ + radius, + style, + children, +}) => ( + + {children} + +); diff --git a/apps/mobile/components/GlassSurface/GlassSurface.types.ts b/apps/mobile/components/GlassSurface/GlassSurface.types.ts new file mode 100644 index 00000000000..6dfb4eff41b --- /dev/null +++ b/apps/mobile/components/GlassSurface/GlassSurface.types.ts @@ -0,0 +1,17 @@ +import type { ComponentType, ReactNode } from "react"; +import type { StyleProp, ViewStyle } from "react-native"; + +export interface GlassSurfaceProps { + radius: number; + /** The surface is a button: it reacts to touch where the OS supports it. */ + interactive?: boolean; + /** Keeps the material dark whatever is behind it. */ + dark?: boolean; + /** The look on OS versions that draw no glass but share this platform's design. */ + fallbackClassName: string; + style?: StyleProp; + children: ReactNode; +} + +/** A floating surface over content: glass where the OS draws it. */ +export type GlassSurfaceComponent = ComponentType; diff --git a/apps/mobile/components/GlassSurface/index.ts b/apps/mobile/components/GlassSurface/index.ts new file mode 100644 index 00000000000..9f74155be1b --- /dev/null +++ b/apps/mobile/components/GlassSurface/index.ts @@ -0,0 +1,2 @@ +export { GlassSurface } from "./GlassSurface"; +export type { GlassSurfaceProps } from "./GlassSurface.types"; diff --git a/apps/mobile/components/ui/input.tsx b/apps/mobile/components/ui/input.tsx index e7e5cfcbf18..386599bf12b 100644 --- a/apps/mobile/components/ui/input.tsx +++ b/apps/mobile/components/ui/input.tsx @@ -2,6 +2,9 @@ import { Platform, TextInput } from "react-native"; import { useTheme } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; +// Android fills the selection with this colour, so it must stay see-through. +const SELECTION_ANDROID = "rgba(250, 250, 250, 0.3)"; + function Input({ className, ...props @@ -30,7 +33,11 @@ function Input({ }), className, )} - selectionColor={theme.foreground} + selectionColor={Platform.select({ + android: SELECTION_ANDROID, + default: theme.foreground, + })} + cursorColor={theme.foreground} {...props} /> ); diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 47d50906e17..0ad0ea78ec8 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -15,6 +15,9 @@ }, "ios": { "image": "macos-tahoe-26.6-xcode-27.1" + }, + "android": { + "buildType": "apk" } }, "development-simulator": { @@ -33,6 +36,9 @@ }, "ios": { "image": "macos-tahoe-26.6-xcode-27.1" + }, + "android": { + "buildType": "apk" } }, "e2e-test": { @@ -42,6 +48,9 @@ "simulator": true, "image": "macos-tahoe-26.6-xcode-27.1" }, + "android": { + "buildType": "apk" + }, "env": { "MOBILE_EAS_BUILD": "1", "EXPO_PUBLIC_E2E": "1" @@ -57,6 +66,9 @@ }, "ios": { "image": "macos-tahoe-26.6-xcode-27.1" + }, + "android": { + "buildType": "app-bundle" } } }, @@ -66,6 +78,10 @@ "ascAppId": "6788926383", "appleTeamId": "NV9657CS5A", "metadataPath": "./store.config.js" + }, + "android": { + "track": "internal", + "releaseStatus": "draft" } } } diff --git a/apps/mobile/lib/env.ts b/apps/mobile/lib/env.ts index 09fb0d662d8..d86021ace34 100644 --- a/apps/mobile/lib/env.ts +++ b/apps/mobile/lib/env.ts @@ -14,6 +14,9 @@ const envSchema = z.object({ EXPO_PUBLIC_SENTRY_DSN_MOBILE: z.url().optional(), EXPO_PUBLIC_SENTRY_ENVIRONMENT: z.string().default("production"), EXPO_PUBLIC_E2E: z.string().optional(), + /** The API's Google web client (`GOOGLE_CLIENT_ID`). Android asks Credential + * Manager for an ID token addressed to it, and uses the browser without it. */ + EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: z.string().optional(), /** Set by .superset/setup.sh or setup.cloud.sh (seed-local-mobile-token.ts, * seed-cloud-mobile-token.ts): a one-time token redeemable for a real * session as the real developer/workspace creator, via the dev-only @@ -32,6 +35,8 @@ const rawEnv: Record = { EXPO_PUBLIC_SENTRY_DSN_MOBILE: process.env.EXPO_PUBLIC_SENTRY_DSN_MOBILE, EXPO_PUBLIC_SENTRY_ENVIRONMENT: process.env.EXPO_PUBLIC_SENTRY_ENVIRONMENT, EXPO_PUBLIC_E2E: process.env.EXPO_PUBLIC_E2E, + EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: + process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID, EXPO_PUBLIC_DEV_ONE_TIME_TOKEN: process.env.EXPO_PUBLIC_DEV_ONE_TIME_TOKEN, }; diff --git a/apps/mobile/lib/intl-polyfills/index.ts b/apps/mobile/lib/intl-polyfills/index.ts index cd224c393d1..1286240df22 100644 --- a/apps/mobile/lib/intl-polyfills/index.ts +++ b/apps/mobile/lib/intl-polyfills/index.ts @@ -1,2 +1,3 @@ // Side effects only — this module installs polyfills and exports nothing. import "./intl-polyfills"; +import "./scriptSubtagFallback"; diff --git a/apps/mobile/lib/intl-polyfills/scriptSubtagFallback.android.ts b/apps/mobile/lib/intl-polyfills/scriptSubtagFallback.android.ts new file mode 100644 index 00000000000..b9c581b6224 --- /dev/null +++ b/apps/mobile/lib/intl-polyfills/scriptSubtagFallback.android.ts @@ -0,0 +1,4 @@ +import { withScriptSubtagFallback } from "./withScriptSubtagFallback"; + +Intl.NumberFormat = withScriptSubtagFallback(Intl.NumberFormat); +Intl.DateTimeFormat = withScriptSubtagFallback(Intl.DateTimeFormat); diff --git a/apps/mobile/lib/intl-polyfills/scriptSubtagFallback.ts b/apps/mobile/lib/intl-polyfills/scriptSubtagFallback.ts new file mode 100644 index 00000000000..f24421b8b85 --- /dev/null +++ b/apps/mobile/lib/intl-polyfills/scriptSubtagFallback.ts @@ -0,0 +1,2 @@ +// Only Android needs this; see scriptSubtagFallback.android.ts. +export {}; diff --git a/apps/mobile/lib/intl-polyfills/withScriptSubtagFallback.test.ts b/apps/mobile/lib/intl-polyfills/withScriptSubtagFallback.test.ts new file mode 100644 index 00000000000..1232ee5df79 --- /dev/null +++ b/apps/mobile/lib/intl-polyfills/withScriptSubtagFallback.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { withScriptSubtagFallback } from "./withScriptSubtagFallback"; + +// Android Hermes's support list, as measured on API 33: script-qualified +// Chinese tags only. +const ANDROID_SUPPORTED = new Set(["en", "pt-BR", "zh-Hans-CN", "zh-Hant-TW"]); + +class AndroidLikeFormat { + readonly locale: string; + constructor(locales?: string | readonly string[]) { + const tags = typeof locales === "string" ? [locales] : (locales ?? []); + this.locale = tags.find((tag) => ANDROID_SUPPORTED.has(tag)) ?? "en-US"; + } + static supportedLocalesOf(locales: string | readonly string[]): string[] { + const tags = typeof locales === "string" ? [locales] : locales; + return tags.filter((tag) => ANDROID_SUPPORTED.has(tag)); + } +} + +describe("withScriptSubtagFallback", () => { + const Format = withScriptSubtagFallback(AndroidLikeFormat); + + test("formats region-only Chinese tags in Chinese, not en-US", () => { + expect(new Format("zh-CN").locale).toBe("zh-Hans-CN"); + expect(new Format(["zh-TW"]).locale).toBe("zh-Hant-TW"); + }); + + test("passes supported tags through unchanged", () => { + expect(new Format("pt-BR").locale).toBe("pt-BR"); + expect(new Format().locale).toBe("en-US"); + }); + + test("reports the requested tags as supported", () => { + expect(Format.supportedLocalesOf(["zh-CN", "zh-TW", "xx"])).toEqual([ + "zh-CN", + "zh-TW", + ]); + }); +}); diff --git a/apps/mobile/lib/intl-polyfills/withScriptSubtagFallback.ts b/apps/mobile/lib/intl-polyfills/withScriptSubtagFallback.ts new file mode 100644 index 00000000000..a03b21d0a54 --- /dev/null +++ b/apps/mobile/lib/intl-polyfills/withScriptSubtagFallback.ts @@ -0,0 +1,42 @@ +type Locales = string | readonly string[]; + +interface IntlFormatConstructor { + new (locales?: Locales, options?: object): object; + supportedLocalesOf(locales: Locales, options?: object): string[]; + prototype: object; +} + +/** + * Hermes on Android lists `zh-Hans-CN` but not `zh-CN`, and its lookup does + * not add the script subtag, so `zh-CN` and `zh-TW` silently format as + * `en-US`. This passes the native constructor the maximized tag (`zh-CN` -> + * `zh-Hans-CN`) for any tag it does not support as written. + */ +export function withScriptSubtagFallback( + Native: T, +): T { + const resolve = (locales: Locales | undefined) => { + if (locales === undefined) return undefined; + return (typeof locales === "string" ? [locales] : [...locales]).map( + (tag) => + Native.supportedLocalesOf([tag]).length > 0 + ? tag + : new Intl.Locale(tag).maximize().toString(), + ); + }; + + function Fallback(locales?: Locales, options?: object) { + return new Native(resolve(locales), options); + } + Fallback.prototype = Native.prototype; + Fallback.supportedLocalesOf = (locales: Locales, options?: object) => { + const requested = typeof locales === "string" ? [locales] : [...locales]; + const resolved = resolve(requested) ?? []; + return requested.filter( + (_, index) => + Native.supportedLocalesOf([resolved[index] as string], options).length > + 0, + ); + }; + return Fallback as unknown as T; +} diff --git a/apps/mobile/lib/navigation.ts b/apps/mobile/lib/navigation.ts deleted file mode 100644 index 7b24570e65a..00000000000 --- a/apps/mobile/lib/navigation.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { isLiquidGlassAvailable } from "expo-glass-effect"; -import type { NativeStackNavigationOptions } from "expo-router"; -import { Platform } from "react-native"; - -export const titledScreenOptions: NativeStackNavigationOptions = { - headerShadowVisible: false, -}; - -/** A sheet's native header: transparent, glass where the OS draws it, content scrolling underneath. */ -export const glassHeaderOptions = { - headerShown: true, - headerTransparent: true, - headerLargeTitle: false, - headerBackButtonDisplayMode: "minimal", - headerShadowVisible: false, - ...(isLiquidGlassAvailable() - ? {} - : { headerBlurEffect: "systemUltraThinMaterial" as const }), - headerStyle: { backgroundColor: "transparent" }, -} as const; - -// iPad shows a form sheet as a fixed-size centered card, so a partial detent -// only shrinks the card and clips what is inside it. -export const sheetDetents = (phoneDetents: number[]) => - Platform.OS === "ios" && Platform.isPad ? [1.0] : phoneDetents; diff --git a/apps/mobile/lib/navigation/components/SheetHeader/SheetHeader.tsx b/apps/mobile/lib/navigation/components/SheetHeader/SheetHeader.tsx new file mode 100644 index 00000000000..5fc1ec40451 --- /dev/null +++ b/apps/mobile/lib/navigation/components/SheetHeader/SheetHeader.tsx @@ -0,0 +1,38 @@ +import type { NativeStackHeaderProps } from "expo-router"; +import { View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { THEME } from "@/lib/theme"; + +const SIDE_WIDTH = 48; + +/** Drag handle, title and bar items, drawn in JS where the sheet has no native bar. */ +export function SheetHeader({ options, route }: NativeStackHeaderProps) { + const title = options.title ?? route.name; + const tintColor = THEME.dark.foreground; + const headerTitle = + typeof options.headerTitle === "function" ? ( + options.headerTitle({ children: title, tintColor }) + ) : ( + + {options.headerTitle ?? title} + + ); + + return ( + + + + + {options.headerLeft?.({ tintColor, canGoBack: false })} + + {headerTitle} + + {options.headerRight?.({ tintColor, canGoBack: false })} + + + + ); +} diff --git a/apps/mobile/lib/navigation/components/SheetHeader/index.ts b/apps/mobile/lib/navigation/components/SheetHeader/index.ts new file mode 100644 index 00000000000..8bbb9f9645e --- /dev/null +++ b/apps/mobile/lib/navigation/components/SheetHeader/index.ts @@ -0,0 +1 @@ +export { SheetHeader } from "./SheetHeader"; diff --git a/apps/mobile/lib/navigation/contentScrollsUnderHeader.ios.ts b/apps/mobile/lib/navigation/contentScrollsUnderHeader.ios.ts new file mode 100644 index 00000000000..2a4befebb60 --- /dev/null +++ b/apps/mobile/lib/navigation/contentScrollsUnderHeader.ios.ts @@ -0,0 +1,3 @@ +import type { ContentScrollsUnderHeader } from "./contentScrollsUnderHeader.types"; + +export const contentScrollsUnderHeader: ContentScrollsUnderHeader = true; diff --git a/apps/mobile/lib/navigation/contentScrollsUnderHeader.ts b/apps/mobile/lib/navigation/contentScrollsUnderHeader.ts new file mode 100644 index 00000000000..24d785d5f7f --- /dev/null +++ b/apps/mobile/lib/navigation/contentScrollsUnderHeader.ts @@ -0,0 +1,5 @@ +import type { ContentScrollsUnderHeader } from "./contentScrollsUnderHeader.types"; + +// contentInsetAdjustmentBehavior is iOS-only: under a transparent header the +// first rows would sit behind the bar and lose their taps. +export const contentScrollsUnderHeader: ContentScrollsUnderHeader = false; diff --git a/apps/mobile/lib/navigation/contentScrollsUnderHeader.types.ts b/apps/mobile/lib/navigation/contentScrollsUnderHeader.types.ts new file mode 100644 index 00000000000..7c52053ed3c --- /dev/null +++ b/apps/mobile/lib/navigation/contentScrollsUnderHeader.types.ts @@ -0,0 +1,2 @@ +/** Whether scroll views inset themselves under a transparent native header. */ +export type ContentScrollsUnderHeader = boolean; diff --git a/apps/mobile/lib/navigation/glassHeaderOptions.ios.ts b/apps/mobile/lib/navigation/glassHeaderOptions.ios.ts new file mode 100644 index 00000000000..f19b71dcca8 --- /dev/null +++ b/apps/mobile/lib/navigation/glassHeaderOptions.ios.ts @@ -0,0 +1,15 @@ +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import type { GlassHeaderOptions } from "./glassHeaderOptions.types"; + +/** Transparent, glass where the OS draws it, content scrolling underneath. */ +export const glassHeaderOptions: GlassHeaderOptions = { + headerShown: true, + headerTransparent: true, + headerLargeTitle: false, + headerBackButtonDisplayMode: "minimal", + headerShadowVisible: false, + ...(isLiquidGlassAvailable() + ? {} + : { headerBlurEffect: "systemUltraThinMaterial" as const }), + headerStyle: { backgroundColor: "transparent" }, +}; diff --git a/apps/mobile/lib/navigation/glassHeaderOptions.ts b/apps/mobile/lib/navigation/glassHeaderOptions.ts new file mode 100644 index 00000000000..22f83b719ef --- /dev/null +++ b/apps/mobile/lib/navigation/glassHeaderOptions.ts @@ -0,0 +1,12 @@ +import { THEME } from "@/lib/theme"; +import type { GlassHeaderOptions } from "./glassHeaderOptions.types"; + +/** Opaque: nothing blurs content under the bar, so content starts below it. */ +export const glassHeaderOptions: GlassHeaderOptions = { + headerShown: true, + headerTransparent: false, + headerLargeTitle: false, + headerBackButtonDisplayMode: "minimal", + headerShadowVisible: false, + headerStyle: { backgroundColor: THEME.dark.background }, +}; diff --git a/apps/mobile/lib/navigation/glassHeaderOptions.types.ts b/apps/mobile/lib/navigation/glassHeaderOptions.types.ts new file mode 100644 index 00000000000..23abb0ec29e --- /dev/null +++ b/apps/mobile/lib/navigation/glassHeaderOptions.types.ts @@ -0,0 +1,4 @@ +import type { NativeStackNavigationOptions } from "expo-router"; + +/** A sheet's or overlay screen's native header. */ +export type GlassHeaderOptions = NativeStackNavigationOptions; diff --git a/apps/mobile/lib/navigation/index.ts b/apps/mobile/lib/navigation/index.ts new file mode 100644 index 00000000000..ced7f30a0c2 --- /dev/null +++ b/apps/mobile/lib/navigation/index.ts @@ -0,0 +1,7 @@ +export { contentScrollsUnderHeader } from "./contentScrollsUnderHeader"; +export type { ContentScrollsUnderHeader } from "./contentScrollsUnderHeader.types"; +export { glassHeaderOptions } from "./glassHeaderOptions"; +export type { GlassHeaderOptions } from "./glassHeaderOptions.types"; +export { sheetDetents, titledScreenOptions } from "./navigation"; +export { sheetHeaderOptions } from "./sheetHeaderOptions"; +export type { SheetHeaderOptions } from "./sheetHeaderOptions.types"; diff --git a/apps/mobile/lib/navigation/navigation.ts b/apps/mobile/lib/navigation/navigation.ts new file mode 100644 index 00000000000..8757e2c2141 --- /dev/null +++ b/apps/mobile/lib/navigation/navigation.ts @@ -0,0 +1,11 @@ +import type { NativeStackNavigationOptions } from "expo-router"; +import { Platform } from "react-native"; + +export const titledScreenOptions: NativeStackNavigationOptions = { + headerShadowVisible: false, +}; + +// iPad shows a form sheet as a fixed-size centered card, so a partial detent +// only shrinks the card and clips what is inside it. +export const sheetDetents = (phoneDetents: number[]) => + Platform.OS === "ios" && Platform.isPad ? [1.0] : phoneDetents; diff --git a/apps/mobile/lib/navigation/sheetHeaderOptions.ios.ts b/apps/mobile/lib/navigation/sheetHeaderOptions.ios.ts new file mode 100644 index 00000000000..3485d937949 --- /dev/null +++ b/apps/mobile/lib/navigation/sheetHeaderOptions.ios.ts @@ -0,0 +1,4 @@ +import { glassHeaderOptions } from "./glassHeaderOptions"; +import type { SheetHeaderOptions } from "./sheetHeaderOptions.types"; + +export const sheetHeaderOptions: SheetHeaderOptions = glassHeaderOptions; diff --git a/apps/mobile/lib/navigation/sheetHeaderOptions.tsx b/apps/mobile/lib/navigation/sheetHeaderOptions.tsx new file mode 100644 index 00000000000..65fded56a0e --- /dev/null +++ b/apps/mobile/lib/navigation/sheetHeaderOptions.tsx @@ -0,0 +1,9 @@ +import { SheetHeader } from "./components/SheetHeader"; +import type { SheetHeaderOptions } from "./sheetHeaderOptions.types"; + +/** A form sheet draws no native bar here, so the header is drawn in JS. */ +export const sheetHeaderOptions: SheetHeaderOptions = { + headerShown: true, + sheetCornerRadius: 28, + header: (props) => , +}; diff --git a/apps/mobile/lib/navigation/sheetHeaderOptions.types.ts b/apps/mobile/lib/navigation/sheetHeaderOptions.types.ts new file mode 100644 index 00000000000..a256bff2236 --- /dev/null +++ b/apps/mobile/lib/navigation/sheetHeaderOptions.types.ts @@ -0,0 +1,4 @@ +import type { NativeStackNavigationOptions } from "expo-router"; + +/** A `formSheet` route's header: its title, items and dismiss affordance. */ +export type SheetHeaderOptions = NativeStackNavigationOptions; diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 28f63a5cc40..5a1451251bc 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -36,6 +36,10 @@ config.resolver.assetExts = [...config.resolver.assetExts, "riv"]; config.resolver.extraNodeModules = { "@superset/alert-prompt": path.resolve(projectRoot, "modules/alert-prompt"), "@superset/composer": path.resolve(projectRoot, "modules/composer"), + "@superset/google-credential": path.resolve( + projectRoot, + "modules/google-credential", + ), "@superset/symbol-button": path.resolve(projectRoot, "modules/symbol-button"), "@superset/attachments-sheet": path.resolve( projectRoot, diff --git a/apps/mobile/modules/alert-prompt/src/AlertPrompt.ios.ts b/apps/mobile/modules/alert-prompt/src/AlertPrompt.ios.ts new file mode 100644 index 00000000000..7cffa23e271 --- /dev/null +++ b/apps/mobile/modules/alert-prompt/src/AlertPrompt.ios.ts @@ -0,0 +1,8 @@ +import { requireNativeModule } from "expo"; +import type { AlertPromptHostComponent, Prompt } from "./AlertPrompt.types"; + +const AlertPromptModule = requireNativeModule("AlertPrompt"); + +export const prompt: Prompt = (options) => AlertPromptModule.prompt(options); + +export const AlertPromptHost: AlertPromptHostComponent = () => null; diff --git a/apps/mobile/modules/alert-prompt/src/AlertPrompt.tsx b/apps/mobile/modules/alert-prompt/src/AlertPrompt.tsx new file mode 100644 index 00000000000..4ee30bf87f8 --- /dev/null +++ b/apps/mobile/modules/alert-prompt/src/AlertPrompt.tsx @@ -0,0 +1,121 @@ +import { useLingui } from "@lingui/react/macro"; +import { useEffect, useState, useSyncExternalStore } from "react"; +import { KeyboardAvoidingView, Modal, Pressable, View } from "react-native"; +import { Input } from "@/components/ui/input"; +import { Text } from "@/components/ui/text"; +import type { + AlertPromptHostComponent, + Prompt, + PromptOptions, +} from "./AlertPrompt.types"; + +interface PendingPrompt { + options: PromptOptions; + resolve: (value: string | null) => void; +} + +const queue: PendingPrompt[] = []; +const listeners = new Set<() => void>(); + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function emit() { + for (const listener of listeners) listener(); +} + +const current = () => queue[0]; + +function settle(value: string | null) { + const pending = queue.shift(); + pending?.resolve(value); + emit(); +} + +export const prompt: Prompt = (options) => + new Promise((resolve) => { + queue.push({ options, resolve }); + emit(); + }); + +export const AlertPromptHost: AlertPromptHostComponent = () => { + const pending = useSyncExternalStore(subscribe, current, current); + const { t } = useLingui(); + const [value, setValue] = useState(""); + + useEffect(() => { + setValue(pending?.options.defaultValue ?? ""); + }, [pending]); + + if (!pending) return null; + const { options } = pending; + const cancel = () => settle(null); + const confirm = () => settle(value); + + return ( + + + + + + + {options.title} + + {options.message ? ( + + {options.message} + + ) : null} + + + + + + {options.cancelText ?? t({ message: "Cancel" })} + + + + + {options.confirmText ?? t({ message: "OK" })} + + + + + + + ); +}; diff --git a/apps/mobile/modules/alert-prompt/src/AlertPrompt.types.ts b/apps/mobile/modules/alert-prompt/src/AlertPrompt.types.ts new file mode 100644 index 00000000000..487b1730fa5 --- /dev/null +++ b/apps/mobile/modules/alert-prompt/src/AlertPrompt.types.ts @@ -0,0 +1,17 @@ +import type { ComponentType } from "react"; + +export type PromptOptions = { + title: string; + message?: string; + defaultValue?: string; + placeholder?: string; + confirmText?: string; + cancelText?: string; + selectText?: boolean; +}; + +/** Resolves the entered text, or null when cancelled. */ +export type Prompt = (options: PromptOptions) => Promise; + +/** Mounted once at the app root; draws prompts where the OS has no native one. */ +export type AlertPromptHostComponent = ComponentType; diff --git a/apps/mobile/modules/alert-prompt/src/index.ts b/apps/mobile/modules/alert-prompt/src/index.ts index b651fd1509a..d9b28867023 100644 --- a/apps/mobile/modules/alert-prompt/src/index.ts +++ b/apps/mobile/modules/alert-prompt/src/index.ts @@ -1,17 +1,6 @@ -import { requireNativeModule } from "expo"; - -export type PromptOptions = { - title: string; - message?: string; - defaultValue?: string; - placeholder?: string; - confirmText?: string; - cancelText?: string; - selectText?: boolean; -}; - -const AlertPromptModule = requireNativeModule("AlertPrompt"); - -export function prompt(options: PromptOptions): Promise { - return AlertPromptModule.prompt(options); -} +export { AlertPromptHost, prompt } from "./AlertPrompt"; +export type { + AlertPromptHostComponent, + Prompt, + PromptOptions, +} from "./AlertPrompt.types"; diff --git a/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ios.ts b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ios.ts new file mode 100644 index 00000000000..2b09692b535 --- /dev/null +++ b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ios.ts @@ -0,0 +1,68 @@ +import { requireOptionalNativeModule } from "expo"; +import { processColor } from "react-native"; +import type { + AttachmentsSheetAction, + AttachmentsSheetAsset, + PresentAttachmentsSheet, +} from "./AttachmentsSheet.types"; + +type AttachmentsSheetEvents = { + onAddAssets: (event: { assets: AttachmentsSheetAsset[] }) => void; + onAction: (event: { action: AttachmentsSheetAction }) => void; + onDismiss: () => void; +}; + +// expo-modules-core 56.0.23 exports a broken `NativeModule` type alias (the +// constructor type, generic dropped), so the emitter surface is typed by hand. +type AttachmentsSheetNativeModule = { + present(options: Record): Promise; + addListener( + eventName: Name, + listener: AttachmentsSheetEvents[Name], + ): { remove(): void }; +}; + +const AttachmentsSheet = + requireOptionalNativeModule("AttachmentsSheet"); + +export const presentAttachmentsSheet: PresentAttachmentsSheet = async ( + theme, + handlers, +) => { + if (!AttachmentsSheet) { + console.warn( + "AttachmentsSheet native module unavailable — rebuild the dev client", + ); + return false; + } + const subscriptions = [ + AttachmentsSheet.addListener("onAddAssets", ({ assets }) => { + settle(); + handlers.onAddAssets(assets); + }), + AttachmentsSheet.addListener("onAction", ({ action }) => { + settle(); + handlers.onAction(action); + }), + AttachmentsSheet.addListener("onDismiss", () => { + settle(); + handlers.onDismiss?.(); + }), + ]; + const settle = () => { + for (const subscription of subscriptions) subscription.remove(); + }; + + const { colorScheme, ...colors } = theme; + const presented = await AttachmentsSheet.present({ + colorScheme, + ...Object.fromEntries( + Object.entries(colors).map(([token, value]) => [ + token, + (processColor(value) as number | null) ?? 0, + ]), + ), + }); + if (!presented) settle(); + return presented; +}; diff --git a/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ts b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ts new file mode 100644 index 00000000000..8df38475101 --- /dev/null +++ b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.ts @@ -0,0 +1,4 @@ +import type { PresentAttachmentsSheet } from "./AttachmentsSheet.types"; + +export const presentAttachmentsSheet: PresentAttachmentsSheet = async () => + false; diff --git a/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.types.ts b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.types.ts new file mode 100644 index 00000000000..31b9b122085 --- /dev/null +++ b/apps/mobile/modules/attachments-sheet/src/AttachmentsSheet.types.ts @@ -0,0 +1,37 @@ +export type AttachmentsSheetAction = "photos" | "camera" | "files"; + +export interface AttachmentsSheetAsset { + uri: string; + name: string; + mediaType: string; + size: number; +} + +export interface AttachmentsSheetTheme { + colorScheme: "light" | "dark"; + background: string; + foreground: string; + mutedForeground: string; + border: string; + secondary: string; + secondaryForeground: string; + primary: string; + primaryForeground: string; +} + +export interface AttachmentsSheetHandlers { + onAddAssets: (assets: AttachmentsSheetAsset[]) => void; + onAction: (action: AttachmentsSheetAction) => void; + /** Dismissed with neither assets nor a row action. */ + onDismiss?: () => void; +} + +/** + * Presents the attachments sheet. Every presentation ends in exactly one + * handler call — assets added, a row action, or a plain dismissal. Resolves + * false when nothing was presented. + */ +export type PresentAttachmentsSheet = ( + theme: AttachmentsSheetTheme, + handlers: AttachmentsSheetHandlers, +) => Promise; diff --git a/apps/mobile/modules/attachments-sheet/src/index.ts b/apps/mobile/modules/attachments-sheet/src/index.ts index 4c0e1031860..b87fef28b79 100644 --- a/apps/mobile/modules/attachments-sheet/src/index.ts +++ b/apps/mobile/modules/attachments-sheet/src/index.ts @@ -1,96 +1,8 @@ -import { requireOptionalNativeModule } from "expo"; -import { processColor } from "react-native"; - -export type AttachmentsSheetAction = "photos" | "camera" | "files"; - -export interface AttachmentsSheetAsset { - uri: string; - name: string; - mediaType: string; - size: number; -} - -export interface AttachmentsSheetTheme { - colorScheme: "light" | "dark"; - background: string; - foreground: string; - mutedForeground: string; - border: string; - secondary: string; - secondaryForeground: string; - primary: string; - primaryForeground: string; -} - -export interface AttachmentsSheetHandlers { - onAddAssets: (assets: AttachmentsSheetAsset[]) => void; - onAction: (action: AttachmentsSheetAction) => void; - /** Dismissed with neither assets nor a row action. */ - onDismiss?: () => void; -} - -type AttachmentsSheetEvents = { - onAddAssets: (event: { assets: AttachmentsSheetAsset[] }) => void; - onAction: (event: { action: AttachmentsSheetAction }) => void; - onDismiss: () => void; -}; - -// expo-modules-core 56.0.23 exports a broken `NativeModule` type alias (the -// constructor type, generic dropped), so the emitter surface is typed by hand. -type AttachmentsSheetNativeModule = { - present(options: Record): Promise; - addListener( - eventName: Name, - listener: AttachmentsSheetEvents[Name], - ): { remove(): void }; -}; - -const AttachmentsSheet = - requireOptionalNativeModule("AttachmentsSheet"); - -/** - * Presents the native attachments sheet. Every presentation ends in exactly - * one native event — assets added, a row action, or a plain dismissal — after - * which the listeners tear themselves down. - */ -export async function presentAttachmentsSheet( - theme: AttachmentsSheetTheme, - handlers: AttachmentsSheetHandlers, -): Promise { - if (!AttachmentsSheet) { - console.warn( - "AttachmentsSheet native module unavailable — rebuild the dev client", - ); - return false; - } - const subscriptions = [ - AttachmentsSheet.addListener("onAddAssets", ({ assets }) => { - settle(); - handlers.onAddAssets(assets); - }), - AttachmentsSheet.addListener("onAction", ({ action }) => { - settle(); - handlers.onAction(action); - }), - AttachmentsSheet.addListener("onDismiss", () => { - settle(); - handlers.onDismiss?.(); - }), - ]; - const settle = () => { - for (const subscription of subscriptions) subscription.remove(); - }; - - const { colorScheme, ...colors } = theme; - const presented = await AttachmentsSheet.present({ - colorScheme, - ...Object.fromEntries( - Object.entries(colors).map(([token, value]) => [ - token, - (processColor(value) as number | null) ?? 0, - ]), - ), - }); - if (!presented) settle(); - return presented; -} +export { presentAttachmentsSheet } from "./AttachmentsSheet"; +export type { + AttachmentsSheetAction, + AttachmentsSheetAsset, + AttachmentsSheetHandlers, + AttachmentsSheetTheme, + PresentAttachmentsSheet, +} from "./AttachmentsSheet.types"; diff --git a/apps/mobile/modules/composer/src/Composer.android.tsx b/apps/mobile/modules/composer/src/Composer.android.tsx new file mode 100644 index 00000000000..afdd35a6a9b --- /dev/null +++ b/apps/mobile/modules/composer/src/Composer.android.tsx @@ -0,0 +1,487 @@ +import { Image } from "expo-image"; +import { ImageManipulator, SaveFormat } from "expo-image-manipulator"; +import { ArrowUp, ChevronDown, Plus, Square } from "lucide-react-native"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { + ActivityIndicator, + Keyboard, + PixelRatio, + Pressable, + StyleSheet, + Text, + TextInput, + type TextInputInstance, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { + ComposerAttachment, + ComposerComponent, + ComposerHandle, + ComposerMenuOption, + ComposerProps, + FileThumbnail, + PresentImage, +} from "./Composer.types"; +import { ComposerAttachmentTray } from "./components/ComposerAttachmentTray"; +import { + ComposerImageViewer, + showImage, +} from "./components/ComposerImageViewer"; +import { ComposerModeMenu } from "./components/ComposerModeMenu"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "./constants"; +import { joinDraft } from "./utils/joinDraft"; + +const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|heic|heif|bmp|avif)(\?|$)/i; + +export const presentImage: PresentImage = async (uri) => { + showImage(uri); +}; + +export const fileThumbnail: FileThumbnail = async (uri, side) => { + if (!IMAGE_EXTENSION.test(uri)) return null; + try { + const image = await ImageManipulator.manipulate(uri) + .resize({ width: PixelRatio.getPixelSizeForLayoutSize(side) }) + .renderAsync(); + const result = await image.saveAsync({ format: SaveFormat.PNG }); + return result.uri; + } catch { + return null; + } +}; + +export const Composer: ComposerComponent = forwardRef< + ComposerHandle, + ComposerProps +>(function Composer( + { + placeholder = "", + initialDraft = "", + backdrop = "dim", + attachments = [], + selectedModel, + launchOptions, + modeOptions, + selectedModeId, + canStop = false, + headerChips, + showAttachments = true, + autocapitalization = "sentences", + compactEditor = false, + isSending = false, + onSubmit, + onAttachmentsPress, + onModelPress, + onLaunchOptionPress, + onModeSelect, + onStop, + onChipPress, + onHeightChange, + onDraftChange, + onRemoveAttachment, + onAttachmentPress, + onExpandedChange, + }, + ref, +) { + const inputRef = useRef(null); + const [draft, setDraftState] = useState(initialDraft); + const [expanded, setExpanded] = useState(false); + // Android keeps focus when Back lowers the keyboard; iOS collapses there. + const keyboardHeight = useKeyboardHeight(() => inputRef.current?.blur()); + const insets = useSafeAreaInsets(); + + const setDraft = (text: string) => { + setDraftState(text); + onDraftChange?.(text); + }; + + const draftRef = useRef(draft); + draftRef.current = draft; + + useImperativeHandle(ref, () => ({ + clear: () => setDraft(""), + appendDraft: (text) => setDraft(joinDraft(draftRef.current, text)), + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), + })); + + const onExpandedChangeRef = useRef(onExpandedChange); + onExpandedChangeRef.current = onExpandedChange; + const reportedExpanded = useRef(false); + useEffect(() => { + if (reportedExpanded.current === expanded) return; + reportedExpanded.current = expanded; + onExpandedChangeRef.current?.(expanded); + }, [expanded]); + + const hasDraft = draft.trim().length > 0; + const hasContent = hasDraft || attachments.length > 0; + const isUploading = attachments.some( + (item) => item.progress !== undefined && !item.failed, + ); + const submit = () => { + if (!hasContent || isSending || isUploading) return; + onSubmit?.(draft); + }; + + const openAttachment = (attachment: ComposerAttachment) => { + if (attachment.kind === "image") showImage(attachment.uri); + else onAttachmentPress?.(attachment.id); + }; + + const editorLines = compactEditor + ? COMPOSER_METRICS.compactEditorLines + : COMPOSER_METRICS.editorLines; + // React Native reports the keyboard without the navigation bar under it. + const lift = keyboardHeight + insets.bottom; + + return ( + + {expanded && backdrop === "dim" ? ( + inputRef.current?.blur()} + style={[StyleSheet.absoluteFill, styles.scrim]} + /> + ) : null} + + + onHeightChange?.(event.nativeEvent.layout.height) + } + style={styles.stack} + > + {expanded && headerChips && headerChips.length > 0 ? ( + + {headerChips.map((chip) => ( + onChipPress?.(chip.id)} + /> + ))} + + ) : null} + + {expanded && attachments.length > 0 ? ( + onRemoveAttachment?.(id)} + /> + ) : null} + + setExpanded(true)} + onBlur={() => setExpanded(false)} + placeholder={placeholder} + placeholderTextColor={COMPOSER_COLORS.muted} + autoCapitalize={ + autocapitalization === "never" ? "none" : "sentences" + } + autoCorrect={autocapitalization !== "never"} + multiline + textAlignVertical="top" + style={[ + styles.input, + { + minHeight: COMPOSER_METRICS.lineHeight * editorLines, + maxHeight: + COMPOSER_METRICS.lineHeight * + COMPOSER_METRICS.maxEditorLines, + }, + ]} + /> + + + {showAttachments ? ( + + + + ) : null} + {modeOptions && modeOptions.length > 0 ? ( + + ) : null} + {expanded ? ( + + {selectedModel ? ( + + ) : null} + {launchOptions?.map((option) => ( + onLaunchOptionPress?.(option.id)} + /> + ))} + + ) : ( + inputRef.current?.focus()} + style={styles.collapsedText} + > + + + {hasDraft ? draft.trim() : placeholder} + + + )} + {isSending ? ( + + + + ) : hasContent ? ( + + + + ) : canStop ? ( + + + + ) : null} + + + + + + + ); +}); + +function useKeyboardHeight(onHide: () => void) { + const onHideRef = useRef(onHide); + onHideRef.current = onHide; + const [height, setHeight] = useState(0); + useEffect(() => { + const show = Keyboard.addListener("keyboardDidShow", (event) => + setHeight(event.endCoordinates.height), + ); + const hide = Keyboard.addListener("keyboardDidHide", () => { + setHeight(0); + onHideRef.current(); + }); + return () => { + show.remove(); + hide.remove(); + }; + }, []); + return height; +} + +function RoundButton({ + children, + filled = false, + disabled = false, + onPress, +}: { + children: React.ReactNode; + filled?: boolean; + disabled?: boolean; + onPress?: () => void; +}) { + return ( + [ + styles.round, + filled && styles.roundFilled, + (pressed || disabled) && styles.pressed, + ]} + > + {children} + + ); +} + +function MenuButton({ + option, + onPress, +}: { + option: ComposerMenuOption; + onPress?: () => void; +}) { + return ( + [styles.menuButton, pressed && styles.pressed]} + > + {option.iconUri ? ( + + ) : option.avatar ? ( + + + {option.label.charAt(0).toUpperCase()} + + + ) : null} + + {option.label} + + + + ); +} + +function CollapsedThumbs({ + attachments, +}: { + attachments: ComposerAttachment[]; +}) { + const first = attachments[0]; + if (!first) return null; + return ( + + {first.kind === "image" ? ( + + ) : ( + + )} + {attachments.length > 1 ? ( + +{attachments.length - 1} + ) : null} + + ); +} + +const styles = StyleSheet.create({ + scrim: { backgroundColor: COMPOSER_COLORS.scrim }, + anchor: { flex: 1, justifyContent: "flex-end" }, + stack: { + paddingHorizontal: COMPOSER_METRICS.edgeInset, + paddingTop: COMPOSER_METRICS.gap, + paddingBottom: COMPOSER_METRICS.gap, + gap: COMPOSER_METRICS.gap, + }, + chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, + card: { + backgroundColor: COMPOSER_COLORS.card, + borderColor: COMPOSER_COLORS.border, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 26, + overflow: "hidden", + }, + editor: { paddingHorizontal: COMPOSER_METRICS.edgeInset + 4, paddingTop: 12 }, + collapsedField: { + position: "absolute", + top: 0, + left: 0, + right: 0, + opacity: 0, + }, + input: { + color: COMPOSER_COLORS.foreground, + fontSize: 16, + lineHeight: COMPOSER_METRICS.lineHeight, + padding: 0, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: COMPOSER_METRICS.gap, + padding: 6, + }, + pickers: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: 4, + overflow: "hidden", + }, + collapsedText: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: COMPOSER_METRICS.gap, + minHeight: COMPOSER_METRICS.controlSize, + paddingLeft: 4, + }, + collapsedLabel: { flex: 1, color: COMPOSER_COLORS.foreground, fontSize: 16 }, + round: { + width: COMPOSER_METRICS.controlSize, + height: COMPOSER_METRICS.controlSize, + borderRadius: COMPOSER_METRICS.controlSize / 2, + backgroundColor: COMPOSER_COLORS.control, + alignItems: "center", + justifyContent: "center", + }, + roundFilled: { backgroundColor: COMPOSER_COLORS.foreground }, + pressed: { opacity: 0.5 }, + menuButton: { + flexDirection: "row", + alignItems: "center", + gap: 6, + maxWidth: 200, + paddingHorizontal: 10, + height: 32, + borderRadius: 16, + backgroundColor: COMPOSER_COLORS.control, + }, + menuIcon: { width: 16, height: 16, borderRadius: 4 }, + avatar: { + backgroundColor: COMPOSER_COLORS.border, + alignItems: "center", + justifyContent: "center", + }, + avatarLetter: { + color: COMPOSER_COLORS.foreground, + fontSize: 10, + fontWeight: "600", + }, + menuLabel: { flexShrink: 1, color: COMPOSER_COLORS.foreground, fontSize: 14 }, + menuLabelMuted: { color: COMPOSER_COLORS.muted }, + miniThumbs: { flexDirection: "row", alignItems: "center", gap: 4 }, + miniThumb: { + width: COMPOSER_METRICS.miniThumbSize, + height: COMPOSER_METRICS.miniThumbSize, + borderRadius: 6, + }, + miniFile: { backgroundColor: COMPOSER_COLORS.control }, + more: { color: COMPOSER_COLORS.muted, fontSize: 13 }, +}); diff --git a/apps/mobile/modules/composer/src/Composer.ios.tsx b/apps/mobile/modules/composer/src/Composer.ios.tsx new file mode 100644 index 00000000000..945bba021a7 --- /dev/null +++ b/apps/mobile/modules/composer/src/Composer.ios.tsx @@ -0,0 +1,202 @@ +import { requireNativeModule, requireNativeView } from "expo"; +import { forwardRef, type Ref, useImperativeHandle, useRef } from "react"; +import type { + ComposerAttachment, + ComposerBackdrop, + ComposerComponent, + ComposerHandle, + ComposerMenuOption, + ComposerModeOption, + ComposerPastedItem, + ComposerProps, + ComposerQuickKey, + ComposerQuickKeysAction, + ComposerSessionTab, + ComposerSessionTabLabels, + ComposerSlashCommand, + FileThumbnail, + PresentImage, +} from "./Composer.types"; + +/** The imperative surface the native view exposes through its ref. */ +interface NativeComposerRef { + clear: () => void; + appendDraft: (text: string) => void; + focus: () => void; + blur: () => void; +} + +interface NativeComposerViewProps { + ref?: Ref; + placeholder?: string; + initialDraft?: string; + backdrop?: ComposerBackdrop; + attachments?: ComposerAttachment[]; + selectedModel?: ComposerMenuOption; + launchOptions?: ComposerMenuOption[]; + modeOptions?: ComposerModeOption[]; + selectedModeId?: string | null; + canStop?: boolean; + headerChips?: ComposerMenuOption[]; + quickKeys?: ComposerQuickKey[]; + sessionTabs?: ComposerSessionTab[]; + sessionTabLabels?: ComposerSessionTabLabels; + /** Null, never undefined — see the pass-through below. */ + quickKeysAction?: ComposerQuickKeysAction | null; + slashCommands?: ComposerSlashCommand[]; + showAttachments?: boolean; + autocapitalization?: "sentences" | "never"; + compactEditor?: boolean; + isSending?: boolean; + onSubmit?: (event: { nativeEvent: { text: string } }) => void; + onAttachmentsPress?: () => void; + onDictationError?: (event: { nativeEvent: { message: string } }) => void; + onModelPress?: () => void; + onLaunchOptionPress?: (event: { nativeEvent: { id: string } }) => void; + onModeSelect?: (event: { nativeEvent: { id: string } }) => void; + onStop?: () => void; + onChipPress?: (event: { nativeEvent: { id: string } }) => void; + onQuickKeyPress?: (event: { nativeEvent: { id: string } }) => void; + onSessionTabPress?: (event: { nativeEvent: { id: string } }) => void; + onSessionTabClose?: (event: { nativeEvent: { id: string } }) => void; + onSessionTabRename?: (event: { nativeEvent: { id: string } }) => void; + onSessionTabCopyId?: (event: { nativeEvent: { id: string } }) => void; + onNewSessionPress?: () => void; + onAllSessionsPress?: () => void; + onQuickKeysActionPress?: () => void; + onHeightChange?: (event: { nativeEvent: { height: number } }) => void; + onPaste?: (event: { nativeEvent: { items: ComposerPastedItem[] } }) => void; + onDraftChange?: (event: { nativeEvent: { text: string } }) => void; + onRemoveAttachment?: (event: { nativeEvent: { id: string } }) => void; + onAttachmentPress?: (event: { nativeEvent: { id: string } }) => void; + onExpandedChange?: (event: { nativeEvent: { expanded: boolean } }) => void; +} + +const NativeComposerView = + requireNativeView("Composer"); + +const NativeComposerModule = requireNativeModule<{ + presentImage: (uri: string) => Promise; + fileThumbnail: (uri: string, side: number) => Promise; +}>("Composer"); + +export const presentImage: PresentImage = (uri) => + NativeComposerModule.presentImage(uri); + +export const fileThumbnail: FileThumbnail = (uri, side) => + NativeComposerModule.fileThumbnail(uri, side); + +export const Composer: ComposerComponent = forwardRef< + ComposerHandle, + ComposerProps +>(function Composer( + { + placeholder = "", + initialDraft = "", + backdrop = "dim", + attachments, + selectedModel, + launchOptions, + modeOptions, + selectedModeId, + canStop = false, + headerChips, + quickKeys, + sessionTabs, + sessionTabLabels, + quickKeysAction, + slashCommands, + showAttachments = true, + autocapitalization = "sentences", + compactEditor = false, + isSending = false, + onSubmit, + onAttachmentsPress, + onDictationError, + onModelPress, + onLaunchOptionPress, + onModeSelect, + onStop, + onChipPress, + onQuickKeyPress, + onSessionTabPress, + onSessionTabClose, + onSessionTabRename, + onSessionTabCopyId, + onNewSessionPress, + onAllSessionsPress, + onQuickKeysActionPress, + onHeightChange, + onPaste, + onDraftChange, + onRemoveAttachment, + onAttachmentPress, + onExpandedChange, + }, + ref, +) { + const nativeRef = useRef(null); + + useImperativeHandle(ref, () => ({ + clear: () => nativeRef.current?.clear(), + appendDraft: (text: string) => nativeRef.current?.appendDraft(text), + focus: () => nativeRef.current?.focus(), + blur: () => nativeRef.current?.blur(), + })); + + return ( + onSubmit?.(event.nativeEvent.text)} + onAttachmentsPress={onAttachmentsPress} + onDictationError={(event) => + onDictationError?.(event.nativeEvent.message) + } + onModelPress={onModelPress} + onLaunchOptionPress={(event) => + onLaunchOptionPress?.(event.nativeEvent.id) + } + onModeSelect={(event) => onModeSelect?.(event.nativeEvent.id)} + onStop={() => onStop?.()} + onChipPress={(event) => onChipPress?.(event.nativeEvent.id)} + onQuickKeyPress={(event) => onQuickKeyPress?.(event.nativeEvent.id)} + onSessionTabPress={(event) => onSessionTabPress?.(event.nativeEvent.id)} + onSessionTabClose={(event) => onSessionTabClose?.(event.nativeEvent.id)} + onSessionTabRename={(event) => onSessionTabRename?.(event.nativeEvent.id)} + onSessionTabCopyId={(event) => onSessionTabCopyId?.(event.nativeEvent.id)} + onNewSessionPress={onNewSessionPress} + onAllSessionsPress={onAllSessionsPress} + onQuickKeysActionPress={onQuickKeysActionPress} + onHeightChange={(event) => onHeightChange?.(event.nativeEvent.height)} + onPaste={(event) => onPaste?.(event.nativeEvent.items)} + onDraftChange={(event) => onDraftChange?.(event.nativeEvent.text)} + onRemoveAttachment={(event) => onRemoveAttachment?.(event.nativeEvent.id)} + onAttachmentPress={(event) => onAttachmentPress?.(event.nativeEvent.id)} + onExpandedChange={(event) => + onExpandedChange?.(event.nativeEvent.expanded) + } + /> + ); +}); diff --git a/apps/mobile/modules/composer/src/Composer.tsx b/apps/mobile/modules/composer/src/Composer.tsx new file mode 100644 index 00000000000..fbc1fa934f6 --- /dev/null +++ b/apps/mobile/modules/composer/src/Composer.tsx @@ -0,0 +1,25 @@ +import { forwardRef, useImperativeHandle } from "react"; +import type { + ComposerComponent, + ComposerHandle, + ComposerProps, + FileThumbnail, + PresentImage, +} from "./Composer.types"; + +export const presentImage: PresentImage = async () => {}; + +export const fileThumbnail: FileThumbnail = async () => null; + +export const Composer: ComposerComponent = forwardRef< + ComposerHandle, + ComposerProps +>(function Composer(_props, ref) { + useImperativeHandle(ref, () => ({ + clear: () => {}, + appendDraft: () => {}, + focus: () => {}, + blur: () => {}, + })); + return null; +}); diff --git a/apps/mobile/modules/composer/src/Composer.types.ts b/apps/mobile/modules/composer/src/Composer.types.ts new file mode 100644 index 00000000000..d8b290e9991 --- /dev/null +++ b/apps/mobile/modules/composer/src/Composer.types.ts @@ -0,0 +1,403 @@ +import type { ForwardRefExoticComponent, RefAttributes } from "react"; + +/** + * How the composer treats the screen behind it while expanded. + * + * `dim` matches the mocks: the composer owns the screen and an outside tap + * dismisses it. `passthrough` leaves the content behind live so it can be + * scrolled while the keyboard is up — what a chat transcript wants. In that + * mode the caller owns dismissal, since nothing intercepts the outside tap. + */ +export type ComposerBackdrop = "dim" | "passthrough"; + +/** + * One entry in a composer picker. + * + * `iconUri` may be a remote URL or a local file URI. What it must not be is a + * Metro asset reference — SwiftUI cannot read those. Resolve bundled art with + * `expo-asset` first; see `useAgentIconUri`. + */ +export interface ComposerMenuOption { + id: string; + label: string; + iconUri?: string; + /** + * Lead with a project avatar. Separate from `iconUri` because most projects + * have no logo and the app draws their initial instead of leaving a gap — + * the same thing `ProjectAvatar` does everywhere else. + */ + avatar?: boolean; + /** + * Render a step back, as a qualifier rather than as the subject. The branch + * belongs to the project name beside it and should not compete with it. + */ + muted?: boolean; +} + +/** + * One permission mode in the menu beside `+`. `symbol` is an SF Symbol name. + */ +export interface ComposerModeOption { + id: string; + label: string; + symbol: string; +} + +/** + * One item in the composer's tray. The tray stays in React Native — it is + * shared with the attachments sheet — so the composer renders a description of + * it and reports removals and taps back out. + */ +export interface ComposerAttachment { + id: string; + uri: string; + kind: "image" | "file"; + /** + * Shown on the file card. Documents are unidentifiable without it — they all + * draw the same glyph. Ignored for images, which show themselves. + */ + name?: string; + /** + * 0–1 while the file is still on its way to cloud storage, drawn as a ring + * over the thumbnail. Omitted once it has landed, or when nothing is + * uploading — a tray of settled attachments draws no rings. + */ + progress?: number; + /** + * The upload failed. Marks the thumbnail instead of the ring; the + * attachment stays in the tray because sending the message retries it. + */ + failed?: boolean; +} + +/** + * One key in the strip above the composer — the terminal's esc/tab/arrows. + * + * Deliberately carries no behaviour: what a key writes into the PTY stays with + * the terminal that owns it. The composer draws the mark and reports the id. + */ +export interface ComposerQuickKey { + id: string; + /** Monospaced label. Ignored when `symbol` is set. */ + label?: string; + /** SF Symbol name, e.g. `arrow.up`. */ + symbol?: string; + /** + * A hairline between groups rather than a key. Which keys belong together + * is the terminal's knowledge, so it arrives as data — but a divider still + * needs its own unique `id`, because the strip identifies entries by it. + */ + divider?: boolean; +} + +/** + * One session in the strip above the quick keys — the workspace's tab bar. + * + * Data only, like the quick keys: the composer draws a pill and reports which + * one was touched. Everything about what a session *is* stays with the caller. + */ +export interface ComposerSessionTab { + id: string; + label: string; + /** + * The agent's brand mark. Same rule as `ComposerMenuOption.iconUri`: a + * remote URL or a local file URI, never a Metro asset reference. Resolve + * bundled art with `expo-asset` first — see `useAgentIconUris`. Omit for a + * plain shell, which draws the session's initial instead. + */ + iconUri?: string; + selected?: boolean; + /** Desktop's StatusIndicator states; omit for a session with nothing to say. */ + attention?: "permission" | "working" | "failed" | "review"; +} + +/** + * The one static control beside the quick keys. + * + * Data only, like the keys: the composer draws a chip and says it was pressed. + * What it opens is the caller's to know — the workspace terminal points it at + * that workspace's pull requests, and nothing here names one. + * + * It sits ahead of the bar rather than inside it, so it holds still while the + * keys scroll. It takes its room only once it exists: the keys slide over the + * moment it arrives, animated by the composer. + */ +export interface ComposerQuickKeysAction { + /** SF Symbol name, e.g. `arrow.triangle.pull`. */ + symbol: string; + /** + * A mark to draw instead of `symbol`. Same rule as + * `ComposerSessionTab.iconUri`: a local file URI, never a Metro asset + * reference. Drawn as a template, so it takes `tint` like the symbol does, + * and `symbol` shows until it resolves. + */ + iconUri?: string; + /** + * Which accent the glyph takes. Omit for the same foreground the keys use. + * The name crosses the bridge and the composer owns the colour, the way + * `ComposerSessionTab.attention` does. + */ + tint?: "open" | "draft" | "queued" | "merged" | "closed"; + /** Accessibility label. Translated here; the composer has no catalog. */ + label: string; +} + +/** + * Every user-facing string the tab strip draws. + * + * They cross the bridge because the composer cannot translate — Lingui's macros + * and catalogs live here — and a hardcoded English menu item would be the one + * untranslated string on a translated screen. + */ +export interface ComposerSessionTabLabels { + /** Context menu: opens the prompt for the session's name. */ + rename: string; + /** Context menu: copies the session's id to the pasteboard. */ + copyId: string; + /** Context menu, destructive, and the close disc's accessibility label. */ + close: string; + newSession: string; + allSessions: string; + scrollToStart: string; +} + +/** + * One slash command or skill the active agent can run — the suggestion panel + * above the composer. Data only, like the quick keys: selection replaces the + * draft natively and reports back through `onDraftChange`. + */ +export interface ComposerSlashCommand { + id: string; + /** Full display name, namespace included (`agent-sdk-dev:new-sdk-app`). */ + name: string; + descriptionText?: string; + /** The sigil that opens and commits this entry: `/`, or `$` for Codex skills. */ + trigger: "/" | "$"; + /** Non-empty when the command takes arguments; a fully typed command with + * arguments keeps the panel closed so they can be typed in peace. */ + argumentHint?: string; + /** Harness-shipped commands sort after user-defined ones, like desktop. */ + isBuiltin?: boolean; + /** Alternate names; matched after the canonical name, like desktop. */ + aliases?: string[]; +} + +/** + * A file or image pasted into the field, already written to disk by the native + * side — the tray takes URIs, the same shape the pickers produce. + */ +export interface ComposerPastedItem { + uri: string; + name: string; + kind: "image" | "file"; +} + +export interface ComposerHandle { + /** Empties the draft. */ + clear: () => void; + /** + * Appends to the draft, for dictation. The composer owns the base text and + * does the join, so callers never have to read it back. + */ + appendDraft: (text: string) => void; + /** + * Re-opens the composer after something else took first responder — an + * attachments sheet, a picker — bringing the keyboard and draft back. + */ + focus: () => void; + blur: () => void; +} + +/** + * The strip and its strings arrive together or not at all. + * + * Split out of the props rather than left as two optional fields: the composer + * cannot translate, so tabs without labels render a context menu and + * accessibility labels that are empty strings — a caller can reach that state + * without any type error, and nothing about it looks wrong until VoiceOver + * reaches it. + */ +type ComposerSessionTabsProps = + | { + sessionTabs: ComposerSessionTab[]; + sessionTabLabels: ComposerSessionTabLabels; + } + | { sessionTabs?: undefined; sessionTabLabels?: undefined }; + +export type ComposerProps = ComposerBaseProps & ComposerSessionTabsProps; + +interface ComposerBaseProps { + placeholder?: string; + /** + * Whatever this surface had typed when it was last open, put back as the + * composer is set up. Read once by the caller and never changed after: this + * is a starting value, not a binding, and the composer owns its text from + * here on. There is deliberately no `value` prop — see `onDraftChange`. + */ + initialDraft?: string; + backdrop?: ComposerBackdrop; + attachments?: ComposerAttachment[]; + /** + * The selected agent, shown as brand mark + name. Omit to hide the picker — + * what the terminal surface wants. The list itself stays in React Native: + * the real pickers are `formSheet` routes with searchable lists. + */ + selectedModel?: ComposerMenuOption; + /** + * The selected agent's launch settings, drawn after it as their own + * chevron buttons — model, effort. Each reports its id on press; the + * lists stay in React Native like the agent's. Omit for agents with none. + */ + launchOptions?: ComposerMenuOption[]; + /** The agent's permission modes, as a native menu beside `+`. */ + modeOptions?: ComposerModeOption[]; + selectedModeId?: string; + /** The agent is working: with an empty draft, send becomes stop. */ + canStop?: boolean; + /** Frame 4's header row. Empty on the session surface (frame 13). */ + headerChips?: ComposerMenuOption[]; + /** + * Keys above the card — the terminal's esc/tab/arrows. Rendered natively + * rather than by the caller: as a React Native sibling the gap to the card + * had to guess a height it could not measure, and drifted every time the + * card grew. + */ + quickKeys?: ComposerQuickKey[]; + /** + * The one static control beside the quick keys. Omitted on every surface + * with nothing to link to — which is all of them but the workspace + * terminal. Independent of `quickKeys`: an action with the keys away still + * draws the row it belongs to. + */ + quickKeysAction?: ComposerQuickKeysAction; + /** + * What the active agent can run behind `/` (or `$`). Empty or omitted + * hides the suggestion panel — a plain shell, an agent without command + * discovery, or a host too old to answer all look the same here. + */ + slashCommands?: ComposerSlashCommand[]; + /** + * Offer the `+` button. A plain shell would try to *execute* an attachment + * path, so only agent sessions get it. + */ + showAttachments?: boolean; + /** `never` for the terminal — a shell command is not a sentence. */ + autocapitalization?: "sentences" | "never"; + /** + * Shrinks the expanded editor's floor by two lines, so the terminal screen + * can leave more of the transcript on screen when the composer is at its + * smallest. Omit for the generous floor the reference frames show. + */ + compactEditor?: boolean; + /** + * A submit is in flight. Send becomes a grey spinner and the mic steps + * aside. The caller owns this because only it knows when delivery finished. + */ + isSending?: boolean; + /** + * Never clears the composer — the caller clears through the ref once its own + * delivery succeeded, so a failed send keeps the draft. + */ + onSubmit?: (text: string) => void; + onAttachmentsPress?: () => void; + /** + * Dictation runs natively — the composer owns the recogniser, the permission + * prompt and the append — so there is no press to handle here. This only + * surfaces a failure so the caller can show its own alert. + */ + onDictationError?: (message: string) => void; + onModelPress?: () => void; + onLaunchOptionPress?: (id: string) => void; + onModeSelect?: (id: string) => void; + onStop?: () => void; + onChipPress?: (id: string) => void; + onQuickKeyPress?: (id: string) => void; + /** A tab was tapped — attach that session. */ + onSessionTabPress?: (id: string) => void; + /** + * Close was chosen, from the selected tab's disc or the press-and-hold + * menu. Nothing has been killed yet: the composer has no idea what closing + * costs, so the caller confirms. + */ + onSessionTabClose?: (id: string) => void; + /** + * Rename was chosen from the press-and-hold menu. The composer neither + * asks for the new name nor knows what a session's name is — a menu cannot + * take text, and the name is the host's — so the caller prompts and saves. + */ + onSessionTabRename?: (id: string) => void; + /** + * Copy id was chosen from the press-and-hold menu. The caller owns the + * pasteboard write and whatever it shows afterwards, so the confirmation + * matches every other copy on the screen. + */ + onSessionTabCopyId?: (id: string) => void; + onNewSessionPress?: () => void; + onAllSessionsPress?: () => void; + /** + * The control beside the quick keys was pressed. Only ever fires when + * `quickKeysAction` is set, so the caller that provided the chip is the one + * that hears about it. + */ + onQuickKeysActionPress?: () => void; + /** + * How much room the composer occupies above the bottom safe area — the + * session tabs, the card, the quick keys and the gaps between them. + * + * The composer draws in an overlay and takes no layout space, so a caller + * with content underneath cannot measure it. Excludes the keyboard, which + * the caller already tracks and gets a duration and curve for. + */ + onHeightChange?: (height: number) => void; + /** + * Files and images pasted into the field. A plain text field only ever takes + * strings, so the composer owns its text view to offer Paste for these and + * writes them out; adding them to the tray is the caller's job, because the + * tray is the caller's. + */ + onPaste?: (items: ComposerPastedItem[]) => void; + /** + * Every keystroke, so a caller can keep a shadow copy of the draft and put + * it back later. Outward only, like `onHeightChange`: the composer owns its + * text while it is live and takes nothing back mid-edit, which is why there + * is no `value` prop. Restore through the ref at mount instead. + */ + onDraftChange?: (text: string) => void; + onRemoveAttachment?: (id: string) => void; + /** + * Fires only for non-image attachments. Images open in the composer's own + * full-screen viewer — it already holds the URI, so routing the tap out and + * back would buy nothing — but only the app knows what to do with a document. + */ + onAttachmentPress?: (id: string) => void; + /** + * Fires whenever the composer opens or closes. Callers need it to restore + * the composer only when it was actually open — re-focusing unconditionally + * after a sheet pops the keyboard back up over a collapsed composer. + */ + onExpandedChange?: (expanded: boolean) => void; +} + +/** + * The composer. Renders nothing in the React Native layout — it mounts a + * full-screen overlay over the screen's own view controller and draws there, so + * callers reserve room for the collapsed pill with a content inset rather than + * with layout. + * + * Every surface-specific difference arrives as data, never as children: the + * moment a `ReactNode` crosses this boundary the seam artifacts that made + * `GlassComposer` unmaintainable come back with it. See + * `plans/20260821-native-composer.md`. + */ +export type ComposerComponent = ForwardRefExoticComponent< + ComposerProps & RefAttributes +>; + +/** Opens an image full screen in the composer's own viewer. */ +export type PresentImage = (uri: string) => Promise; + +/** A PNG preview of a local file, as the composer's file card draws it. */ +export type FileThumbnail = ( + uri: string, + side: number, +) => Promise; diff --git a/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/ComposerAttachmentTray.tsx b/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/ComposerAttachmentTray.tsx new file mode 100644 index 00000000000..2e6a7706a45 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/ComposerAttachmentTray.tsx @@ -0,0 +1,126 @@ +import { Image } from "expo-image"; +import { CircleAlert, FileText, X } from "lucide-react-native"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import type { ComposerAttachment } from "../../Composer.types"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "../../constants"; + +interface ComposerAttachmentTrayProps { + attachments: ComposerAttachment[]; + onPress: (attachment: ComposerAttachment) => void; + onRemove: (id: string) => void; +} + +export function ComposerAttachmentTray({ + attachments, + onPress, + onRemove, +}: ComposerAttachmentTrayProps) { + return ( + + {attachments.map((attachment) => { + const uploading = + attachment.progress !== undefined && !attachment.failed; + return ( + + onPress(attachment)} + style={[ + styles.thumb, + attachment.kind === "file" && styles.fileCard, + ]} + > + {attachment.kind === "image" ? ( + + ) : ( + <> + + + {attachment.name} + + + )} + {uploading ? ( + + + {Math.round((attachment.progress ?? 0) * 100)}% + + + ) : null} + {attachment.failed ? ( + + + + ) : null} + + onRemove(attachment.id)} + style={styles.remove} + > + + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + content: { + gap: COMPOSER_METRICS.gap, + paddingHorizontal: COMPOSER_METRICS.edgeInset, + paddingTop: 10, + }, + item: { paddingTop: 4, paddingRight: 4 }, + thumb: { + width: COMPOSER_METRICS.thumbSize, + height: COMPOSER_METRICS.thumbSize, + borderRadius: 10, + overflow: "hidden", + backgroundColor: COMPOSER_COLORS.control, + alignItems: "center", + justifyContent: "center", + }, + fileCard: { + width: COMPOSER_METRICS.thumbSize * 2, + alignItems: "flex-start", + justifyContent: "space-between", + padding: 8, + }, + fileName: { color: COMPOSER_COLORS.muted, fontSize: 11 }, + dimmed: { opacity: 0.4 }, + badgeCenter: { + ...StyleSheet.absoluteFill, + alignItems: "center", + justifyContent: "center", + }, + progress: { + color: COMPOSER_COLORS.foreground, + fontSize: 12, + fontWeight: "600", + }, + remove: { + position: "absolute", + top: 0, + right: 0, + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: "#525252", + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/index.ts b/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/index.ts new file mode 100644 index 00000000000..55e62577dcb --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerAttachmentTray/index.ts @@ -0,0 +1 @@ +export { ComposerAttachmentTray } from "./ComposerAttachmentTray"; diff --git a/apps/mobile/modules/composer/src/components/ComposerImageViewer/ComposerImageViewer.tsx b/apps/mobile/modules/composer/src/components/ComposerImageViewer/ComposerImageViewer.tsx new file mode 100644 index 00000000000..7b831afd822 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerImageViewer/ComposerImageViewer.tsx @@ -0,0 +1,76 @@ +import { Image } from "expo-image"; +import { X } from "lucide-react-native"; +import { useEffect, useState } from "react"; +import { Modal, Pressable, StyleSheet, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +type Show = (uri: string) => void; + +/** Mounted viewers, newest last: only the newest one opens. */ +const viewers: Show[] = []; + +/** Opens `uri` in the newest mounted viewer; false when none is mounted. */ +export function showImage(uri: string): boolean { + const show = viewers.at(-1); + show?.(uri); + return show !== undefined; +} + +/** + * The full-screen image viewer behind `presentImage`. A function cannot draw, + * so each mounted composer hosts one. + */ +export function ComposerImageViewer() { + const [uri, setUri] = useState(null); + const insets = useSafeAreaInsets(); + + useEffect(() => { + const show: Show = (next) => setUri(next); + viewers.push(show); + return () => { + viewers.splice(viewers.indexOf(show), 1); + }; + }, []); + + return ( + setUri(null)} + statusBarTranslucent + navigationBarTranslucent + > + + {uri ? ( + + ) : null} + setUri(null)} + style={[styles.close, { top: insets.top + 12 }]} + > + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: "#000" }, + close: { + position: "absolute", + left: 16, + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: "rgba(38, 38, 38, 0.8)", + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerImageViewer/index.ts b/apps/mobile/modules/composer/src/components/ComposerImageViewer/index.ts new file mode 100644 index 00000000000..93920c1454e --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerImageViewer/index.ts @@ -0,0 +1 @@ +export { ComposerImageViewer, showImage } from "./ComposerImageViewer"; diff --git a/apps/mobile/modules/composer/src/components/ComposerModeMenu/ComposerModeMenu.tsx b/apps/mobile/modules/composer/src/components/ComposerModeMenu/ComposerModeMenu.tsx new file mode 100644 index 00000000000..950225d985d --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerModeMenu/ComposerModeMenu.tsx @@ -0,0 +1,107 @@ +import { Check } from "lucide-react-native"; +import { useState } from "react"; +import { Modal, Pressable, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { ComposerModeOption } from "../../Composer.types"; +import { COMPOSER_COLORS, COMPOSER_METRICS } from "../../constants"; +import { symbolIcon } from "../../utils/symbolIcon"; + +interface ComposerModeMenuProps { + options: ComposerModeOption[]; + selectedId?: string | null; + onSelect?: (id: string) => void; +} + +export function ComposerModeMenu({ + options, + selectedId, + onSelect, +}: ComposerModeMenuProps) { + const [open, setOpen] = useState(false); + const insets = useSafeAreaInsets(); + const selected = options.find((option) => option.id === selectedId); + const SelectedIcon = symbolIcon(selected?.symbol ?? options[0]?.symbol ?? ""); + + return ( + <> + setOpen(true)} + style={styles.trigger} + > + + + setOpen(false)} + statusBarTranslucent + navigationBarTranslucent + > + setOpen(false)}> + + {options.map((option) => { + const OptionIcon = symbolIcon(option.symbol); + return ( + { + setOpen(false); + onSelect?.(option.id); + }} + style={({ pressed }) => [ + styles.row, + pressed && styles.rowPressed, + ]} + > + + {option.label} + {option.id === selectedId ? ( + + ) : null} + + ); + })} + + + + + ); +} + +const styles = StyleSheet.create({ + trigger: { + width: COMPOSER_METRICS.controlSize, + height: COMPOSER_METRICS.controlSize, + borderRadius: COMPOSER_METRICS.controlSize / 2, + backgroundColor: COMPOSER_COLORS.control, + alignItems: "center", + justifyContent: "center", + }, + scrim: { + flex: 1, + justifyContent: "flex-end", + backgroundColor: COMPOSER_COLORS.scrim, + paddingHorizontal: COMPOSER_METRICS.edgeInset, + }, + sheet: { + backgroundColor: COMPOSER_COLORS.card, + borderColor: COMPOSER_COLORS.border, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 16, + paddingVertical: 6, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingHorizontal: 16, + paddingVertical: 14, + }, + rowPressed: { backgroundColor: COMPOSER_COLORS.control }, + label: { flex: 1, color: COMPOSER_COLORS.foreground, fontSize: 16 }, +}); diff --git a/apps/mobile/modules/composer/src/components/ComposerModeMenu/index.ts b/apps/mobile/modules/composer/src/components/ComposerModeMenu/index.ts new file mode 100644 index 00000000000..9fb1d6d9573 --- /dev/null +++ b/apps/mobile/modules/composer/src/components/ComposerModeMenu/index.ts @@ -0,0 +1 @@ +export { ComposerModeMenu } from "./ComposerModeMenu"; diff --git a/apps/mobile/modules/composer/src/constants.ts b/apps/mobile/modules/composer/src/constants.ts new file mode 100644 index 00000000000..cb7d194c47f --- /dev/null +++ b/apps/mobile/modules/composer/src/constants.ts @@ -0,0 +1,22 @@ +/** The app is dark-only (`userInterfaceStyle: "dark"`); these follow `global.css`. */ +export const COMPOSER_COLORS = { + card: "#171717", + border: "#2a2a2a", + control: "#262626", + foreground: "#fafafa", + muted: "#a3a3a3", + scrim: "rgba(0, 0, 0, 0.45)", + destructive: "#ef4444", +} as const; + +export const COMPOSER_METRICS = { + controlSize: 36, + thumbSize: 64, + miniThumbSize: 24, + lineHeight: 22, + editorLines: 4, + compactEditorLines: 2, + maxEditorLines: 10, + edgeInset: 12, + gap: 8, +} as const; diff --git a/apps/mobile/modules/composer/src/index.tsx b/apps/mobile/modules/composer/src/index.tsx index 29c513e0b62..6fc8babcb90 100644 --- a/apps/mobile/modules/composer/src/index.tsx +++ b/apps/mobile/modules/composer/src/index.tsx @@ -1,586 +1,18 @@ -import { requireNativeModule, requireNativeView } from "expo"; -import { forwardRef, type Ref, useImperativeHandle, useRef } from "react"; - -/** The imperative surface the native view exposes through its ref. */ -interface NativeComposerRef { - clear: () => void; - appendDraft: (text: string) => void; - focus: () => void; - blur: () => void; -} - -interface NativeComposerViewProps { - ref?: Ref; - placeholder?: string; - initialDraft?: string; - backdrop?: ComposerBackdrop; - attachments?: ComposerAttachment[]; - selectedModel?: ComposerMenuOption; - launchOptions?: ComposerMenuOption[]; - modeOptions?: ComposerModeOption[]; - selectedModeId?: string | null; - canStop?: boolean; - headerChips?: ComposerMenuOption[]; - quickKeys?: ComposerQuickKey[]; - sessionTabs?: ComposerSessionTab[]; - sessionTabLabels?: ComposerSessionTabLabels; - /** Null, never undefined — see the pass-through below. */ - quickKeysAction?: ComposerQuickKeysAction | null; - slashCommands?: ComposerSlashCommand[]; - showAttachments?: boolean; - autocapitalization?: "sentences" | "never"; - compactEditor?: boolean; - isSending?: boolean; - onSubmit?: (event: { nativeEvent: { text: string } }) => void; - onAttachmentsPress?: () => void; - onDictationError?: (event: { nativeEvent: { message: string } }) => void; - onModelPress?: () => void; - onLaunchOptionPress?: (event: { nativeEvent: { id: string } }) => void; - onModeSelect?: (event: { nativeEvent: { id: string } }) => void; - onStop?: () => void; - onChipPress?: (event: { nativeEvent: { id: string } }) => void; - onQuickKeyPress?: (event: { nativeEvent: { id: string } }) => void; - onSessionTabPress?: (event: { nativeEvent: { id: string } }) => void; - onSessionTabClose?: (event: { nativeEvent: { id: string } }) => void; - onSessionTabRename?: (event: { nativeEvent: { id: string } }) => void; - onSessionTabCopyId?: (event: { nativeEvent: { id: string } }) => void; - onNewSessionPress?: () => void; - onAllSessionsPress?: () => void; - onQuickKeysActionPress?: () => void; - onHeightChange?: (event: { nativeEvent: { height: number } }) => void; - onPaste?: (event: { nativeEvent: { items: ComposerPastedItem[] } }) => void; - onDraftChange?: (event: { nativeEvent: { text: string } }) => void; - onRemoveAttachment?: (event: { nativeEvent: { id: string } }) => void; - onAttachmentPress?: (event: { nativeEvent: { id: string } }) => void; - onExpandedChange?: (event: { nativeEvent: { expanded: boolean } }) => void; -} - -const NativeComposerView = - requireNativeView("Composer"); - -const NativeComposerModule = requireNativeModule<{ - presentImage: (uri: string) => Promise; - fileThumbnail: (uri: string, side: number) => Promise; -}>("Composer"); - -/** Opens an image full screen in the composer's own viewer. */ -export function presentImage(uri: string): Promise { - return NativeComposerModule.presentImage(uri); -} - -/** A PNG preview of a local file, as the composer's file card draws it. */ -export function fileThumbnail( - uri: string, - side: number, -): Promise { - return NativeComposerModule.fileThumbnail(uri, side); -} - -/** - * How the composer treats the screen behind it while expanded. - * - * `dim` matches the mocks: the composer owns the screen and an outside tap - * dismisses it. `passthrough` leaves the content behind live so it can be - * scrolled while the keyboard is up — what a chat transcript wants. In that - * mode the caller owns dismissal, since nothing intercepts the outside tap. - */ -export type ComposerBackdrop = "dim" | "passthrough"; - -/** - * One entry in a composer picker. - * - * `iconUri` may be a remote URL or a local file URI. What it must not be is a - * Metro asset reference — SwiftUI cannot read those. Resolve bundled art with - * `expo-asset` first; see `useAgentIconUri`. - */ -export interface ComposerMenuOption { - id: string; - label: string; - iconUri?: string; - /** - * Lead with a project avatar. Separate from `iconUri` because most projects - * have no logo and the app draws their initial instead of leaving a gap — - * the same thing `ProjectAvatar` does everywhere else. - */ - avatar?: boolean; - /** - * Render a step back, as a qualifier rather than as the subject. The branch - * belongs to the project name beside it and should not compete with it. - */ - muted?: boolean; -} - -/** - * One permission mode in the menu beside `+`. `symbol` is an SF Symbol name. - */ -export interface ComposerModeOption { - id: string; - label: string; - symbol: string; -} - -/** - * One item in the composer's tray. The tray stays in React Native — it is - * shared with the attachments sheet — so the composer renders a description of - * it and reports removals and taps back out. - */ -export interface ComposerAttachment { - id: string; - uri: string; - kind: "image" | "file"; - /** - * Shown on the file card. Documents are unidentifiable without it — they all - * draw the same glyph. Ignored for images, which show themselves. - */ - name?: string; - /** - * 0–1 while the file is still on its way to cloud storage, drawn as a ring - * over the thumbnail. Omitted once it has landed, or when nothing is - * uploading — a tray of settled attachments draws no rings. - */ - progress?: number; - /** - * The upload failed. Marks the thumbnail instead of the ring; the - * attachment stays in the tray because sending the message retries it. - */ - failed?: boolean; -} - -/** - * One key in the strip above the composer — the terminal's esc/tab/arrows. - * - * Deliberately carries no behaviour: what a key writes into the PTY stays with - * the terminal that owns it. The composer draws the mark and reports the id. - */ -export interface ComposerQuickKey { - id: string; - /** Monospaced label. Ignored when `symbol` is set. */ - label?: string; - /** SF Symbol name, e.g. `arrow.up`. */ - symbol?: string; - /** - * A hairline between groups rather than a key. Which keys belong together - * is the terminal's knowledge, so it arrives as data — but a divider still - * needs its own unique `id`, because the strip identifies entries by it. - */ - divider?: boolean; -} - -/** - * One session in the strip above the quick keys — the workspace's tab bar. - * - * Data only, like the quick keys: the composer draws a pill and reports which - * one was touched. Everything about what a session *is* stays with the caller. - */ -export interface ComposerSessionTab { - id: string; - label: string; - /** - * The agent's brand mark. Same rule as `ComposerMenuOption.iconUri`: a - * remote URL or a local file URI, never a Metro asset reference. Resolve - * bundled art with `expo-asset` first — see `useAgentIconUris`. Omit for a - * plain shell, which draws the session's initial instead. - */ - iconUri?: string; - selected?: boolean; - /** Desktop's StatusIndicator states; omit for a session with nothing to say. */ - attention?: "permission" | "working" | "failed" | "review"; -} - -/** - * The one static control beside the quick keys. - * - * Data only, like the keys: the composer draws a chip and says it was pressed. - * What it opens is the caller's to know — the workspace terminal points it at - * that workspace's pull requests, and nothing here names one. - * - * It sits ahead of the bar rather than inside it, so it holds still while the - * keys scroll. It takes its room only once it exists: the keys slide over the - * moment it arrives, animated by the composer. - */ -export interface ComposerQuickKeysAction { - /** SF Symbol name, e.g. `arrow.triangle.pull`. */ - symbol: string; - /** - * A mark to draw instead of `symbol`. Same rule as - * `ComposerSessionTab.iconUri`: a local file URI, never a Metro asset - * reference. Drawn as a template, so it takes `tint` like the symbol does, - * and `symbol` shows until it resolves. - */ - iconUri?: string; - /** - * Which accent the glyph takes. Omit for the same foreground the keys use. - * The name crosses the bridge and the composer owns the colour, the way - * `ComposerSessionTab.attention` does. - */ - tint?: "open" | "draft" | "queued" | "merged" | "closed"; - /** Accessibility label. Translated here; the composer has no catalog. */ - label: string; -} - -/** - * Every user-facing string the tab strip draws. - * - * They cross the bridge because the composer cannot translate — Lingui's macros - * and catalogs live here — and a hardcoded English menu item would be the one - * untranslated string on a translated screen. - */ -export interface ComposerSessionTabLabels { - /** Context menu: opens the prompt for the session's name. */ - rename: string; - /** Context menu: copies the session's id to the pasteboard. */ - copyId: string; - /** Context menu, destructive, and the close disc's accessibility label. */ - close: string; - newSession: string; - allSessions: string; - scrollToStart: string; -} - -/** - * One slash command or skill the active agent can run — the suggestion panel - * above the composer. Data only, like the quick keys: selection replaces the - * draft natively and reports back through `onDraftChange`. - */ -export interface ComposerSlashCommand { - id: string; - /** Full display name, namespace included (`agent-sdk-dev:new-sdk-app`). */ - name: string; - descriptionText?: string; - /** The sigil that opens and commits this entry: `/`, or `$` for Codex skills. */ - trigger: "/" | "$"; - /** Non-empty when the command takes arguments; a fully typed command with - * arguments keeps the panel closed so they can be typed in peace. */ - argumentHint?: string; - /** Harness-shipped commands sort after user-defined ones, like desktop. */ - isBuiltin?: boolean; - /** Alternate names; matched after the canonical name, like desktop. */ - aliases?: string[]; -} - -/** - * A file or image pasted into the field, already written to disk by the native - * side — the tray takes URIs, the same shape the pickers produce. - */ -export interface ComposerPastedItem { - uri: string; - name: string; - kind: "image" | "file"; -} - -export interface ComposerHandle { - /** Empties the draft. */ - clear: () => void; - /** - * Appends to the draft, for dictation. The composer owns the base text and - * does the join, so callers never have to read it back. - */ - appendDraft: (text: string) => void; - /** - * Re-opens the composer after something else took first responder — an - * attachments sheet, a picker — bringing the keyboard and draft back. - */ - focus: () => void; - blur: () => void; -} - -/** - * The strip and its strings arrive together or not at all. - * - * Split out of the props rather than left as two optional fields: the composer - * cannot translate, so tabs without labels render a context menu and - * accessibility labels that are empty strings — a caller can reach that state - * without any type error, and nothing about it looks wrong until VoiceOver - * reaches it. - */ -type ComposerSessionTabsProps = - | { - sessionTabs: ComposerSessionTab[]; - sessionTabLabels: ComposerSessionTabLabels; - } - | { sessionTabs?: undefined; sessionTabLabels?: undefined }; - -export type ComposerProps = ComposerBaseProps & ComposerSessionTabsProps; - -interface ComposerBaseProps { - placeholder?: string; - /** - * Whatever this surface had typed when it was last open, put back as the - * composer is set up. Read once by the caller and never changed after: this - * is a starting value, not a binding, and the composer owns its text from - * here on. There is deliberately no `value` prop — see `onDraftChange`. - */ - initialDraft?: string; - backdrop?: ComposerBackdrop; - attachments?: ComposerAttachment[]; - /** - * The selected agent, shown as brand mark + name. Omit to hide the picker — - * what the terminal surface wants. The list itself stays in React Native: - * the real pickers are `formSheet` routes with searchable lists. - */ - selectedModel?: ComposerMenuOption; - /** - * The selected agent's launch settings, drawn after it as their own - * chevron buttons — model, effort. Each reports its id on press; the - * lists stay in React Native like the agent's. Omit for agents with none. - */ - launchOptions?: ComposerMenuOption[]; - /** The agent's permission modes, as a native menu beside `+`. */ - modeOptions?: ComposerModeOption[]; - selectedModeId?: string; - /** The agent is working: with an empty draft, send becomes stop. */ - canStop?: boolean; - /** Frame 4's header row. Empty on the session surface (frame 13). */ - headerChips?: ComposerMenuOption[]; - /** - * Keys above the card — the terminal's esc/tab/arrows. Rendered natively - * rather than by the caller: as a React Native sibling the gap to the card - * had to guess a height it could not measure, and drifted every time the - * card grew. - */ - quickKeys?: ComposerQuickKey[]; - /** - * The one static control beside the quick keys. Omitted on every surface - * with nothing to link to — which is all of them but the workspace - * terminal. Independent of `quickKeys`: an action with the keys away still - * draws the row it belongs to. - */ - quickKeysAction?: ComposerQuickKeysAction; - /** - * What the active agent can run behind `/` (or `$`). Empty or omitted - * hides the suggestion panel — a plain shell, an agent without command - * discovery, or a host too old to answer all look the same here. - */ - slashCommands?: ComposerSlashCommand[]; - /** - * Offer the `+` button. A plain shell would try to *execute* an attachment - * path, so only agent sessions get it. - */ - showAttachments?: boolean; - /** `never` for the terminal — a shell command is not a sentence. */ - autocapitalization?: "sentences" | "never"; - /** - * Shrinks the expanded editor's floor by two lines, so the terminal screen - * can leave more of the transcript on screen when the composer is at its - * smallest. Omit for the generous floor the reference frames show. - */ - compactEditor?: boolean; - /** - * A submit is in flight. Send becomes a grey spinner and the mic steps - * aside. The caller owns this because only it knows when delivery finished. - */ - isSending?: boolean; - /** - * Never clears the composer — the caller clears through the ref once its own - * delivery succeeded, so a failed send keeps the draft. - */ - onSubmit?: (text: string) => void; - onAttachmentsPress?: () => void; - /** - * Dictation runs natively — the composer owns the recogniser, the permission - * prompt and the append — so there is no press to handle here. This only - * surfaces a failure so the caller can show its own alert. - */ - onDictationError?: (message: string) => void; - onModelPress?: () => void; - onLaunchOptionPress?: (id: string) => void; - onModeSelect?: (id: string) => void; - onStop?: () => void; - onChipPress?: (id: string) => void; - onQuickKeyPress?: (id: string) => void; - /** A tab was tapped — attach that session. */ - onSessionTabPress?: (id: string) => void; - /** - * Close was chosen, from the selected tab's disc or the press-and-hold - * menu. Nothing has been killed yet: the composer has no idea what closing - * costs, so the caller confirms. - */ - onSessionTabClose?: (id: string) => void; - /** - * Rename was chosen from the press-and-hold menu. The composer neither - * asks for the new name nor knows what a session's name is — a menu cannot - * take text, and the name is the host's — so the caller prompts and saves. - */ - onSessionTabRename?: (id: string) => void; - /** - * Copy id was chosen from the press-and-hold menu. The caller owns the - * pasteboard write and whatever it shows afterwards, so the confirmation - * matches every other copy on the screen. - */ - onSessionTabCopyId?: (id: string) => void; - onNewSessionPress?: () => void; - onAllSessionsPress?: () => void; - /** - * The control beside the quick keys was pressed. Only ever fires when - * `quickKeysAction` is set, so the caller that provided the chip is the one - * that hears about it. - */ - onQuickKeysActionPress?: () => void; - /** - * How much room the composer occupies above the bottom safe area — the - * session tabs, the card, the quick keys and the gaps between them. - * - * The composer draws in an overlay and takes no layout space, so a caller - * with content underneath cannot measure it. Excludes the keyboard, which - * the caller already tracks and gets a duration and curve for. - */ - onHeightChange?: (height: number) => void; - /** - * Files and images pasted into the field. A plain text field only ever takes - * strings, so the composer owns its text view to offer Paste for these and - * writes them out; adding them to the tray is the caller's job, because the - * tray is the caller's. - */ - onPaste?: (items: ComposerPastedItem[]) => void; - /** - * Every keystroke, so a caller can keep a shadow copy of the draft and put - * it back later. Outward only, like `onHeightChange`: the composer owns its - * text while it is live and takes nothing back mid-edit, which is why there - * is no `value` prop. Restore through the ref at mount instead. - */ - onDraftChange?: (text: string) => void; - onRemoveAttachment?: (id: string) => void; - /** - * Fires only for non-image attachments. Images open in the composer's own - * full-screen viewer — it already holds the URI, so routing the tap out and - * back would buy nothing — but only the app knows what to do with a document. - */ - onAttachmentPress?: (id: string) => void; - /** - * Fires whenever the composer opens or closes. Callers need it to restore - * the composer only when it was actually open — re-focusing unconditionally - * after a sheet pops the keyboard back up over a collapsed composer. - */ - onExpandedChange?: (expanded: boolean) => void; -} - -/** - * The native composer. Renders nothing in the React Native layout — it mounts a - * full-screen overlay over the screen's own view controller and draws there, so - * callers reserve room for the collapsed pill with a content inset rather than - * with layout. - * - * Every surface-specific difference arrives as data, never as children: the - * moment a `ReactNode` crosses this boundary the seam artifacts that made - * `GlassComposer` unmaintainable come back with it. See - * `plans/20260821-native-composer.md`. - */ -export const Composer = forwardRef( - function Composer( - { - placeholder = "", - initialDraft = "", - backdrop = "dim", - attachments, - selectedModel, - launchOptions, - modeOptions, - selectedModeId, - canStop = false, - headerChips, - quickKeys, - sessionTabs, - sessionTabLabels, - quickKeysAction, - slashCommands, - showAttachments = true, - autocapitalization = "sentences", - compactEditor = false, - isSending = false, - onSubmit, - onAttachmentsPress, - onDictationError, - onModelPress, - onLaunchOptionPress, - onModeSelect, - onStop, - onChipPress, - onQuickKeyPress, - onSessionTabPress, - onSessionTabClose, - onSessionTabRename, - onSessionTabCopyId, - onNewSessionPress, - onAllSessionsPress, - onQuickKeysActionPress, - onHeightChange, - onPaste, - onDraftChange, - onRemoveAttachment, - onAttachmentPress, - onExpandedChange, - }, - ref, - ) { - const nativeRef = useRef(null); - - useImperativeHandle(ref, () => ({ - clear: () => nativeRef.current?.clear(), - appendDraft: (text: string) => nativeRef.current?.appendDraft(text), - focus: () => nativeRef.current?.focus(), - blur: () => nativeRef.current?.blur(), - })); - - return ( - onSubmit?.(event.nativeEvent.text)} - onAttachmentsPress={onAttachmentsPress} - onDictationError={(event) => - onDictationError?.(event.nativeEvent.message) - } - onModelPress={onModelPress} - onLaunchOptionPress={(event) => - onLaunchOptionPress?.(event.nativeEvent.id) - } - onModeSelect={(event) => onModeSelect?.(event.nativeEvent.id)} - onStop={() => onStop?.()} - onChipPress={(event) => onChipPress?.(event.nativeEvent.id)} - onQuickKeyPress={(event) => onQuickKeyPress?.(event.nativeEvent.id)} - onSessionTabPress={(event) => onSessionTabPress?.(event.nativeEvent.id)} - onSessionTabClose={(event) => onSessionTabClose?.(event.nativeEvent.id)} - onSessionTabRename={(event) => - onSessionTabRename?.(event.nativeEvent.id) - } - onSessionTabCopyId={(event) => - onSessionTabCopyId?.(event.nativeEvent.id) - } - onNewSessionPress={onNewSessionPress} - onAllSessionsPress={onAllSessionsPress} - onQuickKeysActionPress={onQuickKeysActionPress} - onHeightChange={(event) => onHeightChange?.(event.nativeEvent.height)} - onPaste={(event) => onPaste?.(event.nativeEvent.items)} - onDraftChange={(event) => onDraftChange?.(event.nativeEvent.text)} - onRemoveAttachment={(event) => - onRemoveAttachment?.(event.nativeEvent.id) - } - onAttachmentPress={(event) => onAttachmentPress?.(event.nativeEvent.id)} - onExpandedChange={(event) => - onExpandedChange?.(event.nativeEvent.expanded) - } - /> - ); - }, -); +export { Composer, fileThumbnail, presentImage } from "./Composer"; +export type { + ComposerAttachment, + ComposerBackdrop, + ComposerComponent, + ComposerHandle, + ComposerMenuOption, + ComposerModeOption, + ComposerPastedItem, + ComposerProps, + ComposerQuickKey, + ComposerQuickKeysAction, + ComposerSessionTab, + ComposerSessionTabLabels, + ComposerSlashCommand, + FileThumbnail, + PresentImage, +} from "./Composer.types"; diff --git a/apps/mobile/modules/composer/src/utils/joinDraft/index.ts b/apps/mobile/modules/composer/src/utils/joinDraft/index.ts new file mode 100644 index 00000000000..edddaa4e6a2 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/joinDraft/index.ts @@ -0,0 +1 @@ +export { joinDraft } from "./joinDraft"; diff --git a/apps/mobile/modules/composer/src/utils/joinDraft/joinDraft.test.ts b/apps/mobile/modules/composer/src/utils/joinDraft/joinDraft.test.ts new file mode 100644 index 00000000000..68f98ee832b --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/joinDraft/joinDraft.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { joinDraft } from "./joinDraft"; + +describe("joinDraft", () => { + test("puts a space between words", () => { + expect(joinDraft("fix the", " bug ")).toBe("fix the bug"); + }); + + test("adds nothing before punctuation or after a newline", () => { + expect(joinDraft("done", ".")).toBe("done."); + expect(joinDraft("first\n", "second")).toBe("first\nsecond"); + }); + + test("does not space Japanese or Chinese", () => { + expect(joinDraft("バグを", "直して")).toBe("バグを直して"); + }); + + test("ignores an empty addition and an empty draft", () => { + expect(joinDraft("keep", " ")).toBe("keep"); + expect(joinDraft("", "start")).toBe("start"); + }); +}); diff --git a/apps/mobile/modules/composer/src/utils/joinDraft/joinDraft.ts b/apps/mobile/modules/composer/src/utils/joinDraft/joinDraft.ts new file mode 100644 index 00000000000..33f520e325a --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/joinDraft/joinDraft.ts @@ -0,0 +1,19 @@ +const UNSPACED = /[\p{Script=Han} -〿぀-ヿ＀-￯]/u; +const PUNCTUATION = /\p{P}/u; + +/** + * `appendDraft`'s join, the same rule as the iOS `ComposerDictatedText.join`: + * a space between the two, except after whitespace, before punctuation, or + * next to a script that does not separate words. + */ +export function joinDraft(draft: string, text: string): string { + const addition = text.trim(); + if (!addition) return draft; + const base = draft.replace(/[ \t]+$/, ""); + const last = base.at(-1); + const first = addition[0]; + if (!last || !first) return base + addition; + if (/\s/.test(last) || PUNCTUATION.test(first)) return base + addition; + if (UNSPACED.test(last) || UNSPACED.test(first)) return base + addition; + return `${base} ${addition}`; +} diff --git a/apps/mobile/modules/composer/src/utils/symbolIcon/index.ts b/apps/mobile/modules/composer/src/utils/symbolIcon/index.ts new file mode 100644 index 00000000000..1aed0641513 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/symbolIcon/index.ts @@ -0,0 +1 @@ +export { symbolIcon } from "./symbolIcon"; diff --git a/apps/mobile/modules/composer/src/utils/symbolIcon/symbolIcon.ts b/apps/mobile/modules/composer/src/utils/symbolIcon/symbolIcon.ts new file mode 100644 index 00000000000..58b0dc38944 --- /dev/null +++ b/apps/mobile/modules/composer/src/utils/symbolIcon/symbolIcon.ts @@ -0,0 +1,68 @@ +import { + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, + Ban, + Circle, + CircleHelp, + ClipboardList, + CornerDownLeft, + Eye, + FileText, + FlaskConical, + GitMerge, + GitPullRequest, + Hand, + LayoutGrid, + Lock, + type LucideIcon, + Map as MapIcon, + Pencil, + Search, + ShieldCheck, + Shuffle, + TestTubes, + ThumbsUp, + TrendingDown, + Zap, +} from "lucide-react-native"; + +const SYMBOL_ICONS: Record = { + "arrow.down": ArrowDown, + "arrow.left": ArrowLeft, + "arrow.right": ArrowRight, + "arrow.up": ArrowUp, + "arrow.triangle.merge": GitMerge, + "arrow.triangle.pull": GitPullRequest, + bolt: Zap, + "bolt.fill": Zap, + "chart.line.downtrend.xyaxis": TrendingDown, + "checkmark.shield": ShieldCheck, + "checkmark.shield.fill": ShieldCheck, + "doc.text": FileText, + eye: Eye, + flask: FlaskConical, + hand: Hand, + "hand.raised": Hand, + "hand.raised.fill": Hand, + "hand.thumbsup": ThumbsUp, + "list.bullet.clipboard": ClipboardList, + lock: Lock, + "lock.fill": Lock, + magnifyingglass: Search, + map: MapIcon, + nosign: Ban, + pencil: Pencil, + "questionmark.circle": CircleHelp, + return: CornerDownLeft, + shield: ShieldCheck, + shuffle: Shuffle, + "square.grid.2x2": LayoutGrid, + "testtube.2": TestTubes, +}; + +/** The lucide icon drawn on Android for an SF Symbol name in the contract. */ +export function symbolIcon(symbol: string): LucideIcon { + return SYMBOL_ICONS[symbol] ?? Circle; +} diff --git a/apps/mobile/modules/forward-swipe/src/ForwardSwipe.ios.tsx b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.ios.tsx new file mode 100644 index 00000000000..f445cae4867 --- /dev/null +++ b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.ios.tsx @@ -0,0 +1,22 @@ +import { requireNativeView } from "expo"; +import type { ForwardSwipeComponent } from "./ForwardSwipe.types"; + +interface NativeForwardSwipeProps { + enabled: boolean; + onSwipeBegin: () => void; + style: { width: number; height: number }; +} + +const NativeForwardSwipe = + requireNativeView("ForwardSwipe"); + +export const ForwardSwipe: ForwardSwipeComponent = ({ + enabled, + onSwipeBegin, +}) => ( + +); diff --git a/apps/mobile/modules/forward-swipe/src/ForwardSwipe.tsx b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.tsx new file mode 100644 index 00000000000..737abb84bd5 --- /dev/null +++ b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.tsx @@ -0,0 +1,38 @@ +import { View } from "react-native"; +import { GestureDetector, usePanGesture } from "react-native-gesture-handler"; +import type { ForwardSwipeComponent } from "./ForwardSwipe.types"; + +// Gesture navigation keeps the outer ~24dp for system Back. +const EDGE_WIDTH = 40; +const ACTIVATE_AFTER = 16; + +export const ForwardSwipe: ForwardSwipeComponent = ({ + enabled, + onSwipeBegin, +}) => { + // A 1px view with hit slop, not a 40dp strip: taps under the edge still + // reach the content, and only a leftward pan claims the touch. + const pan = usePanGesture({ + enabled, + runOnJS: true, + hitSlop: { left: EDGE_WIDTH }, + activeOffsetX: -ACTIVATE_AFTER, + failOffsetY: [-ACTIVATE_AFTER, ACTIVATE_AFTER], + onActivate: () => onSwipeBegin(), + }); + return ( + + + + ); +}; diff --git a/apps/mobile/modules/forward-swipe/src/ForwardSwipe.types.ts b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.types.ts new file mode 100644 index 00000000000..d6493d65723 --- /dev/null +++ b/apps/mobile/modules/forward-swipe/src/ForwardSwipe.types.ts @@ -0,0 +1,12 @@ +import type { ComponentType } from "react"; + +export interface ForwardSwipeProps { + enabled: boolean; + onSwipeBegin: () => void; +} + +/** + * A pan from the enclosing screen's right edge calls `onSwipeBegin`. The + * screen that handler pushes then follows the finger. + */ +export type ForwardSwipeComponent = ComponentType; diff --git a/apps/mobile/modules/forward-swipe/src/index.tsx b/apps/mobile/modules/forward-swipe/src/index.tsx index 18ff380a74a..ee58e6a2a52 100644 --- a/apps/mobile/modules/forward-swipe/src/index.tsx +++ b/apps/mobile/modules/forward-swipe/src/index.tsx @@ -1,30 +1,2 @@ -import { requireNativeView } from "expo"; - -interface NativeForwardSwipeProps { - enabled: boolean; - onSwipeBegin: () => void; - style: { width: number; height: number }; -} - -const NativeForwardSwipe = - requireNativeView("ForwardSwipe"); - -/** - * A pan from the enclosing screen's right edge calls `onSwipeBegin`. The - * screen that handler pushes then follows the finger. - */ -export function ForwardSwipe({ - enabled, - onSwipeBegin, -}: { - enabled: boolean; - onSwipeBegin: () => void; -}) { - return ( - - ); -} +export { ForwardSwipe } from "./ForwardSwipe"; +export type { ForwardSwipeProps } from "./ForwardSwipe.types"; diff --git a/apps/mobile/modules/google-credential/.gitignore b/apps/mobile/modules/google-credential/.gitignore new file mode 100644 index 00000000000..0e1b04e90ed --- /dev/null +++ b/apps/mobile/modules/google-credential/.gitignore @@ -0,0 +1 @@ +android/build/ diff --git a/apps/mobile/modules/google-credential/android/build.gradle b/apps/mobile/modules/google-credential/android/build.gradle new file mode 100644 index 00000000000..957793a209d --- /dev/null +++ b/apps/mobile/modules/google-credential/android/build.gradle @@ -0,0 +1,21 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'sh.superset.mobile' +version = '0.0.1' + +expoModule { + v2 true +} + +android { + namespace "expo.modules.googlecredential" +} + +dependencies { + implementation "androidx.credentials:credentials:1.5.0" + implementation "androidx.credentials:credentials-play-services-auth:1.5.0" + implementation "com.google.android.libraries.identity.googleid:googleid:1.1.1" +} diff --git a/apps/mobile/modules/google-credential/android/src/main/java/expo/modules/googlecredential/GoogleCredentialModule.kt b/apps/mobile/modules/google-credential/android/src/main/java/expo/modules/googlecredential/GoogleCredentialModule.kt new file mode 100644 index 00000000000..63f41e34682 --- /dev/null +++ b/apps/mobile/modules/google-credential/android/src/main/java/expo/modules/googlecredential/GoogleCredentialModule.kt @@ -0,0 +1,46 @@ +package expo.modules.googlecredential + +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.NoCredentialException +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import expo.modules.kotlin.exception.CodedException +import expo.modules.kotlin.exception.Exceptions +import expo.modules.kotlin.functions.Coroutine +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class GoogleCredentialModule : Module() { + override fun definition() = ModuleDefinition { + Name("GoogleCredential") + + AsyncFunction("getIdToken") Coroutine { serverClientId: String, nonce: String -> + val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity() + val option = GetGoogleIdOption.Builder() + .setServerClientId(serverClientId) + .setFilterByAuthorizedAccounts(false) + .setNonce(nonce) + .build() + val request = GetCredentialRequest.Builder().addCredentialOption(option).build() + val credential = try { + CredentialManager.create(activity).getCredential(activity, request).credential + } catch (e: GetCredentialCancellationException) { + throw CodedException("ERR_CANCELED", "The user canceled Google sign-in", e) + } catch (e: NoCredentialException) { + throw CodedException("ERR_NO_CREDENTIAL", "No Google account is available", e) + } catch (e: GetCredentialException) { + throw CodedException("ERR_GOOGLE_CREDENTIAL", e.message ?: e.type, e) + } + if (credential !is CustomCredential || + credential.type != GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + ) { + throw CodedException("ERR_GOOGLE_CREDENTIAL", "Unexpected credential type ${credential.type}", null) + } + GoogleIdTokenCredential.createFrom(credential.data).idToken + } + } +} diff --git a/apps/mobile/modules/google-credential/expo-module.config.json b/apps/mobile/modules/google-credential/expo-module.config.json new file mode 100644 index 00000000000..15f84be1e34 --- /dev/null +++ b/apps/mobile/modules/google-credential/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.googlecredential.GoogleCredentialModule"] + } +} diff --git a/apps/mobile/modules/google-credential/package.json b/apps/mobile/modules/google-credential/package.json new file mode 100644 index 00000000000..185bbe3e68a --- /dev/null +++ b/apps/mobile/modules/google-credential/package.json @@ -0,0 +1,10 @@ +{ + "name": "@superset/google-credential", + "version": "0.0.1", + "main": "src/index.ts", + "private": true, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } +} diff --git a/apps/mobile/modules/google-credential/src/index.ts b/apps/mobile/modules/google-credential/src/index.ts new file mode 100644 index 00000000000..00e62824667 --- /dev/null +++ b/apps/mobile/modules/google-credential/src/index.ts @@ -0,0 +1,8 @@ +import { requireNativeModule } from "expo"; + +interface GoogleCredentialModule { + getIdToken(serverClientId: string, nonce: string): Promise; +} + +export const GoogleCredential = + requireNativeModule("GoogleCredential"); diff --git a/apps/mobile/modules/live-activity/.gitignore b/apps/mobile/modules/live-activity/.gitignore new file mode 100644 index 00000000000..0e1b04e90ed --- /dev/null +++ b/apps/mobile/modules/live-activity/.gitignore @@ -0,0 +1 @@ +android/build/ diff --git a/apps/mobile/modules/live-activity/android/build.gradle b/apps/mobile/modules/live-activity/android/build.gradle new file mode 100644 index 00000000000..dd438bd2388 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/build.gradle @@ -0,0 +1,19 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'sh.superset' +version = '0.0.1' + +expoModule { + v2 true +} + +android { + namespace "sh.superset.liveactivity" + defaultConfig { + versionCode 1 + versionName "0.0.1" + } +} diff --git a/apps/mobile/modules/live-activity/android/src/main/AndroidManifest.xml b/apps/mobile/modules/live-activity/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..89d5840f99a --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/AgentCard.kt b/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/AgentCard.kt new file mode 100644 index 00000000000..fdf70b6df78 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/AgentCard.kt @@ -0,0 +1,138 @@ +package sh.superset.liveactivity + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.graphics.Color +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationManagerCompat +import java.io.File + +/** + * The Android form of the Lock Screen card: one ongoing notification. On + * Android 16+ it asks to be promoted to a Live Update, which pins it to the + * top of the shade and the Lock Screen and adds a status bar chip. + */ +internal object AgentCard { + const val TAG = "agent-card" + const val NOTIFICATION_ID = 1 + /** Notification large icons draw at 48dp; 144px covers xxxhdpi at 3x. */ + const val ICON_PX = 144 + private const val CHANNEL_ID = "agent-status" + private const val SEPARATOR = " · " + + private val stateColor = mapOf( + "permission" to Color.rgb(228, 182, 92), + "failed" to Color.rgb(228, 124, 117), + "review" to Color.rgb(115, 191, 136), + ) + + fun iconsDir(context: Context): File = + File(context.cacheDir, "agent-card-icons").apply { mkdirs() } + + fun isEnabled(context: Context): Boolean { + val manager = NotificationManagerCompat.from(context) + if (!manager.areNotificationsEnabled()) return false + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return true + val channel = manager.getNotificationChannel(CHANNEL_ID) ?: return true + return channel.importance != NotificationManager.IMPORTANCE_NONE + } + + fun activeIds(context: Context): List { + val manager = context.getSystemService(NotificationManager::class.java) + return manager.activeNotifications + .filter { it.tag == TAG && it.id == NOTIFICATION_ID } + .map { TAG } + } + + fun post(context: Context, snapshot: AgentSnapshotRecord): String { + ensureChannel(context) + context.getSystemService(NotificationManager::class.java) + .notify(TAG, NOTIFICATION_ID, build(context, snapshot)) + return TAG + } + + private fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + // LOW: the card changes every few seconds and must never sound. A Live + // Update cannot use MIN. + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.agent_card_channel), + NotificationManager.IMPORTANCE_LOW, + ).apply { setShowBadge(false) } + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + private fun build(context: Context, snapshot: AgentSnapshotRecord): Notification { + val rows = snapshot.rows + val top = rows.firstOrNull() + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(context, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(context) + } + val title = top?.let { "${it.status}$SEPARATOR${it.name}" } ?: "" + val topMeta = top?.let { listOf(it.project, it.elapsed).filter(String::isNotEmpty) } + ?.joinToString(SEPARATOR) ?: "" + val others = rows.drop(1).map { listOf(it.status, it.name, it.elapsed).joinToString(SEPARATOR) } + val collapsed = if (others.isEmpty()) { + topMeta + } else { + (rows.drop(1).joinToString(", ") { it.name } + (snapshot.more?.let { " $it" } ?: "")) + } + val expanded = (listOf(topMeta) + others + listOfNotNull(snapshot.more)) + .filter(String::isNotEmpty) + .joinToString("\n") + + builder + .setSmallIcon(R.drawable.agent_card_mark) + .setContentTitle(title) + .setContentText(collapsed) + .setStyle(Notification.BigTextStyle().bigText(expanded)) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(Notification.CATEGORY_STATUS) + .setVisibility(Notification.VISIBILITY_PUBLIC) + // Updates arrive only while the app is in the foreground. The shade's + // own "5m" next to the app name is how the card admits it is old. + .setShowWhen(true) + .setWhen(System.currentTimeMillis()) + .setContentIntent(openIntent(context, rows)) + stateColor[snapshot.topState]?.let { builder.setColor(it) } + top?.iconFile?.let { file -> + BitmapFactory.decodeFile(File(iconsDir(context), file).path)?.let(builder::setLargeIcon) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + builder.setRequestPromotedOngoing(true) + top?.let { builder.setShortCriticalText(it.status) } + } + return builder.build() + } + + /** + * A notification has one tap target, so a single agent opens its session + * and several open Home, the same fallback the iOS card uses around rows. + */ + private fun openIntent(context: Context, rows: List): PendingIntent { + val only = rows.singleOrNull() + val uri = if (only != null) { + Uri.parse("superset:///workspace/${Uri.encode(only.workspaceId)}?tab=${Uri.encode(only.id)}") + } else { + Uri.parse("superset:///") + } + val intent = Intent(Intent.ACTION_VIEW, uri).setPackage(context.packageName) + return PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } +} diff --git a/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/LiveActivityModule.kt b/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/LiveActivityModule.kt new file mode 100644 index 00000000000..00c1924170b --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/java/sh/superset/liveactivity/LiveActivityModule.kt @@ -0,0 +1,94 @@ +package sh.superset.liveactivity + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.core.app.NotificationManagerCompat +import expo.modules.kotlin.exception.CodedException +import expo.modules.kotlin.functions.Coroutine +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.net.URL + +class AgentRowRecord : Record { + @Field var id: String = "" + @Field var workspaceId: String = "" + @Field var name: String = "" + @Field var project: String = "" + @Field var iconFile: String? = null + @Field var status: String = "" + @Field var state: String = "working" + @Field var elapsed: String = "" + @Field var isQuiet: Boolean = false +} + +class AgentSnapshotRecord : Record { + @Field var rows: List = emptyList() + @Field var more: String? = null + @Field var totalCount: Int = 0 + @Field var topState: String = "working" + @Field var staleDetail: String? = null + @Field var machineName: String = "" + @Field var staleAfterSeconds: Double = 0.0 +} + +private class DisabledException : + CodedException("ERR_DISABLED", "Notifications are off", null) + +private class BadKeyException(key: String) : + CodedException("ERR_BAD_KEY", key, null) + +private class DecodeException : + CodedException("ERR_DECODE", "not an image", null) + +class LiveActivityModule : Module() { + private val context: Context + get() = appContext.reactContext ?: throw IllegalStateException("No React context") + + override fun definition() = ModuleDefinition { + Name("LiveActivity") + + Function("areActivitiesEnabled") { AgentCard.isEnabled(context) } + + Function("activeIds") { AgentCard.activeIds(context) } + + AsyncFunction("cacheIcon") Coroutine { key: String, url: String -> + // The key arrives from JS: a separator or a traversal component would + // write outside the icons directory. + val safe = key.replace(Regex("[^A-Za-z0-9._-]"), "_") + if (safe.isEmpty() || safe == "." || safe == "..") throw BadKeyException(key) + val file = "$safe.png" + val target = File(AgentCard.iconsDir(context), file) + if (target.exists()) return@Coroutine file + withContext(Dispatchers.IO) { + val source = URL(url).openStream().use { BitmapFactory.decodeStream(it) } + ?: throw DecodeException() + val side = AgentCard.ICON_PX + val scaled = Bitmap.createScaledBitmap(source, side, side, true) + val partial = File(target.parentFile, "$file.part") + FileOutputStream(partial).use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) } + partial.renameTo(target) + } + file + } + + AsyncFunction("start") { snapshot: AgentSnapshotRecord -> + if (!AgentCard.isEnabled(context)) throw DisabledException() + AgentCard.post(context, snapshot) + } + + AsyncFunction("update") { id: String, snapshot: AgentSnapshotRecord -> + if (id in AgentCard.activeIds(context)) AgentCard.post(context, snapshot) + } + + AsyncFunction("endAll") { + NotificationManagerCompat.from(context).cancel(AgentCard.TAG, AgentCard.NOTIFICATION_ID) + } + } +} diff --git a/apps/mobile/modules/live-activity/android/src/main/res/drawable-hdpi/agent_card_mark.png b/apps/mobile/modules/live-activity/android/src/main/res/drawable-hdpi/agent_card_mark.png new file mode 100644 index 00000000000..8df1558d5e4 Binary files /dev/null and b/apps/mobile/modules/live-activity/android/src/main/res/drawable-hdpi/agent_card_mark.png differ diff --git a/apps/mobile/modules/live-activity/android/src/main/res/drawable-mdpi/agent_card_mark.png b/apps/mobile/modules/live-activity/android/src/main/res/drawable-mdpi/agent_card_mark.png new file mode 100644 index 00000000000..5d751590942 Binary files /dev/null and b/apps/mobile/modules/live-activity/android/src/main/res/drawable-mdpi/agent_card_mark.png differ diff --git a/apps/mobile/modules/live-activity/android/src/main/res/drawable-xhdpi/agent_card_mark.png b/apps/mobile/modules/live-activity/android/src/main/res/drawable-xhdpi/agent_card_mark.png new file mode 100644 index 00000000000..dd0d354107b Binary files /dev/null and b/apps/mobile/modules/live-activity/android/src/main/res/drawable-xhdpi/agent_card_mark.png differ diff --git a/apps/mobile/modules/live-activity/android/src/main/res/drawable-xxhdpi/agent_card_mark.png b/apps/mobile/modules/live-activity/android/src/main/res/drawable-xxhdpi/agent_card_mark.png new file mode 100644 index 00000000000..37238b53e2b Binary files /dev/null and b/apps/mobile/modules/live-activity/android/src/main/res/drawable-xxhdpi/agent_card_mark.png differ diff --git a/apps/mobile/modules/live-activity/android/src/main/res/drawable-xxxhdpi/agent_card_mark.png b/apps/mobile/modules/live-activity/android/src/main/res/drawable-xxxhdpi/agent_card_mark.png new file mode 100644 index 00000000000..6aef3493f1b Binary files /dev/null and b/apps/mobile/modules/live-activity/android/src/main/res/drawable-xxxhdpi/agent_card_mark.png differ diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-cs/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-cs/strings.xml new file mode 100644 index 00000000000..57159e1bda1 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-cs/strings.xml @@ -0,0 +1,4 @@ + + + Stav agentů + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-de/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-de/strings.xml new file mode 100644 index 00000000000..15ec1652739 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-de/strings.xml @@ -0,0 +1,4 @@ + + + Agentenstatus + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-es/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-es/strings.xml new file mode 100644 index 00000000000..b258f2e79b3 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-es/strings.xml @@ -0,0 +1,4 @@ + + + Estado de los agentes + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-fr/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-fr/strings.xml new file mode 100644 index 00000000000..79745b7f3a3 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-fr/strings.xml @@ -0,0 +1,4 @@ + + + État des agents + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-in/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-in/strings.xml new file mode 100644 index 00000000000..87098bf7ec4 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-in/strings.xml @@ -0,0 +1,4 @@ + + + Status agen + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-it/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-it/strings.xml new file mode 100644 index 00000000000..a23773cf143 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-it/strings.xml @@ -0,0 +1,4 @@ + + + Stato degli agenti + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-ja/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-ja/strings.xml new file mode 100644 index 00000000000..73ec6cc4709 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-ja/strings.xml @@ -0,0 +1,4 @@ + + + エージェントの状態 + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-ko/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-ko/strings.xml new file mode 100644 index 00000000000..290ee4961ed --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-ko/strings.xml @@ -0,0 +1,4 @@ + + + 에이전트 상태 + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-nl/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-nl/strings.xml new file mode 100644 index 00000000000..9bb4785237d --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-nl/strings.xml @@ -0,0 +1,4 @@ + + + Agentstatus + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-pl/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-pl/strings.xml new file mode 100644 index 00000000000..c048588e002 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-pl/strings.xml @@ -0,0 +1,4 @@ + + + Stan agentów + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-pt-rBR/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 00000000000..537feb2ee1a --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,4 @@ + + + Status dos agentes + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-ru/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-ru/strings.xml new file mode 100644 index 00000000000..a02632e6806 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-ru/strings.xml @@ -0,0 +1,4 @@ + + + Статус агентов + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-tr/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000000..1c4c6d84b0f --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-tr/strings.xml @@ -0,0 +1,4 @@ + + + Ajan durumu + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-vi/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-vi/strings.xml new file mode 100644 index 00000000000..708b4237cd3 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-vi/strings.xml @@ -0,0 +1,4 @@ + + + Trạng thái agent + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-zh-rCN/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 00000000000..44d70a8672c --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,4 @@ + + + 智能体状态 + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values-zh-rTW/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 00000000000..a03982be117 --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,4 @@ + + + 代理程式狀態 + diff --git a/apps/mobile/modules/live-activity/android/src/main/res/values/strings.xml b/apps/mobile/modules/live-activity/android/src/main/res/values/strings.xml new file mode 100644 index 00000000000..35eafdfefaa --- /dev/null +++ b/apps/mobile/modules/live-activity/android/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Agent status + diff --git a/apps/mobile/modules/live-activity/expo-module.config.json b/apps/mobile/modules/live-activity/expo-module.config.json index d76a075f733..c71615ff0fb 100644 --- a/apps/mobile/modules/live-activity/expo-module.config.json +++ b/apps/mobile/modules/live-activity/expo-module.config.json @@ -1,4 +1,5 @@ { - "platforms": ["apple"], - "apple": { "modules": ["LiveActivityModule"] } + "platforms": ["apple", "android"], + "apple": { "modules": ["LiveActivityModule"] }, + "android": { "modules": ["sh.superset.liveactivity.LiveActivityModule"] } } diff --git a/apps/mobile/modules/live-activity/src/LiveActivity.native.ts b/apps/mobile/modules/live-activity/src/LiveActivity.native.ts new file mode 100644 index 00000000000..9076bca2205 --- /dev/null +++ b/apps/mobile/modules/live-activity/src/LiveActivity.native.ts @@ -0,0 +1,4 @@ +import { requireNativeModule } from "expo"; +import type { LiveActivityModule } from "./LiveActivity.types"; + +export default requireNativeModule("LiveActivity"); diff --git a/apps/mobile/modules/live-activity/src/LiveActivity.ts b/apps/mobile/modules/live-activity/src/LiveActivity.ts new file mode 100644 index 00000000000..69fc1ee52d4 --- /dev/null +++ b/apps/mobile/modules/live-activity/src/LiveActivity.ts @@ -0,0 +1,12 @@ +import type { LiveActivityModule } from "./LiveActivity.types"; + +const LiveActivity: LiveActivityModule = { + areActivitiesEnabled: () => false, + activeIds: () => [], + cacheIcon: async () => "", + start: async () => "", + update: async () => {}, + endAll: async () => {}, +}; + +export default LiveActivity; diff --git a/apps/mobile/modules/live-activity/src/LiveActivity.types.ts b/apps/mobile/modules/live-activity/src/LiveActivity.types.ts new file mode 100644 index 00000000000..0948c063355 --- /dev/null +++ b/apps/mobile/modules/live-activity/src/LiveActivity.types.ts @@ -0,0 +1,42 @@ +export interface AgentRow { + /** Terminal id — selected as `?tab=` on the workspace route. */ + id: string; + /** Workspace the terminal belongs to; the deep link's real target. */ + workspaceId: string; + name: string; + project: string; + /** Filename returned by `cacheIcon`, or omitted to draw the initial. */ + iconFile?: string; + /** Pre-translated status word, e.g. "Needs you". */ + status: string; + state: "permission" | "working" | "failed" | "review"; + /** + * Time in the current state, already formatted — "12m", "1h", "3d". + * Formatted here rather than natively: SwiftUI's free-ticking timer can + * only render mm:ss, and units belong in Lingui's catalogs. + */ + elapsed: string; + isQuiet?: boolean; +} + +export interface AgentSnapshot { + rows: AgentRow[]; + more?: string; + /** Every agent, not just the rows that fit on the card. */ + totalCount: number; + topState: AgentRow["state"]; + staleDetail?: string; + machineName: string; + staleAfterSeconds?: number; +} + +/** A glanceable, system-drawn card of agent status outside the app. */ +export interface LiveActivityModule { + areActivitiesEnabled: () => boolean; + activeIds: () => string[]; + /** Downloads, downscales and caches a project icon where the card can read it. */ + cacheIcon: (key: string, url: string) => Promise; + start: (snapshot: AgentSnapshot) => Promise; + update: (id: string, snapshot: AgentSnapshot) => Promise; + endAll: () => Promise; +} diff --git a/apps/mobile/modules/live-activity/src/index.ts b/apps/mobile/modules/live-activity/src/index.ts index ba2ff5e3f21..fc017bf462e 100644 --- a/apps/mobile/modules/live-activity/src/index.ts +++ b/apps/mobile/modules/live-activity/src/index.ts @@ -1,66 +1,7 @@ -import { requireNativeModule } from "expo"; - -export interface AgentRow { - /** Terminal id — selected as `?tab=` on the workspace route. */ - id: string; - /** Workspace the terminal belongs to; the deep link's real target. */ - workspaceId: string; - name: string; - project: string; - /** Filename returned by `cacheIcon`, or omitted to draw the initial. */ - iconFile?: string; - /** Pre-translated status word, e.g. "Needs you". */ - status: string; - state: "permission" | "working" | "failed" | "review"; - /** - * Time in the current state, already formatted — "12m", "1h", "3d". - * Formatted here rather than in Swift: SwiftUI's free-ticking timer can - * only render mm:ss, and units belong in Lingui's catalogs. - */ - elapsed: string; - isQuiet?: boolean; -} - -export interface AgentSnapshot { - rows: AgentRow[]; - more?: string; - /** Every agent, not just the rows that fit on the card. */ - totalCount: number; - topState: AgentRow["state"]; - staleDetail?: string; - machineName: string; - staleAfterSeconds?: number; -} - -/** - * Row order for the card, most urgent first. - * - * Deliberately NOT desktop's `STATUS_PRIORITY`, which ranks `working` above - * `review`. On a glanceable card the question is "what wants me?", and a - * finished session waiting to be read wants you more than a busy one that - * does not. Rows are sorted by this, then by recency. - */ -export const CARD_PRIORITY: Record = { - permission: 4, - failed: 3, - review: 2, - working: 1, -}; - -export function orderRows(rows: AgentRow[]): AgentRow[] { - return [...rows].sort( - (a, b) => CARD_PRIORITY[b.state] - CARD_PRIORITY[a.state], - ); -} - -interface LiveActivityModule { - areActivitiesEnabled: () => boolean; - activeIds: () => string[]; - /** Downloads, downscales and caches a project icon into the App Group. */ - cacheIcon: (key: string, url: string) => Promise; - start: (snapshot: AgentSnapshot) => Promise; - update: (id: string, snapshot: AgentSnapshot) => Promise; - endAll: () => Promise; -} - -export default requireNativeModule("LiveActivity"); +export { default } from "./LiveActivity"; +export type { + AgentRow, + AgentSnapshot, + LiveActivityModule, +} from "./LiveActivity.types"; +export { CARD_PRIORITY, orderRows } from "./orderRows"; diff --git a/apps/mobile/modules/live-activity/src/orderRows.ts b/apps/mobile/modules/live-activity/src/orderRows.ts new file mode 100644 index 00000000000..2cbe3fc3d25 --- /dev/null +++ b/apps/mobile/modules/live-activity/src/orderRows.ts @@ -0,0 +1,22 @@ +import type { AgentRow } from "./LiveActivity.types"; + +/** + * Row order for the card, most urgent first. + * + * Deliberately NOT desktop's `STATUS_PRIORITY`, which ranks `working` above + * `review`. On a glanceable card the question is "what wants me?", and a + * finished session waiting to be read wants you more than a busy one that + * does not. Rows are sorted by this, then by recency. + */ +export const CARD_PRIORITY: Record = { + permission: 4, + failed: 3, + review: 2, + working: 1, +}; + +export function orderRows(rows: AgentRow[]): AgentRow[] { + return [...rows].sort( + (a, b) => CARD_PRIORITY[b.state] - CARD_PRIORITY[a.state], + ); +} diff --git a/apps/mobile/modules/symbol-button/src/SymbolButton.ios.tsx b/apps/mobile/modules/symbol-button/src/SymbolButton.ios.tsx new file mode 100644 index 00000000000..a7ebd57f2ec --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/SymbolButton.ios.tsx @@ -0,0 +1,28 @@ +import { requireNativeView } from "expo"; +import type { + SymbolButtonComponent, + SymbolButtonProps, +} from "./SymbolButton.types"; + +type NativeSymbolButtonProps = Omit< + SymbolButtonProps, + "onPress" | "onSelect" +> & { + onTap?: () => void; + onSelect?: (event: { nativeEvent: { id: string } }) => void; +}; + +const NativeSymbolButton = + requireNativeView("SymbolButton"); + +export const SymbolButton: SymbolButtonComponent = ({ + onSelect, + onPress, + ...props +}) => ( + onSelect?.(event.nativeEvent.id)} + /> +); diff --git a/apps/mobile/modules/symbol-button/src/SymbolButton.tsx b/apps/mobile/modules/symbol-button/src/SymbolButton.tsx new file mode 100644 index 00000000000..3fb72296f49 --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/SymbolButton.tsx @@ -0,0 +1,75 @@ +import { Pressable, View } from "react-native"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Icon } from "@/components/ui/icon"; +import { Text } from "@/components/ui/text"; +import { SYMBOL_ICONS } from "./constants"; +import type { SymbolButtonComponent } from "./SymbolButton.types"; + +const DEFAULT_SIZE = 17; + +export const SymbolButton: SymbolButtonComponent = ({ + systemImage, + size = DEFAULT_SIZE, + tint, + accessibilityLabel, + enabled = true, + items, + onPress, + onSelect, + style, +}) => { + const glyph = SYMBOL_ICONS[systemImage]; + const content = ( + + {glyph ? : null} + + ); + + if (!items) { + return ( + + {content} + + ); + } + + return ( + + + {content} + + + {items.map((item) => { + const itemGlyph = item.systemImage + ? SYMBOL_ICONS[item.systemImage] + : undefined; + return ( + onSelect?.(item.id)}> + {itemGlyph ? : null} + {item.title} + + ); + })} + + + ); +}; diff --git a/apps/mobile/modules/symbol-button/src/SymbolButton.types.ts b/apps/mobile/modules/symbol-button/src/SymbolButton.types.ts new file mode 100644 index 00000000000..e96c6a91f61 --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/SymbolButton.types.ts @@ -0,0 +1,25 @@ +import type { ComponentType } from "react"; +import type { StyleProp, ViewStyle } from "react-native"; + +export interface SymbolMenuItem { + id: string; + title: string; + /** SF Symbol name. */ + systemImage?: string; +} + +export interface SymbolButtonProps { + /** SF Symbol name. */ + systemImage: string; + size?: number; + tint?: string; + accessibilityLabel?: string; + enabled?: boolean; + /** Turns the tap into a pull-down menu of these items. */ + items?: SymbolMenuItem[]; + onPress?: () => void; + onSelect?: (id: string) => void; + style?: StyleProp; +} + +export type SymbolButtonComponent = ComponentType; diff --git a/apps/mobile/modules/symbol-button/src/constants.ts b/apps/mobile/modules/symbol-button/src/constants.ts new file mode 100644 index 00000000000..d9498c33d59 --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/constants.ts @@ -0,0 +1,31 @@ +import { + Ban, + CircleQuestionMark, + Ellipsis, + FlaskConical, + LayoutGrid, + type LucideIcon, + Map as MapIcon, + Search, + Shuffle, + TestTubes, + ThumbsUp, + Trash2, + TrendingDown, +} from "lucide-react-native"; + +/** The SF Symbol names callers pass, drawn with their closest lucide icon. */ +export const SYMBOL_ICONS: Record = { + "chart.line.downtrend.xyaxis": TrendingDown, + ellipsis: Ellipsis, + flask: FlaskConical, + "hand.thumbsup": ThumbsUp, + magnifyingglass: Search, + map: MapIcon, + nosign: Ban, + "questionmark.circle": CircleQuestionMark, + shuffle: Shuffle, + "square.grid.2x2": LayoutGrid, + "testtube.2": TestTubes, + trash: Trash2, +}; diff --git a/apps/mobile/modules/symbol-button/src/index.tsx b/apps/mobile/modules/symbol-button/src/index.tsx index 762eace7447..5b2cc46cebf 100644 --- a/apps/mobile/modules/symbol-button/src/index.tsx +++ b/apps/mobile/modules/symbol-button/src/index.tsx @@ -1,39 +1,2 @@ -import { requireNativeView } from "expo"; -import type { StyleProp, ViewStyle } from "react-native"; - -export interface SymbolMenuItem { - id: string; - title: string; - systemImage?: string; -} - -interface SymbolButtonProps { - systemImage: string; - size?: number; - tint?: string; - accessibilityLabel?: string; - enabled?: boolean; - items?: SymbolMenuItem[]; - onTap?: () => void; - onSelect?: (event: { nativeEvent: { id: string } }) => void; - style?: StyleProp; -} - -const NativeSymbolButton = requireNativeView("SymbolButton"); - -export function SymbolButton({ - onSelect, - onPress, - ...props -}: Omit & { - onSelect?: (id: string) => void; - onPress?: () => void; -}) { - return ( - onSelect?.(event.nativeEvent.id)} - /> - ); -} +export { SymbolButton } from "./SymbolButton"; +export type { SymbolButtonProps, SymbolMenuItem } from "./SymbolButton.types"; diff --git a/apps/mobile/modules/title-press/src/TitlePress.ios.tsx b/apps/mobile/modules/title-press/src/TitlePress.ios.tsx new file mode 100644 index 00000000000..8b6230c9c91 --- /dev/null +++ b/apps/mobile/modules/title-press/src/TitlePress.ios.tsx @@ -0,0 +1,15 @@ +import { requireNativeView } from "expo"; +import type { PressableTitle, TitlePressComponent } from "./TitlePress.types"; + +interface NativeTitlePressProps { + onTitlePress: () => void; + style: { width: number; height: number }; +} + +const NativeTitlePress = requireNativeView("TitlePress"); + +export const TitlePress: TitlePressComponent = ({ onPress }) => ( + +); + +export const pressableTitle: PressableTitle = () => undefined; diff --git a/apps/mobile/modules/title-press/src/TitlePress.tsx b/apps/mobile/modules/title-press/src/TitlePress.tsx new file mode 100644 index 00000000000..1ff9fa13d2c --- /dev/null +++ b/apps/mobile/modules/title-press/src/TitlePress.tsx @@ -0,0 +1,20 @@ +import { Pressable } from "react-native"; +import { Text } from "@/components/ui/text"; +import type { PressableTitle, TitlePressComponent } from "./TitlePress.types"; + +export const TitlePress: TitlePressComponent = () => null; + +export const pressableTitle: PressableTitle = + (onPress) => + ({ children }) => ( + + + {children} + + + ); diff --git a/apps/mobile/modules/title-press/src/TitlePress.types.ts b/apps/mobile/modules/title-press/src/TitlePress.types.ts new file mode 100644 index 00000000000..450dc3fcae6 --- /dev/null +++ b/apps/mobile/modules/title-press/src/TitlePress.types.ts @@ -0,0 +1,17 @@ +import type { NativeStackNavigationOptions } from "expo-router"; +import type { ComponentType } from "react"; + +export interface TitlePressProps { + onPress: () => void; +} + +/** Reports taps on the enclosing native stack screen's title. */ +export type TitlePressComponent = ComponentType; + +/** + * The `headerTitle` to set where the native bar cannot report taps itself. + * Undefined keeps the native title. + */ +export type PressableTitle = ( + onPress: () => void, +) => NativeStackNavigationOptions["headerTitle"]; diff --git a/apps/mobile/modules/title-press/src/index.tsx b/apps/mobile/modules/title-press/src/index.tsx index dcd584d9b1b..8ea6df2ff37 100644 --- a/apps/mobile/modules/title-press/src/index.tsx +++ b/apps/mobile/modules/title-press/src/index.tsx @@ -1,15 +1,6 @@ -import { requireNativeView } from "expo"; - -interface NativeTitlePressProps { - onTitlePress: () => void; - style: { width: number; height: number }; -} - -const NativeTitlePress = requireNativeView("TitlePress"); - -/** Reports taps on the enclosing native stack screen's title. */ -export function TitlePress({ onPress }: { onPress: () => void }) { - return ( - - ); -} +export { pressableTitle, TitlePress } from "./TitlePress"; +export type { + PressableTitle, + TitlePressComponent, + TitlePressProps, +} from "./TitlePress.types"; diff --git a/apps/mobile/modules/voice/src/VoiceAudio.ios.ts b/apps/mobile/modules/voice/src/VoiceAudio.ios.ts new file mode 100644 index 00000000000..89c0f99647b --- /dev/null +++ b/apps/mobile/modules/voice/src/VoiceAudio.ios.ts @@ -0,0 +1,39 @@ +import { requireOptionalNativeModule } from "expo"; +import type { EventSubscription } from "expo-modules-core"; +import type { + VoiceAudioApi, + VoiceInterruptionEvent, + VoiceRouteChangeEvent, +} from "./VoiceAudio.types"; + +interface VoiceAudioNativeModule { + configure(): Promise; + preferSpeaker(on: boolean): Promise; + release(): Promise; + currentRoute(): string[]; + addListener( + event: "onInterruption", + listener: (event: VoiceInterruptionEvent) => void, + ): EventSubscription; + addListener( + event: "onRouteChange", + listener: (event: VoiceRouteChangeEvent) => void, + ): EventSubscription; +} + +// Optional: a shell built before this module runs voice over WebRTC's own +// session, on the receiver, rather than not at all. +const native = + requireOptionalNativeModule("VoiceAudio"); + +export const VoiceAudio: VoiceAudioApi = { + available: native !== null, + configure: () => native?.configure() ?? Promise.resolve(), + preferSpeaker: (on) => native?.preferSpeaker(on) ?? Promise.resolve(), + release: () => native?.release() ?? Promise.resolve(), + currentRoute: () => native?.currentRoute() ?? [], + onInterruption: (listener) => + native?.addListener("onInterruption", listener) ?? null, + onRouteChange: (listener) => + native?.addListener("onRouteChange", listener) ?? null, +}; diff --git a/apps/mobile/modules/voice/src/VoiceAudio.ts b/apps/mobile/modules/voice/src/VoiceAudio.ts new file mode 100644 index 00000000000..56f648f3c5b --- /dev/null +++ b/apps/mobile/modules/voice/src/VoiceAudio.ts @@ -0,0 +1,11 @@ +import type { VoiceAudioApi } from "./VoiceAudio.types"; + +export const VoiceAudio: VoiceAudioApi = { + available: false, + configure: () => Promise.resolve(), + preferSpeaker: () => Promise.resolve(), + release: () => Promise.resolve(), + currentRoute: () => [], + onInterruption: () => null, + onRouteChange: () => null, +}; diff --git a/apps/mobile/modules/voice/src/VoiceAudio.types.ts b/apps/mobile/modules/voice/src/VoiceAudio.types.ts new file mode 100644 index 00000000000..376a5e7887b --- /dev/null +++ b/apps/mobile/modules/voice/src/VoiceAudio.types.ts @@ -0,0 +1,29 @@ +export interface VoiceInterruptionEvent { + phase: "began" | "ended"; + shouldResume: boolean; +} + +export interface VoiceRouteChangeEvent { + reason: string; + outputs: string[]; +} + +export interface VoiceSubscription { + remove(): void; +} + +/** The app's audio session for a voice call. */ +export interface VoiceAudioApi { + /** False when voice runs on WebRTC's own session instead. */ + available: boolean; + configure(): Promise; + preferSpeaker(on: boolean): Promise; + release(): Promise; + currentRoute(): string[]; + onInterruption( + listener: (event: VoiceInterruptionEvent) => void, + ): VoiceSubscription | null; + onRouteChange( + listener: (event: VoiceRouteChangeEvent) => void, + ): VoiceSubscription | null; +} diff --git a/apps/mobile/modules/voice/src/index.ts b/apps/mobile/modules/voice/src/index.ts index 893b7219043..c28cfbb7bc0 100644 --- a/apps/mobile/modules/voice/src/index.ts +++ b/apps/mobile/modules/voice/src/index.ts @@ -1,45 +1,7 @@ -import { requireOptionalNativeModule } from "expo"; -import type { EventSubscription } from "expo-modules-core"; - -export interface VoiceInterruptionEvent { - phase: "began" | "ended"; - shouldResume: boolean; -} - -export interface VoiceRouteChangeEvent { - reason: string; - outputs: string[]; -} - -interface VoiceAudioNativeModule { - configure(): Promise; - preferSpeaker(on: boolean): Promise; - release(): Promise; - currentRoute(): string[]; - addListener( - event: "onInterruption", - listener: (event: VoiceInterruptionEvent) => void, - ): EventSubscription; - addListener( - event: "onRouteChange", - listener: (event: VoiceRouteChangeEvent) => void, - ): EventSubscription; -} - -// Optional: a JS-only build (tests, a shell built before this module) runs -// voice over WebRTC's own session, on the receiver, rather than not at all. -const native = - requireOptionalNativeModule("VoiceAudio"); - -export const VoiceAudio = { - available: native !== null, - configure: () => native?.configure() ?? Promise.resolve(), - preferSpeaker: (on: boolean) => - native?.preferSpeaker(on) ?? Promise.resolve(), - release: () => native?.release() ?? Promise.resolve(), - currentRoute: () => native?.currentRoute() ?? [], - onInterruption: (listener: (event: VoiceInterruptionEvent) => void) => - native?.addListener("onInterruption", listener) ?? null, - onRouteChange: (listener: (event: VoiceRouteChangeEvent) => void) => - native?.addListener("onRouteChange", listener) ?? null, -}; +export { VoiceAudio } from "./VoiceAudio"; +export type { + VoiceAudioApi, + VoiceInterruptionEvent, + VoiceRouteChangeEvent, + VoiceSubscription, +} from "./VoiceAudio.types"; diff --git a/apps/mobile/plans/20261029-android-agent-card.md b/apps/mobile/plans/20261029-android-agent-card.md new file mode 100644 index 00000000000..f77033013dc --- /dev/null +++ b/apps/mobile/plans/20261029-android-agent-card.md @@ -0,0 +1,150 @@ +# Android agent card (and push, planned only) + +Step 5 of Android support. Status: MVP implemented on branch `android/05-live-activity-push`. +Push is **not** implemented; the second half of this plan is the design for it. + +## Today on iOS + +- Contract: `modules/live-activity/src/LiveActivity.types.ts` (`LiveActivityModule`, + `AgentSnapshot`, `AgentRow`). +- Caller: `screens/(authenticated)/(home)/home/hooks/useAgentLiveActivity`. Home polls host + terminals every ~5 s, builds a snapshot of agents that want attention (permission, failed, + review, working), keeps the four most urgent rows, and starts/updates/ends one card. It only + runs in the foreground; `staleAfterSeconds: 120` lets the card say "Not updating" when the app + stops feeding it. +- Native: ActivityKit Live Activity (`modules/live-activity/ios`) drawn by the WidgetKit + extension `targets/agentactivity`. Started with `pushType: nil`: no remote updates. + +## Closest Android equivalent + +| iOS | Android | +| --------------------------------- | --------------------------------------------------------------- | +| Live Activity on Lock Screen | Ongoing notification, `VISIBILITY_PUBLIC` | +| Dynamic Island compact view | Android 16+ Live Update status bar chip (`setShortCriticalText`) | +| Pinned at top of Lock Screen | Android 16+ promoted ongoing notification (Live Update) | +| `staleDate` → "Not updating" | `setWhen(now)` + `setShowWhen`: the shade shows "5m" by itself | +| Per-row `Link` deep links | One `contentIntent`; actions are at most three buttons | +| App Group icon cache | App `cacheDir`, read back as the large icon | +| ActivityKit push token | FCM data message that rebuilds the notification (see Push) | + +**Live Updates (Android 16, API 36).** A notification is promoted when it asks with +`setRequestPromotedOngoing(true)`, the manifest has the normal permission +`POST_PROMOTED_NOTIFICATIONS`, and it has promotable characteristics: ongoing, a content title, +Standard / BigText / Call / Progress / Metric style, not colorized, no custom `RemoteViews`, not a +group summary, channel importance above MIN. The user can turn promotion off per app +(`NotificationManager.canPostPromotedNotifications()`, settings intent +`ACTION_MANAGE_APP_PROMOTED_NOTIFICATIONS`). Source: +. + +`ProgressStyle` is the "progress-centric" template. Our card is a list of agents, not one +journey with a progress bar, so it uses **BigTextStyle** (promotable). `InboxStyle` would draw the +rows more cleanly but is not promotable, and a custom `RemoteViews` layout (the only way to get +per-row icons and colours like the iOS card) is also not promotable. We take promotion over +layout. + +## MVP (implemented) + +Same contract, no caller changes; iOS is unchanged. + +- `modules/live-activity/expo-module.config.json` adds `android`; the binding + `LiveActivity.ios.ts` is renamed `LiveActivity.native.ts` so both platforms load the native + module. `LiveActivity.ts` stays the fallback for web and `bun test`. +- `android/…/LiveActivityModule.kt` maps the contract: + - `areActivitiesEnabled`: app notifications on and the `agent-status` channel not blocked. + - `activeIds`: our notification (tag `agent-card`) in `getActiveNotifications()`. Like + ActivityKit, this is the source of truth across relaunches and user dismissal. + - `cacheIcon`: download, scale to 144 px, write `cacheDir/agent-card-icons/.png`. + - `start` / `update`: post the notification; `update` with an id that is gone does nothing. + - `endAll`: cancel it. +- `AgentCard.kt` builds it. Title "` · `" of the most urgent row; collapsed + text is the project and time for one agent, or the other names plus "+N more"; expanded + BigText has one line per row. Accent colour from `topState` (the iOS dark-mode tones). Tap + opens `superset:///workspace/?tab=` when one agent is on the card, Home + otherwise. On API 36+ it requests promotion and sets the top status as the chip text. +- Channel `agent-status`, `IMPORTANCE_LOW` (never sounds), name in Android string resources for + all 17 shipped locales. +- Small icon: the white bracket mark from `targets/agentactivity`, padded square, 5 densities. + +### Known gaps + +- **Runtime permission.** On Android 13+ `POST_NOTIFICATIONS` must be granted. Nothing in the + app asks today (iOS needs no permission for Live Activities), so `areActivitiesEnabled()` is + false until the user grants it. The push work below adds the prompt; until then, grant with + `adb shell pm grant sh.superset.mobile android.permission.POST_NOTIFICATIONS`. +- **No "Not updating" text.** `staleAfterSeconds` and `staleDetail` are ignored; the shade's + relative time is the stale signal. A follow-up can schedule an inexact `AlarmManager` alarm at + `staleAfterSeconds` whose receiver re-posts the last snapshot with `staleDetail` as sub-text. +- **One tap target.** Rows cannot each deep-link. Option: up to three actions, "Open ", + for the top rows. +- **Not verified on Android 16.** The local AVD is API 33, so promotion and the chip are + compiled and gated on `SDK_INT >= 36` but have not been seen running. +- Android 14+ lets users swipe away ongoing notifications that are not foreground services. + That matches iOS, where the user can dismiss a Live Activity; the hook starts a new card on + the next change. + +## Push (not implemented) + +### What it replaces + +The card is fed only by the foreground poll in `useAgentLiveActivity`. The moment the phone +locks, the card freezes, and `staleAfterSeconds` exists only to admit that. Push replaces: + +1. the foreground-only feed of the card (iOS `pushType: nil`, Android local `notify`) with + server-driven updates while the app is backgrounded or killed; +2. the "Not updating" crutch as the normal state on a locked phone; +3. (new) a plain alert notification when an agent starts needing you and no card is running. + +### Token registration, both platforms + +One path for both: **Expo push tokens** (`ExponentPushToken[…]`) from `expo-notifications`, +which is already installed (`app.config.ts`: `enableBackgroundRemoteNotifications: false`). Expo +fans out to APNs and FCM v1. + +- **Credentials.** APNs key (.p8) and FCM v1 service account JSON uploaded to EAS + (`eas credentials`). Android also needs `android.googleServicesFile` in `app.config.ts` + (a `google-services.json` from a Firebase project for `sh.superset.mobile`). +- **Mobile.** `apps/mobile/lib/push/` (seam per the AGENTS convention: `registerPush.types.ts`, + `.native.ts`, fallback `.ts`), called once from the authenticated layout: + 1. ask permission (`requestPermissionsAsync`; on Android 13+ this is the `POST_NOTIFICATIONS` + prompt the card also needs), behind a soft prompt, never at cold start; + 2. `getExpoPushTokenAsync({ projectId })`; + 3. `push.registerToken` with token, platform, a stable install id, app version; + 4. re-register on `addPushTokenListener` and on every sign-in; `push.unregisterToken` on + sign-out. +- **Live Activity tokens (iOS only).** A Live Activity started with `pushType: .token` gets its + own per-activity APNs token (`activity.pushTokenUpdates`). Expo's push service is not + known to send `liveactivity` pushes (check before building), so plan on sending these + **directly to APNs** (token-based auth with the same .p8, + `apns-push-type: liveactivity`, topic `.push-type.liveactivity`). The contract + grows an optional `onPushToken(activityId, token)` event; Android has no equivalent token: a + high-priority FCM *data* message to the device token rebuilds the card in a + `FirebaseMessagingService` (or the expo-notifications background task), using + `AgentCard.post` with the snapshot from the payload. + +### Where it lives server-side + +- **DB** (`packages/db/src/schema`): `push_tokens` (id, user_id, organization_id, install_id, + platform `ios|android`, expo_token unique, live_activity_token nullable, app_version, + created_at, last_seen_at, disabled_at). Generated with the db-migrations skill. +- **tRPC** (`packages/trpc/src/router/push/`, mounted in the root router): + `registerToken`, `unregisterToken`, `registerLiveActivityToken` as `protectedProcedure`s. + New procedures only, so released clients are unaffected (trpc-compat). +- **Sender** (`packages/trpc/src/lib/push/` or a shared package): Expo push via + `expo-server-sdk` (batches of 100, receipts checked later; `DeviceNotRegistered` sets + `disabled_at`), plus a small APNs client for Live Activity updates. +- **Trigger.** The API does not know agent state today; the host-service does (agent lifecycle + events, `packages/host-service/src/events/map-event-type.ts`) and the phone reads it through + the relay. The host-service would report state *transitions* (not every poll) to a new host-authenticated procedure, e.g. + `push.agentStateChanged`, through its existing API client + (`packages/host-service/src/api/createApiClient`). The API rolls them up per user into the + same `AgentSnapshot` shape and sends: an alert on `→ permission` / `→ failed`, a silent card + update otherwise, rate-limited (APNs budgets Live Activity updates). +- **API env** (`apps/api/src/env.ts`): `EXPO_ACCESS_TOKEN`, `APNS_KEY_ID`, `APNS_TEAM_ID`, + `APNS_PRIVATE_KEY`. Follow `docs/environment-variables.md`. + +### Order + +1. Permission prompt + token registration + `push_tokens` (no sending): measurable opt-in. +2. Alert pushes on `permission` / `failed` transitions (both platforms through Expo). +3. Remote card updates: iOS Live Activity APNs, Android FCM data → `AgentCard`. +4. Remove `staleAfterSeconds` once remote updates keep the card fresh. diff --git a/apps/mobile/plans/20261029-android-composer.md b/apps/mobile/plans/20261029-android-composer.md new file mode 100644 index 00000000000..4e7837db453 --- /dev/null +++ b/apps/mobile/plans/20261029-android-composer.md @@ -0,0 +1,83 @@ +# Android composer + +This ExecPlan is a living document. Keep `Progress`, `Missing after the MVP`, and `Decision Log` +up to date. + +## Purpose + +`Composer.tsx`, the fallback that Android resolves to, renders `null`, so Android has no chat input +on Home or in a workspace. This plan gives Android a composer to the same contract +(`modules/composer/src/Composer.types.ts`) as the iOS view, without changing iOS. + +## Decision: React Native (`Composer.android.tsx`), not a Kotlin Expo module + +| | React Native `Composer.android.tsx` | Kotlin module (`modules/composer/android`) | +|---|---|---| +| Why iOS went native | Liquid Glass, SwiftUI keyboard avoidance, an overlay view controller with its own safe area. None of these exist on Android. | Would rebuild the same overlay with Compose and `WindowInsets.ime`, a second large codebase. | +| Overlay | An absolute-fill `box-none` view over the screen. The callers already render the composer as the last child of the screen root (`HomeScreen`, `WorkspaceScreen`), so it overlays without a portal. | A full-screen `ComposeView` added to the activity's decor view, plus hit-test passthrough. | +| Keyboard | SDK 58 is edge-to-edge, so the window does not resize: the composer pads itself by the keyboard height from `Keyboard` events. | `WindowInsetsAnimationCompat` gives a frame-exact animation. Better, but only visible as polish. | +| Theme and icons | `lucide-react-native` and the app's dark palette, the same art the rest of the app draws. | A second icon set and colour table in Kotlin. | +| Iteration | Metro reload. No Gradle build, which matters with one build slot shared by five agents. | A Gradle build per change. | +| Seams | Adds one file. `Composer.ios.tsx` and the `expo-module.config.json` platforms stay as they are. | Rename the binding to `Composer.native.tsx`, add `"android"` to `platforms`, and keep both natives in step on every prop. | +| `presentImage` / `fileThumbnail` | A viewer `Modal` hosted by the mounted composer; image thumbnails through `expo-image-manipulator`. No PDF preview. | Native `PdfRenderer` thumbnails and a system image viewer. | + +Pick React Native. The iOS reasons for going native are iOS-specific; on Android the cost is a second +native codebase and slow iteration for little visible gain. A Kotlin module stays open later for one +piece at a time, for example a `fileThumbnail` with `PdfRenderer` or dictation with +`SpeechRecognizer`, behind the same contract. + +## Shape + +- `modules/composer/src/Composer.android.tsx` exports `Composer`, `presentImage`, `fileThumbnail`, + annotated with the contract types. It imports nothing from `@/`, like the rest of the module. +- Expanded is "the text field has focus". Focus opens it, blur closes it, `onExpandedChange` reports + each change. `focus()`/`blur()` on the handle drive the field. +- Collapsed: one row, `+` · mini thumb with `+N` · one line of draft or the placeholder · send/stop. +- Expanded: header chips, attachment tray with ✕ and upload state, a multi-line editor (4-line + floor, 2 with `compactEditor`), then the toolbar: `+` · mode menu · agent picker · launch options · + send/stop. `dim` draws a scrim that blurs on tap; `passthrough` draws none. +- `onHeightChange` reports the card and the rows around it, without the keyboard and the bottom + safe area, as on iOS. +- SF Symbol names in the contract (`ComposerModeOption.symbol`, `ComposerQuickKey.symbol`, + `ComposerQuickKeysAction.symbol`) go through one map to lucide icons, with a fallback icon. + +## Missing after the MVP + +- Dictation (`onDictationError` never fires): Android `SpeechRecognizer` needs a native module. +- Slash command suggestions (`slashCommands`). +- Session tabs (`sessionTabs`, its callbacks and labels). +- Quick keys and the quick-keys action (`quickKeys`, `quickKeysAction`). The lucide map exists for + them already. +- Paste of images and files (`onPaste`): RN `TextInput` takes text only. +- PDF and document thumbnails: `fileThumbnail` returns `null` for files that are not images. +- Swipe-down on the grabber to dismiss, animated collapse, upload rings drawn as rings (the MVP draws + a dimmed thumbnail with a percentage). + +## Progress + +- [x] (2026-10-29) Plan and decision. +- [x] (2026-10-29) MVP `Composer.android.tsx`. +- [x] (2026-10-29) Verified on the `Pixel_2_API_33` emulator through a temporary route that drove + every prop and handle method: collapsed and expanded states, the keyboard lift, `dim` and + `passthrough`, Back to collapse, tray remove and upload states, send held while uploading, + `isSending`, `canStop`/`onStop`, `clear`, `appendDraft`, the mode menu, `presentImage` and + `fileThumbnail`. Home and the workspace screen are not verified yet: they need a signed-in session + against a local API. + +## Surprises & Discoveries + +- Fabric flattens a layout-only `View`. The field's wrapper had a layout-only style when expanded + and `opacity: 0` when collapsed, so each change re-parented the field and Android moved focus to + the next focusable view. `collapsable={false}` keeps the wrapper. +- `keyboardDidShow` on Android reports the keyboard height without the navigation bar below it, so + the lift is `keyboard + insets.bottom`. +- Back lowers the keyboard without blurring the field, so the composer blurs on `keyboardDidHide`. +- `CI=1 expo start` turns off Metro's file watcher: a cold restart still gets the old bundle. +- The emulator shows the soft keyboard only after the first key input; it is not an app bug. + +## Decision Log + +| Date | Decision | +|---|---| +| 2026-10-29 | React Native over a Kotlin module, for the reasons in the table above. | +| 2026-10-29 | Expanded means focused. The collapsed row shows a one-line label over a transparent field, rather than restyling the field, so the head of a long draft shows with an ellipsis, as on iOS. | diff --git a/apps/mobile/plans/20261029-android-launch-checklist.md b/apps/mobile/plans/20261029-android-launch-checklist.md new file mode 100644 index 00000000000..2583952e846 --- /dev/null +++ b/apps/mobile/plans/20261029-android-launch-checklist.md @@ -0,0 +1,125 @@ +# Android launch checklist + +What stands between the Android work on `android-app-support-check-244611af` and a Play Store +release. Part 1 is for a person: accounts, keys and consoles that no agent can reach. Part 2 is +engineering that is known to be missing. Tick items off here as they land. + +Running the app locally needs none of this: `apps/mobile/scripts/android-dev.sh` (see +`.agents/skills/mobile-android-verification/SKILL.md`). + +## 0. Decide: the iOS release it starts + +- [ ] The last commit on the branch, "Android release config (changes the iOS runtime + fingerprint)", holds `app.config.ts`, `eas.json`, `apps/mobile/.gitignore`, + `packages/shared/src/app-identity.ts` and the `assetlinks.json` route. Those files feed the + iOS runtime version. Merging that commit to main makes `deploy.yml` build and submit a new + iOS binary, and installed iOS builds stop taking OTA updates until they update. Merge the + branch without it to ship the Android code dark, then merge it with a planned iOS release. + +## 1. Accounts and keys (a person) + +**Status 2026-10-29:** Superset Inc.'s organization developer account exists: developer name +"Paradev", account ID 8938663520557055179, owned by kiet@superset.sh. The website (superset.sh) is +verified. The organization document (IRS Letter 147C) and Kiet's ID are with Google for review; +the phone check and **Create app** unlock after Google emails kiet@superset.sh. + +Do Google Cloud in both projects that own a `GOOGLE_CLIENT_ID` web client: dev and production. + +### Signing + +- [x] **Upload keystore.** Done 2026-10-29 (EAS, default build credentials). SHA-1 + `D1:FE:00:E3:8F:27:B5:90:28:3D:3A:D0:D5:33:ED:D0:ED:65:BA:F1`, SHA-256 + `01:19:18:27:15:2A:D2:39:1B:FE:BC:D0:AD:A1:2D:89:28:1A:5C:42:04:EA:26:6C:0A:54:E8:47:23:E5:C1:93`. + Was: In `apps/mobile`: `eas credentials -p android`, production profile, + let EAS generate the keystore. Copy its SHA-1 and SHA-256. +- [ ] **Play Console app.** Create the app with package `sh.superset.mobile` and accept Play App + Signing. Copy the app signing key's SHA-1 and SHA-256 from Test and release → App + integrity. + +### Google sign-in (native, Credential Manager) + +- [ ] **Android OAuth clients** for `sh.superset.mobile`, one per signing key, in project + `fair-scout-481221-v0` (it holds both the Web and Web Dev clients). Debug and upload-key + clients created 2026-10-29; the Play App Signing one waits for the Play app. No client secret: + - Debug, dev project only (React Native's shared template keystore): + `5E:8F:16:06:2E:A3:CD:2C:4A:0D:54:78:76:BA:A6:F3:8C:AB:F6:25` + - Upload key SHA-1 (from the keystore step). + - Play App Signing SHA-1 (from the Play Console step). +- [x] **OAuth consent screen** (already live for web sign-in) complete (app name, support email). Without it the account sheet + does not show. +- [x] **`EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID`** Done 2026-10-29: production and preview use the Web + client, development uses Web Dev. Was: set to the project's web client ID in the EAS + `development`, `preview` and `production` environments. Empty means Android uses the + browser flow. Locally, copy `GOOGLE_CLIENT_ID` into it. + +Sign in with Apple is not offered on Android, by decision. GitHub stays browser-based on both +platforms. + +### App Links + +- [ ] Put both SHA-256 values (upload key and Play App Signing key, uppercase, colon-separated) in + `ANDROID_SIGNING_CERT_SHA256` in `packages/shared/src/constants.ts`, and deploy web. Check + on a device: `adb shell pm verify-app-links --re-verify sh.superset.mobile`, then + `adb shell pm get-app-links sh.superset.mobile`. + +### Play submission + +- [ ] **Service account.** In Google Cloud, create a service account with a JSON key; grant it + release permissions on the app in Play Console → Users and permissions. Upload the key with + `eas credentials -p android` (Google Service Account) so `eas submit` needs no key path. +- [ ] **First upload by hand.** Google rejects API uploads until one `.aab` is uploaded manually. + +### Store listing + +The listing lives in `apps/mobile/play-store.config.js`. Its text comes from the App Store listing in +`store.config.js`, and the icon and feature graphic are in `apps/mobile/store/android/`. +`bun apps/mobile/scripts/play-listing.ts` checks it against Play's limits. + +- [x] **Phone screenshots** — 3 drafts taken 2026-10-29 from the review account on production + (Home, workspace sheet, new task). Retake the new-task shot once the Android composer's project + chip stops overlapping the list header. Was: (2 to 8 PNGs) in `apps/mobile/store/android/phone/`. Take them from the + review demo account (`APP_REVIEW_EMAIL`), whose organization has sample sessions, never from + a real account. Use `apps/mobile/scripts/android-dev.sh --shot`. +- [ ] **Push text and images**: + `PLAY_SERVICE_ACCOUNT_KEY=key.json bun apps/mobile/scripts/play-listing.ts --push`. Needs + the service account above. The push code has not run against Play yet. +- [ ] **App content forms** in Play Console, answered in `play-store.config.js`: privacy policy, + app access (demo account from `APP_REVIEW_EMAIL` / `APP_REVIEW_PASSWORD`), ads (none), + target audience (18+), content rating, Data safety. Review the Data safety answers first: + PostHog session replay, Sentry, attached photos, and voice audio sent to OpenAI. +- [ ] **Category and tags**: Productivity; add developer tags in Play Console. + +**Review demo account.** `appreview@superset.sh` (team "App Review", `9617bc8e-…`) had read as +Free since the Oct 20 Autumn cutover, so App Store and Play reviewers would only see the paywall. +Fixed 2026-10-29: Autumn customer created and the Team plan attached with the seat price customized +to $0 (3 seats, 216,000 box GB-minutes, overage billing off, past-due ignored). The demo host +`acme-devbox` and its sample workspaces were already there. + +## 2. Engineering still missing + +### Before a public release + +- [ ] **Query cache too large for Android storage.** A "Row too big to fit into CursorWindow" toast + shows: the persisted query cache is larger than one AsyncStorage row on Android. +- [ ] **Sign-in end to end.** GitHub and Google open their providers, but nobody has finished a + real login and the redirect back through `superset://` on Android. Native Google needs the + Google Cloud steps above first. +- [ ] **Sign-in errors.** GitHub and Google ignore `res.error`, so their failures are silent (shared + with iOS). +- [ ] **Screens not yet checked on a device:** the Page sheets (preview, comments, share), voice + sheets, the PR checks/reviewers sheets, line comment, and `ValueMenu` in share-invite. +- [ ] **CI and EAS workflows.** `.eas/workflows` jobs are iOS only. +- [ ] **Home-screen name.** Android shows `name` ("Superset"); iOS shows the brand name. + +### Composer, after the MVP + +From `plans/20261029-android-composer.md`: dictation (needs `SpeechRecognizer`), slash command +suggestions, session tabs, quick keys and the quick-keys action, paste of images and files, PDF and +document thumbnails, swipe-down to dismiss. + +### Agent card and push + +From `plans/20261029-android-agent-card.md`: the app never asks for notification permission (it +belongs with push registration), "Not updating" is not shown, one tap target per card instead of +per row, and the Android 16 Live Update features are untested (the AVD is API 33). Push is planned +there and not implemented on either platform. diff --git a/apps/mobile/play-store.config.js b/apps/mobile/play-store.config.js new file mode 100644 index 00000000000..f3228d89b88 --- /dev/null +++ b/apps/mobile/play-store.config.js @@ -0,0 +1,114 @@ +// The Google Play listing. Text comes from store.config.js (the App Store +// listing) so the two stores say the same thing; only Play's own fields live +// here. Pushed by scripts/play-listing.ts. The Data safety, content rating and +// app access answers have no API: enter them in Play Console from this file. + +const apple = require("./store.config.js").apple; +const appStore = apple.info["en-US"]; + +const shortDescription = + "Start, follow and review your coding agents from your phone."; + +// The App Store review notes argue against Apple's guidelines; Play reviewers +// need only the access steps. +const appAccessInstructions = [ + "On the sign-in screen tap 'Sign in with email' and use the demo account above.", + "Home lists workspaces and sessions. Open a session to read the agent transcript, chat with it, view the files it changed, and open the terminal.", + "Settings is reachable from the organization name in the top-left; account deletion is under Danger Zone.", + "Free accounts see a screen explaining Pro is required; the demo account's organization has Pro.", +].join("\n"); + +module.exports = { + packageName: "sh.superset.mobile", + defaultLanguage: "en-US", + listings: { + "en-US": { + title: appStore.title, + shortDescription, + fullDescription: appStore.description, + }, + }, + details: { + contactEmail: apple.review.email, + contactWebsite: appStore.supportUrl, + contactPhone: apple.review.phone, + }, + images: { + icon: "store/android/icon-512.png", + featureGraphic: "store/android/feature-graphic.png", + phoneScreenshots: "store/android/phone", + }, + + // Play Console > Policy > App content. Entered by hand. + privacyPolicyUrl: appStore.privacyPolicyUrl, + appCategory: "Productivity", + appAccess: { + restricted: true, + username: process.env.APP_REVIEW_EMAIL ?? "", + password: process.env.APP_REVIEW_PASSWORD ?? "", + instructions: appAccessInstructions, + }, + ads: false, + targetAudience: "18 and over", + contentRating: { + category: "Utility, Productivity, Communication, or Other", + violence: false, + sexualContent: false, + language: false, + controlledSubstances: false, + gambling: false, + // Users chat with their own AI agents and teammates' sessions. + userInteraction: true, + sharesLocation: false, + digitalPurchases: false, + }, + // What lib/posthog, lib/sentry, auth, attachments and voice send. + dataSafety: { + encryptedInTransit: true, + accountDeletionInApp: true, + accountDeletionUrl: "https://paradev.com/privacy", + collected: [ + { + type: "Personal info: Name, Email address, User IDs", + purposes: ["App functionality", "Account management", "Analytics"], + shared: false, + optional: false, + }, + { + type: "App activity: App interactions, Other user-generated content", + purposes: ["App functionality", "Analytics"], + shared: false, + optional: false, + note: "PostHog events and session replay; messages sent to agents", + }, + { + type: "Photos and videos: Photos", + purposes: ["App functionality"], + shared: false, + optional: true, + note: "Only images the user attaches to a message", + }, + { + type: "Audio: Voice or sound recordings", + purposes: ["App functionality"], + shared: true, + optional: true, + note: "Voice mode streams microphone audio to OpenAI's Realtime API; not stored by us", + }, + { + type: "App info and performance: Crash logs, Diagnostics", + purposes: ["Analytics"], + shared: false, + optional: false, + note: "Sentry, sendDefaultPii off", + }, + { + type: "Device or other IDs", + purposes: ["Analytics"], + shared: false, + optional: false, + note: "PostHog anonymous and device IDs", + }, + ], + }, +}; diff --git a/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx b/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx index 852d226a766..5b99abc9644 100644 --- a/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx +++ b/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx @@ -1,13 +1,10 @@ import { Trans } from "@lingui/react/macro"; import { BRAND } from "@superset/shared/brand"; import { COMPANY } from "@superset/shared/constants"; -import * as AppleAuthentication from "expo-apple-authentication"; -import * as Crypto from "expo-crypto"; import { useState } from "react"; import { Image, View } from "react-native"; import { Text } from "@/components/ui/text"; -import { signIn } from "@/lib/auth/client"; import { env } from "@/lib/env"; import { errorCopy } from "@/lib/errors"; import { openUrl } from "@/lib/open-url"; @@ -16,6 +13,16 @@ import { DevSignInOptions } from "./components/DevSignInOptions"; import { EmailSignInLink } from "./components/EmailSignInLink"; import type { SocialProvider } from "./components/SocialButton"; import { SocialButton } from "./components/SocialButton"; +import { signInInBrowser } from "./utils/signInInBrowser"; +import { signInWithApple } from "./utils/signInWithApple"; +import { signInWithGoogle } from "./utils/signInWithGoogle"; +import { SOCIAL_PROVIDERS } from "./utils/socialProviders"; + +const SIGN_IN: Record Promise> = { + apple: signInWithApple, + github: () => signInInBrowser("github"), + google: signInWithGoogle, +}; export function SignInScreen() { const appName = BRAND.NAME; @@ -24,61 +31,9 @@ export function SignInScreen() { const handleSignIn = async (provider: SocialProvider) => { setError(null); try { - await signIn.social({ - provider, - callbackURL: "/", - }); - } catch (err) { - console.error("[sign-in] Error:", err); - setError(errorCopy(err)); - } - }; - - const handleAppleSignIn = async () => { - setError(null); - try { - // Apple puts SHA256(nonce) in the identity token; the server gets the - // raw nonce and better-auth compares against the hashed claim. - const rawNonce = Crypto.randomUUID(); - const hashedNonce = await Crypto.digestStringAsync( - Crypto.CryptoDigestAlgorithm.SHA256, - rawNonce, - ); - const credential = await AppleAuthentication.signInAsync({ - requestedScopes: [ - AppleAuthentication.AppleAuthenticationScope.FULL_NAME, - AppleAuthentication.AppleAuthenticationScope.EMAIL, - ], - nonce: hashedNonce, - }); - if (!credential.identityToken) { - throw new Error("Apple did not return an identity token"); - } - await signIn.social({ - provider: "apple", - idToken: { - token: credential.identityToken, - nonce: rawNonce, - // Apple only sends name/email on the very first authorization - user: { - name: { - firstName: credential.fullName?.givenName ?? undefined, - lastName: credential.fullName?.familyName ?? undefined, - }, - email: credential.email ?? undefined, - }, - }, - callbackURL: "/", - }); + await SIGN_IN[provider](); } catch (err) { - if ( - err instanceof Error && - "code" in err && - err.code === "ERR_REQUEST_CANCELED" - ) { - return; - } - console.error("[sign-in] Apple error:", err); + console.error(`[sign-in] ${provider} error:`, err); setError(errorCopy(err)); } }; @@ -100,21 +55,14 @@ export function SignInScreen() { - - handleSignIn("github")} - className="w-4/5 max-w-sm" - /> - handleSignIn("google")} - className="w-4/5 max-w-sm" - /> + {SOCIAL_PROVIDERS.map((provider) => ( + handleSignIn(provider)} + className="w-4/5 max-w-sm" + /> + ))} {(__DEV__ || env.EXPO_PUBLIC_E2E === "1") && } diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/index.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/index.ts new file mode 100644 index 00000000000..9140a17116c --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/index.ts @@ -0,0 +1 @@ +export { signInInBrowser } from "./signInInBrowser"; diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/signInInBrowser.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/signInInBrowser.ts new file mode 100644 index 00000000000..867b0117c9b --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInInBrowser/signInInBrowser.ts @@ -0,0 +1,7 @@ +import { signIn } from "@/lib/auth/client"; + +export async function signInInBrowser( + provider: "github" | "google", +): Promise { + await signIn.social({ provider, callbackURL: "/" }); +} diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/index.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/index.ts new file mode 100644 index 00000000000..cc78aeb0bc8 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/index.ts @@ -0,0 +1 @@ +export { signInWithApple } from "./signInWithApple"; diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ios.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ios.ts new file mode 100644 index 00000000000..f86bfa0dea0 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ios.ts @@ -0,0 +1,52 @@ +import * as AppleAuthentication from "expo-apple-authentication"; +import * as Crypto from "expo-crypto"; +import { signIn } from "@/lib/auth/client"; + +/** Resolves without signing in when the user cancels the system sheet. */ +export async function signInWithApple(): Promise { + // Apple puts SHA256(nonce) in the identity token; the server gets the + // raw nonce and better-auth compares against the hashed claim. + 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 (err) { + if ( + err instanceof Error && + "code" in err && + err.code === "ERR_REQUEST_CANCELED" + ) { + return; + } + throw err; + } + if (!credential.identityToken) { + throw new Error("Apple did not return an identity token"); + } + await signIn.social({ + provider: "apple", + idToken: { + token: credential.identityToken, + nonce: rawNonce, + // Apple only sends name/email on the very first authorization + user: { + name: { + firstName: credential.fullName?.givenName ?? undefined, + lastName: credential.fullName?.familyName ?? undefined, + }, + email: credential.email ?? undefined, + }, + }, + callbackURL: "/", + }); +} diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ts new file mode 100644 index 00000000000..3029ccd9dd2 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithApple/signInWithApple.ts @@ -0,0 +1,2 @@ +// Sign in with Apple is offered on iOS only, so nothing calls this elsewhere. +export async function signInWithApple(): Promise {} diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/index.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/index.ts new file mode 100644 index 00000000000..fcd0c3c06c9 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/index.ts @@ -0,0 +1 @@ +export { signInWithGoogle } from "./signInWithGoogle"; diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/signInWithGoogle.android.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/signInWithGoogle.android.ts new file mode 100644 index 00000000000..23346fb9a9f --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/signInWithGoogle.android.ts @@ -0,0 +1,31 @@ +import { GoogleCredential } from "@superset/google-credential"; +import * as Crypto from "expo-crypto"; +import { signIn } from "@/lib/auth/client"; +import { env } from "@/lib/env"; +import { signInInBrowser } from "../signInInBrowser"; + +/** + * The Credential Manager account sheet. Falls back to the browser when the + * device has no Google account or the app has no web client ID to ask for. + */ +export async function signInWithGoogle(): Promise { + const serverClientId = env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID; + if (!serverClientId) return signInInBrowser("google"); + + const nonce = Crypto.randomUUID(); + let token: string; + try { + token = await GoogleCredential.getIdToken(serverClientId, nonce); + } catch (err) { + const code = (err as { code?: unknown } | null)?.code; + if (code === "ERR_CANCELED") return; + if (code === "ERR_NO_CREDENTIAL") return signInInBrowser("google"); + throw err; + } + const res = await signIn.social({ + provider: "google", + idToken: { token, nonce }, + callbackURL: "/", + }); + if (res.error) throw new Error(res.error.message); +} diff --git a/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/signInWithGoogle.ts b/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/signInWithGoogle.ts new file mode 100644 index 00000000000..798bf8e6f72 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/signInWithGoogle/signInWithGoogle.ts @@ -0,0 +1,5 @@ +import { signInInBrowser } from "../signInInBrowser"; + +export async function signInWithGoogle(): Promise { + await signInInBrowser("google"); +} diff --git a/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/index.ts b/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/index.ts new file mode 100644 index 00000000000..69e1ac9b2ca --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/index.ts @@ -0,0 +1 @@ +export { SOCIAL_PROVIDERS } from "./socialProviders"; diff --git a/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/socialProviders.ios.ts b/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/socialProviders.ios.ts new file mode 100644 index 00000000000..de0990bb99a --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/socialProviders.ios.ts @@ -0,0 +1,7 @@ +import type { SocialProvider } from "../../components/SocialButton"; + +export const SOCIAL_PROVIDERS: readonly SocialProvider[] = [ + "apple", + "github", + "google", +]; diff --git a/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/socialProviders.ts b/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/socialProviders.ts new file mode 100644 index 00000000000..845782c62ea --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/utils/socialProviders/socialProviders.ts @@ -0,0 +1,4 @@ +import type { SocialProvider } from "../../components/SocialButton"; + +// Sign in with Apple is offered on iOS only. +export const SOCIAL_PROVIDERS: readonly SocialProvider[] = ["github", "google"]; diff --git a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx index c52589e2b2a..35dec3d0e32 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx @@ -40,6 +40,7 @@ import { useOrgHosts } from "@/hooks/useOrgHosts"; import { useReadableInset } from "@/hooks/useReadableInset"; import { useSession } from "@/lib/auth/client"; import { errorCopy } from "@/lib/errors"; +import { contentScrollsUnderHeader } from "@/lib/navigation"; import { useVoiceSession } from "@/lib/voice/useVoiceSession"; import { useVoiceActive } from "@/lib/voice/voiceStore"; import { useCloudFilters } from "@/screens/(authenticated)/(home)/hooks/useCloudFilters"; @@ -773,7 +774,7 @@ export function HomeScreen() { offline — its list isn't shown, so there is nothing to search. */} ( - {isLiquidGlassAvailable() ? ( - - {label} - - ) : ( - {label} - )} + + {label} + ); } diff --git a/apps/mobile/screens/(authenticated)/components/ShareAccess/ShareInviteScreen/ShareInviteScreen.tsx b/apps/mobile/screens/(authenticated)/components/ShareAccess/ShareInviteScreen/ShareInviteScreen.tsx index 000813b47d7..353d9e3f083 100644 --- a/apps/mobile/screens/(authenticated)/components/ShareAccess/ShareInviteScreen/ShareInviteScreen.tsx +++ b/apps/mobile/screens/(authenticated)/components/ShareAccess/ShareInviteScreen/ShareInviteScreen.tsx @@ -1,4 +1,3 @@ -import { Button } from "@expo/ui/swift-ui"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { @@ -147,19 +146,14 @@ export function ShareInviteScreen({ label={current?.label} a11yLabel={t({ message: "Access for this person" })} size={14} - chevron="chevron.up.chevron.down" - > - {roles.map((option) => ( -