diff --git a/.changeset/mobile-store-readiness-polish.md b/.changeset/mobile-store-readiness-polish.md new file mode 100644 index 0000000000..27ee38a1f4 --- /dev/null +++ b/.changeset/mobile-store-readiness-polish.md @@ -0,0 +1,5 @@ +--- +'@electric-ax/agents-mobile': patch +--- + +Polish the mobile app for App Store / Play review: declare the iOS privacy manifest for required-reason APIs (AsyncStorage + Sentry), drop the unused microphone permission and block legacy Android storage permissions, add a splash screen, ship an opaque iOS icon and a monochrome Android adaptive-icon layer, add `expo-system-ui` so `userInterfaceStyle` applies on Android, wrap the app in a recoverable error boundary, add a timeout escape hatch to the OAuth callback, and keep auth diagnostics out of production logs. diff --git a/packages/agents-mobile/app.config.ts b/packages/agents-mobile/app.config.ts index 93bdd47dfa..5a2ba9ac5a 100644 --- a/packages/agents-mobile/app.config.ts +++ b/packages/agents-mobile/app.config.ts @@ -20,6 +20,7 @@ export default ({ config }: ConfigContext): ExpoConfig => slug: `agents-mobile`, owner: `electric-ax`, scheme: `electric-agents`, + description: `Mobile client for Electric Agents — connect to your agents servers and chat with running agents.`, version: packageJson.version, runtimeVersion: packageJson.version, orientation: `portrait`, @@ -29,6 +30,21 @@ export default ({ config }: ConfigContext): ExpoConfig => plugins: [ `expo-router`, `expo-web-browser`, + // Branded launch screen so cold start shows the logo on the + // app's dark background instead of a blank white flash. + [ + `expo-splash-screen`, + { + backgroundColor: `#101217`, + image: `./assets/splash-icon.png`, + imageWidth: 200, + resizeMode: `contain`, + dark: { + backgroundColor: `#101217`, + image: `./assets/splash-icon.png`, + }, + }, + ], // The chat WebView (Expo DOM / streamdown) ships regex lookbehind, // which JavaScriptCore only parses on iOS 16.4+. Below that the whole // DOM bundle fails to parse and the chat renders blank. @@ -45,8 +61,11 @@ export default ({ config }: ConfigContext): ExpoConfig => [ `expo-image-picker`, { - photosPermission: `Allow Electric Agents to attach photos to messages.`, - cameraPermission: `Allow Electric Agents to take a photo to attach to a message.`, + photosPermission: `Electric Agents accesses your photo library so you can attach an existing photo to a chat message.`, + cameraPermission: `Electric Agents uses your camera so you can take a photo to attach to a chat message.`, + // The app never records audio; `false` drops the RECORD_AUDIO + // (Android) + NSMicrophoneUsageDescription (iOS) the plugin adds. + microphonePermission: false, }, ], ], @@ -59,14 +78,72 @@ export default ({ config }: ConfigContext): ExpoConfig => ITSAppUsesNonExemptEncryption: false, }, supportsTablet: true, + // Apple rejects uploads (ITMS-91053) that call "required reason" + // APIs without declaring them, and doesn't reliably read + // statically-linked pods' own manifests — so declare app-level. + // UserDefaults + file timestamp come from AsyncStorage / RN core; + // system boot time + disk space from Sentry. + privacyManifests: { + NSPrivacyTracking: false, + NSPrivacyTrackingDomains: [], + NSPrivacyCollectedDataTypes: [ + { + NSPrivacyCollectedDataType: `NSPrivacyCollectedDataTypeCrashData`, + NSPrivacyCollectedDataTypeLinked: false, + NSPrivacyCollectedDataTypeTracking: false, + NSPrivacyCollectedDataTypePurposes: [ + `NSPrivacyCollectedDataTypePurposeAppFunctionality`, + ], + }, + { + NSPrivacyCollectedDataType: `NSPrivacyCollectedDataTypePerformanceData`, + NSPrivacyCollectedDataTypeLinked: false, + NSPrivacyCollectedDataTypeTracking: false, + NSPrivacyCollectedDataTypePurposes: [ + `NSPrivacyCollectedDataTypePurposeAppFunctionality`, + ], + }, + ], + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: `NSPrivacyAccessedAPICategoryUserDefaults`, + NSPrivacyAccessedAPITypeReasons: [`CA92.1`], + }, + { + NSPrivacyAccessedAPIType: `NSPrivacyAccessedAPICategoryFileTimestamp`, + NSPrivacyAccessedAPITypeReasons: [`C617.1`], + }, + { + NSPrivacyAccessedAPIType: `NSPrivacyAccessedAPICategorySystemBootTime`, + NSPrivacyAccessedAPITypeReasons: [`35F9.1`], + }, + { + NSPrivacyAccessedAPIType: `NSPrivacyAccessedAPICategoryDiskSpace`, + NSPrivacyAccessedAPITypeReasons: [`E174.1`], + }, + ], + }, }, android: { ...config.android, package: applicationId, versionCode, edgeToEdgeEnabled: true, + // expo-image-picker declares these, but the app uses the system + // photo picker (content URIs) and never records audio, so block + // them to keep the AAB permission list clean. WRITE_EXTERNAL_STORAGE + // is deliberately left in place: image-picker's pre-Android-10 + // (API < 29) camera path hard-requires it, so blocking it breaks + // "take a photo" on Android 7–9. + blockedPermissions: [ + `android.permission.RECORD_AUDIO`, + `android.permission.READ_EXTERNAL_STORAGE`, + ], adaptiveIcon: { foregroundImage: `./assets/adaptive-icon.png`, + // Android 13+ themed (monochrome) icons — white silhouette + // the launcher tints to match the user's wallpaper. + monochromeImage: `./assets/adaptive-icon-monochrome.png`, backgroundColor: `#101217`, }, }, diff --git a/packages/agents-mobile/app/_layout.tsx b/packages/agents-mobile/app/_layout.tsx index 9a32273b2b..3e63334858 100644 --- a/packages/agents-mobile/app/_layout.tsx +++ b/packages/agents-mobile/app/_layout.tsx @@ -1,12 +1,13 @@ import { Redirect, Stack, usePathname } from 'expo-router' import { StatusBar } from 'expo-status-bar' -import { ActivityIndicator, StyleSheet, View } from 'react-native' +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' import { GestureHandlerRootView } from 'react-native-gesture-handler' import { SafeAreaProvider, initialWindowMetrics, } from 'react-native-safe-area-context' import { AgentsProvider } from '../src/lib/AgentsProvider' +import { PrimaryButton } from '../src/components/PrimaryButton' import { ThemeProvider, useColorSchemeMode, @@ -18,6 +19,7 @@ import { } from '../src/lib/MobileAppState' import { CloudAuthProvider } from '../src/lib/CloudAuthContext' import { isCallbackUrl } from '../src/lib/cloudAuth' +import { fontSize, lineHeight, spacing } from '../src/lib/theme' import { Sentry, initSentry } from '../src/lib/sentry' // Initialize early so startup crashes are captured (no-op in dev). @@ -28,17 +30,48 @@ function RootLayout(): React.ReactElement { - - - - - + {/* Inside ThemeProvider so the themed fallback turns an uncaught + render error into a recoverable screen, not a blank crash. */} + ( + + )} + > + + + + + + ) } +function RootErrorFallback({ + onRetry, +}: { + onRetry: () => void +}): React.ReactElement { + const tokens = useTokens() + return ( + + + + Something went wrong + + + The app hit an unexpected error. You can try again — if it keeps + happening, reopen the app. + + + + + + ) +} + export default Sentry.wrap(RootLayout) function RootNavigator(): React.ReactElement { @@ -115,4 +148,26 @@ const styles = StyleSheet.create({ alignItems: `center`, justifyContent: `center`, }, + fallback: { + flex: 1, + alignItems: `center`, + justifyContent: `center`, + paddingHorizontal: spacing.xl, + gap: spacing.md, + }, + fallbackTitle: { + fontSize: fontSize.xl, + fontWeight: `600`, + lineHeight: lineHeight.xl, + textAlign: `center`, + }, + fallbackBody: { + fontSize: fontSize.sm, + lineHeight: lineHeight.sm, + textAlign: `center`, + }, + fallbackAction: { + alignSelf: `stretch`, + marginTop: spacing.sm, + }, }) diff --git a/packages/agents-mobile/app/oauth/callback.tsx b/packages/agents-mobile/app/oauth/callback.tsx index bcd64bdf08..af642e4552 100644 --- a/packages/agents-mobile/app/oauth/callback.tsx +++ b/packages/agents-mobile/app/oauth/callback.tsx @@ -1,12 +1,13 @@ import { useEffect, useMemo, useRef, useState } from 'react' import * as Linking from 'expo-linking' -import { Redirect, useLocalSearchParams } from 'expo-router' +import { Redirect, useLocalSearchParams, useRouter } from 'expo-router' import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' import { cloudAuth, isCallbackUrl, type CloudAuthState, } from '../../src/lib/cloudAuth' +import { PrimaryButton } from '../../src/components/PrimaryButton' import { useMobileAppState } from '../../src/lib/MobileAppState' import { useTokens } from '../../src/lib/ThemeProvider' import { fontSize, lineHeight, spacing } from '../../src/lib/theme' @@ -50,6 +51,7 @@ type RouteStatus = export default function OAuthCallbackRoute(): React.ReactElement { const params = useLocalSearchParams() + const router = useRouter() const tokens = useTokens() const styles = useMemo(() => createStyles(tokens), [tokens]) const { serverUrl, onboardingDismissed } = useMobileAppState() @@ -63,6 +65,17 @@ export default function OAuthCallbackRoute(): React.ReactElement { deriveStatus(cloudAuth.getState()) ) + // Escape hatch: a cold start onto `/oauth/callback` with no pending + // request (or a redirect that never arrives) would otherwise spin on + // "Finishing sign-in…" forever. After a grace period, surface a way + // back to the sign-in screen. + const [tookTooLong, setTookTooLong] = useState(false) + useEffect(() => { + if (status.kind !== `working`) return + const timer = setTimeout(() => setTookTooLong(true), 10_000) + return () => clearTimeout(timer) + }, [status.kind]) + useEffect(() => { // Sync once on mount in case the state changed between the // `useState` initializer and this effect attaching (very narrow @@ -137,6 +150,18 @@ export default function OAuthCallbackRoute(): React.ReactElement { Finishing sign-in… + {tookTooLong ? ( + + + This is taking longer than expected. + + router.replace(`/onboarding`)} + /> + + ) : null} ) } @@ -191,5 +216,17 @@ function createStyles(tokens: Tokens) { lineHeight: lineHeight.base, textAlign: `center`, }, + escape: { + alignSelf: `stretch`, + alignItems: `center`, + gap: spacing.sm, + marginTop: spacing.lg, + }, + subtext: { + color: tokens.text3, + fontSize: fontSize.sm, + lineHeight: lineHeight.sm, + textAlign: `center`, + }, }) } diff --git a/packages/agents-mobile/assets/adaptive-icon-monochrome.png b/packages/agents-mobile/assets/adaptive-icon-monochrome.png new file mode 100644 index 0000000000..f66902f11e Binary files /dev/null and b/packages/agents-mobile/assets/adaptive-icon-monochrome.png differ diff --git a/packages/agents-mobile/assets/icon.png b/packages/agents-mobile/assets/icon.png index 0833adefb7..1bdf93132c 100644 Binary files a/packages/agents-mobile/assets/icon.png and b/packages/agents-mobile/assets/icon.png differ diff --git a/packages/agents-mobile/assets/splash-icon.png b/packages/agents-mobile/assets/splash-icon.png new file mode 100644 index 0000000000..6795ff511e Binary files /dev/null and b/packages/agents-mobile/assets/splash-icon.png differ diff --git a/packages/agents-mobile/package.json b/packages/agents-mobile/package.json index 0c9bdb6218..7ede496a23 100644 --- a/packages/agents-mobile/package.json +++ b/packages/agents-mobile/package.json @@ -33,7 +33,9 @@ "expo-image-picker": "~17.0.11", "expo-linking": "~8.0.12", "expo-router": "~6.0.24", + "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", + "expo-system-ui": "~6.0.9", "expo-web-browser": "~15.0.11", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/packages/agents-mobile/src/lib/CloudAuthContext.tsx b/packages/agents-mobile/src/lib/CloudAuthContext.tsx index 200067359e..1aae5e5a62 100644 --- a/packages/agents-mobile/src/lib/CloudAuthContext.tsx +++ b/packages/agents-mobile/src/lib/CloudAuthContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useMemo, useState } from 'react' import * as Linking from 'expo-linking' import { cloudAuth, + devWarn, getCloudBaseUrl, isCallbackUrl, type CloudAuthProvider, @@ -68,7 +69,7 @@ export function CloudAuthProvider({ } }) .catch((err) => { - console.warn(`[agents-mobile] cloud-auth getInitialURL failed:`, err) + devWarn(`[agents-mobile] cloud-auth getInitialURL failed:`, err) }) return () => subscription.remove() }, []) diff --git a/packages/agents-mobile/src/lib/cloudAuth.ts b/packages/agents-mobile/src/lib/cloudAuth.ts index 73674baeed..7999dea269 100644 --- a/packages/agents-mobile/src/lib/cloudAuth.ts +++ b/packages/agents-mobile/src/lib/cloudAuth.ts @@ -24,6 +24,12 @@ export { getCloudServiceIdFromServerUrl } from './cloudAgentUrls' * param the client sent. */ +// Auth diagnostics help in development but shouldn't write token-exchange +// / HTTP-status details to production device logs (logcat / Console.app). +export const devWarn = (...args: Array): void => { + if (__DEV__) console.warn(...args) +} + export type CloudAuthProvider = `github` | `google` export type CloudAuthStatus = @@ -577,11 +583,11 @@ export class CloudAuth { body: `{}`, }) } catch (err) { - console.warn(`[agents-mobile] cloud-auth: getTokenForAgents fetch:`, err) + devWarn(`[agents-mobile] cloud-auth: getTokenForAgents fetch:`, err) return null } if (!res.ok) { - console.warn( + devWarn( `[agents-mobile] cloud-auth: getTokenForAgents returned ${res.status}` ) return null @@ -617,25 +623,25 @@ export class CloudAuth { body: `{"json":{}}`, }) } catch (err) { - console.warn(`[agents-mobile] cloud-auth: whoami fetch failed:`, err) + devWarn(`[agents-mobile] cloud-auth: whoami fetch failed:`, err) return } if (res.status === 401 || res.status === 403) { - console.warn( + devWarn( `[agents-mobile] cloud-auth: whoami returned ${res.status}; signing out` ) await this.signOut() return } if (!res.ok) { - console.warn(`[agents-mobile] cloud-auth: whoami returned ${res.status}`) + devWarn(`[agents-mobile] cloud-auth: whoami returned ${res.status}`) return } let body: unknown try { body = await res.json() } catch (err) { - console.warn(`[agents-mobile] cloud-auth: whoami body parse:`, err) + devWarn(`[agents-mobile] cloud-auth: whoami body parse:`, err) return } const result = parseWhoamiUserResponse(body) @@ -709,7 +715,7 @@ export class CloudAuth { try { listener(next) } catch (err) { - console.warn(`[agents-mobile] cloud-auth listener threw:`, err) + devWarn(`[agents-mobile] cloud-auth listener threw:`, err) } } } diff --git a/packages/agents-mobile/src/screens/AccountScreen.tsx b/packages/agents-mobile/src/screens/AccountScreen.tsx index 53df9c6390..b27059aeb2 100644 --- a/packages/agents-mobile/src/screens/AccountScreen.tsx +++ b/packages/agents-mobile/src/screens/AccountScreen.tsx @@ -9,7 +9,7 @@ import { useCloudAuth } from '../lib/CloudAuthContext' import { fontSize, lineHeight, radii, spacing } from '../lib/theme' import type { Tokens } from '../lib/theme' -const DELETE_ACCOUNT_URL = `https://electric-sql.com/about/legal/delete-account` +const DELETE_ACCOUNT_URL = `https://electric.ax/about/legal/delete-account` /** * Settings → Account screen. Mirrors the desktop's `AccountPage` — diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a71eea6119..01db79712b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1746,9 +1746,15 @@ importers: expo-router: specifier: ~6.0.24 version: 6.0.24(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.12)(expo@54.0.35)(react-dom@19.1.0(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-splash-screen: + specifier: ~31.0.13 + version: 31.0.13(expo@54.0.35)(typescript@5.9.3) expo-status-bar: specifier: ~3.0.9 version: 3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-system-ui: + specifier: ~6.0.9 + version: 6.0.9(expo@54.0.35)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) expo-web-browser: specifier: ~15.0.11 version: 15.0.11(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) @@ -14307,6 +14313,11 @@ packages: resolution: {integrity: sha512-mcmyML3oXcqFUXUxtdtCL1O00ztNI2v76d+MdniXRUgHNxIcHZ05zo+DqBaOOT6LQnPk4vA4YHqQl7iGUfRb3g==} engines: {node: '>=20.16.0'} + expo-splash-screen@31.0.13: + resolution: {integrity: sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA==} + peerDependencies: + expo: '*' + expo-status-bar@2.2.3: resolution: {integrity: sha512-+c8R3AESBoduunxTJ8353SqKAKpxL6DvcD8VKBuh81zzJyUUbfB4CVjr1GufSJEKsMzNPXZU+HJwXx7Xh7lx8Q==} peerDependencies: @@ -14319,6 +14330,16 @@ packages: react: '*' react-native: '*' + expo-system-ui@6.0.9: + resolution: {integrity: sha512-eQTYGzw1V4RYiYHL9xDLYID3Wsec2aZS+ypEssmF64D38aDrqbDgz1a2MSlHLQp2jHXSs3FvojhZ9FVela1Zcg==} + peerDependencies: + expo: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + expo-web-browser@15.0.11: resolution: {integrity: sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==} peerDependencies: @@ -37228,6 +37249,14 @@ snapshots: expo-server@1.0.7: {} + expo-splash-screen@31.0.13(expo@54.0.35)(typescript@5.9.3): + dependencies: + '@expo/prebuild-config': 54.0.8(expo@54.0.35)(typescript@5.9.3) + expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + - typescript + expo-status-bar@2.2.3(react-native@0.80.1(@babel/core@7.28.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 @@ -37241,6 +37270,17 @@ snapshots: react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) react-native-is-edge-to-edge: 1.3.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-system-ui@6.0.9(expo@54.0.35)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): + dependencies: + '@react-native/normalize-colors': 0.81.5 + debug: 4.4.3 + expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - supports-color + expo-web-browser@15.0.11(expo@54.0.35)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)): dependencies: expo: 54.0.35(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.24)(react-native-webview@13.15.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3)