Files
orca/mobile/app/_layout.tsx
T
OrcaWinandOrcaWin 12a7d4c13a feat(mobile): localization infrastructure and language setting (#35399)
* feat(mobile): add UI localization infrastructure

Mobile follows the phone's language (or an in-app choice under Settings > Appearance)
across the same 11 languages as desktop, switching live like the theme does.

- i18next with desktop's translate(key, fallback, options) call shape; catalogs in
  mobile/src/i18n/locales load lazily, English fallbacks stay inline
- Hermes lacks Intl.PluralRules: load the FormatJS polyfill with the 11 languages' rules
- OTA pages get the shell's language through an optional bridge init field
- iOS permission prompts localized; iOS and Android 13+ per-app language pickers
- verify:mobile-localization and a coverage ratchet run in lint, check:changed and CI

* fix(mobile): mount pages after the newest language request; tighten localization gates

- Wait for the latest page language activation, not a superseded one
- Keep literal fallbacks when a key also has a dynamic one
- Count headerTitle as visible copy; select the mobile check when the runtime catalog generator changes

* feat(mobile): repaint themed components on language changes

Components that read the theme already subscribe for repaint; a language switch
repaints them too, so labels built by helpers during render follow it. RPC
recordings load the real i18next and JSON catalogs.

* fix(mobile): scope the translate() gate to render bodies and memos; prune stale keys on sync

Handlers and effects may call translate() when they run. sync:mobile-localization now
removes keys no code references from every catalog.

* fix(mobile): use node: imports in the locale config plugin; pass uiLocale in bridge hook test

* chore(mobile): refresh the localization coverage baseline

* fix(mobile): follow render callbacks in the translate() gate; keep used plural siblings on prune

Also let anonymous default-exported screens count as components, flag any plural
family a locale started, and mark JSON modules as ES modules in the recording loader.

* fix(mobile): treat async and named local callbacks as handlers in the translate() gate

* chore(mobile): refresh the localization coverage baseline

* chore(mobile): refresh the localization coverage baseline

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-28 17:14:05 -07:00

254 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { startAndroidForegroundPushPresentation } from '../src/notifications/android-foreground-push'
import { registerPushDismissalTask } from '../src/notifications/push-background-dismissal'
import { readNativeNotificationData } from '../src/notifications/native-notification-data'
import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy'
import { useCallback, useEffect, useRef } from 'react'
import { View } from 'react-native'
import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import * as SplashScreen from 'expo-splash-screen'
import * as Notifications from 'expo-notifications'
import * as Linking from 'expo-linking'
import { colors } from '../src/theme/mobile-theme'
import { startMobileAppearance } from '../src/theme/mobile-appearance'
import { themedStyleSheet } from '../src/theme/themed-styles'
import { useMobileColorScheme } from '../src/theme/use-mobile-color-scheme'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { RpcClientProvider } from '../src/transport/client-context'
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
import {
isRemotePushTrigger,
pushNotificationRouteData,
foregroundNotificationBehavior
} from '../src/notifications/push-receive'
import { startPushTokenSync } from '../src/notifications/push-registration'
import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel'
import { loadHostCatalog } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
import { appUpdateChecker } from '../src/app-update/app-update-runtime'
import { SideSafeArea } from '../src/layout/side-safe-area'
import { useFollowDeviceUiLocale } from '../src/i18n/use-follow-device-ui-locale'
import { startMobileUiLanguage } from '../src/i18n/ui-language-preference'
// Keep splash visible until React content is ready to avoid a blank frame.
SplashScreen.preventAutoHideAsync()
// Apply the phone's scheme before the first render; the saved choice lands under the splash.
const appearanceReady = startMobileAppearance()
// Likewise the UI language: the saved choice and its catalog load under the splash.
const languageReady = startMobileUiLanguage()
// Create orca-desktop channel at boot: background FCM can arrive before socket subscription, and Android drops unknown-channel pushes.
void ensureDesktopNotificationChannel().catch(() => {})
void registerPushDismissalTask().catch(() => {})
// Register before scheduling so foreground delivery uses the same suppression policy.
Notifications.setNotificationHandler({
handleNotification: foregroundNotificationBehavior
})
export default function RootLayout() {
const colorScheme = useMobileColorScheme()
useFollowDeviceUiLocale()
const router = useRouter()
const pathname = usePathname()
const { hostId, worktreeId, executionHost } = useGlobalSearchParams<{
hostId?: string
worktreeId?: string
executionHost?: string
}>()
useEffect(() => {
setNotificationViewingWorkspace(
pathname.includes('/session/') && typeof hostId === 'string' && typeof worktreeId === 'string'
? { hostId, worktreeId, ...(typeof executionHost === 'string' ? { executionHost } : {}) }
: null
)
return () => setNotificationViewingWorkspace(null)
}, [pathname, hostId, worktreeId, executionHost])
const openNotificationRoute = useOpenNotificationRoute()
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
// Reconcile the crash-persistent pairing journal before another scan can replace it.
void recoverMobileRelayPairing()
}, [])
// Re-register rolled APNs/FCM tokens with every paired host or delivery silently stops.
useEffect(() => startPushTokenSync(), [])
// Cold start, then foreground and timer checks on the desktop updater's cadence.
useEffect(() => appUpdateChecker.start(), [])
useEffect(() => startAndroidForegroundPushPresentation(), [])
// Route pair links through confirmation; getInitialURL covers cold starts and the listener covers warm starts.
useEffect(() => {
function handleUrl(url: string) {
const code = extractPairingCodeFromUrl(url)
if (code) {
// Replace Android camera’s unmatched orca://pair route so Cancel/Back cannot reveal the router error.
router.replace({ pathname: '/pair-confirm', params: { code } })
}
}
void Linking.getInitialURL().then((url) => {
if (url) {
handleUrl(url)
}
})
const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url))
return () => sub.remove()
}, [router])
// Notification taps arrive through expo-notifications, not Linking; route both cold and warm responses to their host/worktree.
useEffect(() => {
let disposed = false
function clearLastNotificationResponse() {
try {
Notifications.clearLastNotificationResponse()
} catch {
// Older native shells may lack clear API; duplicate guards still protect this JS runtime.
}
}
function getInitialNotificationResponse(): Notifications.NotificationResponse | null {
try {
return Notifications.getLastNotificationResponse()
} catch {
return null
}
}
async function getNavigationTarget(notification: Notifications.Notification) {
const hosts = await loadHostCatalog().catch(() => null)
const data = readNativeNotificationData(notification.request)
// Gateway pushes identify hosts by key fingerprint; without catalog resolution, never fall back to their raw hostId.
const routeData = pushNotificationRouteData(
data,
hosts ?? [],
isRemotePushTrigger(notification.request.trigger)
)
return getNotificationNavigationTarget(routeData, {
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined,
credentialStatusByHostId: hosts
? new Map(hosts.map((host) => [host.id, host.credentialStatus]))
: undefined
})
}
async function handleNotificationResponse(response: Notifications.NotificationResponse) {
if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) {
clearLastNotificationResponse()
return
}
const notificationId = response.notification.request.identifier
if (handledNotificationIdsRef.current.has(notificationId)) {
return
}
handledNotificationIdsRef.current.add(notificationId)
// RootLayout never unmounts; bound tap dedup with FIFO eviction.
if (handledNotificationIdsRef.current.size > 256) {
const oldest = handledNotificationIdsRef.current.values().next().value
if (oldest !== undefined) {
handledNotificationIdsRef.current.delete(oldest)
}
}
const target = await getNavigationTarget(response.notification)
clearLastNotificationResponse()
if (disposed) {
return
}
if (target) {
openNotificationRoute(target)
}
}
const initialResponse = getInitialNotificationResponse()
if (initialResponse) {
void handleNotificationResponse(initialResponse)
}
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
void handleNotificationResponse(response)
})
return () => {
disposed = true
sub.remove()
}
}, [openNotificationRoute])
// Hide splash only after Stack layout; a placeholder render can precede real screen content and expose a grey gap.
const onNavigatorLayout = useCallback(async () => {
await Promise.all([appearanceReady, languageReady])
// Let the language switch commit before revealing the first frame.
await new Promise((resolve) => requestAnimationFrame(resolve))
await SplashScreen.hideAsync()
}, [])
return (
<RpcClientProvider>
<View style={styles.root} onLayout={onNavigatorLayout}>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<SideSafeArea>
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bgPanel },
headerTintColor: colors.textPrimary,
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
contentStyle: { backgroundColor: colors.bgBase },
headerShadowVisible: false
// Leave orientation unset: react-native-screens overrides rotation lock even for default; Android manifest fullUser honors the user’s setting.
}}
>
<Stack.Screen
name="index"
options={{
headerShown: false,
headerTitle: () => <OrcaLogo size={22} />
}}
/>
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen
name="mobile-onboarding"
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="appearance-settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
<Stack.Screen
name="new-workspace"
options={{
headerShown: false,
// Draws nothing of its own: the drawer is the page's, over Home.
presentation: 'transparentModal',
animation: 'none',
contentStyle: { backgroundColor: 'transparent' }
}}
/>
</Stack>
</SideSafeArea>
</View>
</RpcClientProvider>
)
}
const styles = themedStyleSheet(() => ({
root: {
flex: 1,
backgroundColor: colors.bgBase
}
}))