Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mobile-store-readiness-polish.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 79 additions & 2 deletions packages/agents-mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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.
Expand All @@ -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,
},
],
],
Expand All @@ -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`,
},
},
Expand Down
67 changes: 61 additions & 6 deletions packages/agents-mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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).
Expand All @@ -28,17 +30,48 @@ function RootLayout(): React.ReactElement {
<GestureHandlerRootView style={styles.root}>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<ThemeProvider>
<MobileAppStateProvider>
<CloudAuthProvider>
<RootNavigator />
</CloudAuthProvider>
</MobileAppStateProvider>
{/* Inside ThemeProvider so the themed fallback turns an uncaught
render error into a recoverable screen, not a blank crash. */}
<Sentry.ErrorBoundary
fallback={({ resetError }) => (
<RootErrorFallback onRetry={resetError} />
)}
>
<MobileAppStateProvider>
<CloudAuthProvider>
<RootNavigator />
</CloudAuthProvider>
</MobileAppStateProvider>
</Sentry.ErrorBoundary>
</ThemeProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
)
}

function RootErrorFallback({
onRetry,
}: {
onRetry: () => void
}): React.ReactElement {
const tokens = useTokens()
return (
<View style={[styles.fallback, { backgroundColor: tokens.bg }]}>
<StatusBar style="light" />
<Text style={[styles.fallbackTitle, { color: tokens.text1 }]}>
Something went wrong
</Text>
<Text style={[styles.fallbackBody, { color: tokens.text2 }]}>
The app hit an unexpected error. You can try again — if it keeps
happening, reopen the app.
</Text>
<View style={styles.fallbackAction}>
<PrimaryButton title="Try again" onPress={onRetry} />
</View>
</View>
)
}

export default Sentry.wrap(RootLayout)

function RootNavigator(): React.ReactElement {
Expand Down Expand Up @@ -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,
},
})
39 changes: 38 additions & 1 deletion packages/agents-mobile/app/oauth/callback.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -137,6 +150,18 @@ export default function OAuthCallbackRoute(): React.ReactElement {
<View style={styles.root}>
<ActivityIndicator color={tokens.accent11} />
<Text style={styles.text}>Finishing sign-in…</Text>
{tookTooLong ? (
<View style={styles.escape}>
<Text style={styles.subtext}>
This is taking longer than expected.
</Text>
<PrimaryButton
title="Back to sign-in"
variant="soft"
onPress={() => router.replace(`/onboarding`)}
/>
</View>
) : null}
</View>
)
}
Expand Down Expand Up @@ -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`,
},
})
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packages/agents-mobile/assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packages/agents-mobile/assets/splash-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions packages/agents-mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/agents-mobile/src/lib/CloudAuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
}, [])
Expand Down
20 changes: 13 additions & 7 deletions packages/agents-mobile/src/lib/cloudAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>): void => {
if (__DEV__) console.warn(...args)
}

export type CloudAuthProvider = `github` | `google`

export type CloudAuthStatus =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Loading
Loading