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)