diff --git a/mobile/app.json b/mobile/app.json index 7656263a4..4545fda0f 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -14,9 +14,9 @@ "backgroundColor": "#111111" }, "ios": { - "supportsTablet": true, + "supportsTablet": false, "bundleIdentifier": "com.stably.orca.mobile", - "buildNumber": "5", + "buildNumber": "6", "infoPlist": { "NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.", "NSAppTransportSecurity": { diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx index 7b0fdf9cd..d20aacabb 100644 --- a/mobile/app/notifications.tsx +++ b/mobile/app/notifications.tsx @@ -13,7 +13,7 @@ import { ensureNotificationPermissions } from '../src/notifications/mobile-notif export default function NotificationsScreen() { const router = useRouter() const insets = useSafeAreaInsets() - const [pushEnabled, setPushEnabled] = useState(true) + const [pushEnabled, setPushEnabled] = useState(false) useFocusEffect( useCallback(() => { diff --git a/mobile/app/settings.tsx b/mobile/app/settings.tsx index 171bd0048..c0285de36 100644 --- a/mobile/app/settings.tsx +++ b/mobile/app/settings.tsx @@ -1,7 +1,15 @@ -import { View, Text, StyleSheet, Pressable } from 'react-native' +import { View, Text, StyleSheet, Pressable, Linking } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' -import { ChevronLeft, ChevronRight, Info, Bell, Wrench } from 'lucide-react-native' +import { + ChevronLeft, + ChevronRight, + Info, + Bell, + Wrench, + Shield, + LifeBuoy +} from 'lucide-react-native' import { colors, spacing, typography } from '../src/theme/mobile-theme' export default function SettingsScreen() { @@ -45,6 +53,24 @@ export default function SettingsScreen() { + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => void Linking.openURL('https://www.onorca.dev/privacy')} + > + + Privacy Policy + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => void Linking.openURL('https://github.com/stablyai/orca/issues')} + > + + Support + + ) } @@ -78,6 +104,9 @@ const styles = StyleSheet.create({ borderRadius: 12, overflow: 'hidden' }, + sectionSpacer: { + marginTop: spacing.md + }, row: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index efd4dc75c..7a588902d 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -4,13 +4,18 @@ const PINS_PREFIX = 'orca:pins:' const PREFS_PREFIX = 'orca:prefs:' const NOTIF_KEY = 'orca:pushNotificationsEnabled' +// Why: default-off so the iOS notification permission prompt never +// fires until the user explicitly opts in via Settings → Notifications. +// Apple's review guideline 4.5.4 and HIG both prefer user-initiated +// permission prompts; default-on would fire the prompt the moment the +// desktop sent its first notification, which can read as unsolicited. export async function loadPushNotificationsEnabled(): Promise { try { const raw = await AsyncStorage.getItem(NOTIF_KEY) - if (raw === null) return true + if (raw === null) return false return raw === 'true' } catch { - return true + return false } } diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index bfa935396..176b80355 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -23,13 +23,10 @@ import { connect, type RpcClient } from './rpc-client' import { loadHosts } from './host-store' import type { ConnectionState, HostProfile } from './types' -const IDLE_CLOSE_MS = 30_000 - type StoreEntry = { client: RpcClient state: ConnectionState refCount: number - idleTimer: ReturnType | null unsubState: () => void } @@ -83,7 +80,6 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const closeEntry = useCallback((hostId: string) => { const entry = storeRef.current.get(hostId) if (!entry) return - if (entry.idleTimer) clearTimeout(entry.idleTimer) entry.unsubState() entry.client.close() storeRef.current.delete(hostId) @@ -150,7 +146,6 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { client, state: client.getState(), refCount: 0, - idleTimer: null, unsubState } storeRef.current.set(hostId, entry) @@ -175,10 +170,6 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const existing = storeRef.current.get(hostId) if (existing) { existing.refCount += 1 - if (existing.idleTimer) { - clearTimeout(existing.idleTimer) - existing.idleTimer = null - } return existing.client } // Trigger async open. The acquire-side will return null this tick and @@ -197,36 +188,30 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { for (const host of hosts) primedHostsRef.current.set(host.id, host) }, []) - const release = useCallback( - (hostId: string) => { - const entry = storeRef.current.get(hostId) - if (!entry) return - entry.refCount = Math.max(0, entry.refCount - 1) - if (entry.refCount > 0) return - if (entry.idleTimer) clearTimeout(entry.idleTimer) - entry.idleTimer = setTimeout(() => { - // Why: only close if still idle when the timer fires. A late acquire - // would have cleared the timer. - const cur = storeRef.current.get(hostId) - if (!cur || cur.refCount > 0) return - closeEntry(hostId) - }, IDLE_CLOSE_MS) - }, - [closeEntry] - ) + // Why: refcount dropping to 0 no longer triggers an idle-close. The + // app deliberately keeps live WebSockets open while the app itself is + // foregrounded — closing on transient navigation gaps was producing + // false 'disconnected' flashes when the user navigated home → host → + // back to home faster than React could re-acquire on the home side. + // Connections still close on: explicit user Disconnect, host removal, + // app backgrounding (OS-level socket suspension), and provider + // unmount (app shutdown). + const release = useCallback((hostId: string) => { + const entry = storeRef.current.get(hostId) + if (!entry) return + entry.refCount = Math.max(0, entry.refCount - 1) + }, []) const forceReconnect = useCallback( async (hostId: string) => { const entry = storeRef.current.get(hostId) - // Why: if the entry was previously closed (e.g. user tapped - // Disconnect), refCount is lost. Fall back to the number of active - // state listeners as a proxy for "screens currently watching this - // host," so the freshly-opened entry doesn't trip the idle-close - // timer immediately. + // Why: preserve refcount across the swap. If the user reaches + // forceReconnect via the Disconnect → Reconnect path, the entry + // was already closed and refCount=0; fall back to active listener + // count as a proxy for "screens still watching this host." const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0 const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount) if (entry) { - if (entry.idleTimer) clearTimeout(entry.idleTimer) entry.unsubState() entry.client.close() storeRef.current.delete(hostId) @@ -278,13 +263,23 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { } }, []) - // Close all clients on provider unmount (app shutdown / hot reload). + // Close all clients on provider unmount (app shutdown). + // Why: deps must be empty so this cleanup ONLY runs on real unmount. + // Hot-reload re-evaluates this module, which makes closeEntry a new + // function identity. With [closeEntry] as deps, every Fast Refresh + // would tear down all open WebSockets, leaving screens holding closed + // clients and the user staring at a 'Reconnecting…' card. Reading + // storeRef.current and the locally-scoped closeEntry inside the + // cleanup is safe — the ref is stable across renders, and the + // function captured here will be the one defined in the same + // closure as this effect. + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { const store = storeRef.current return () => { for (const [hostId] of store) closeEntry(hostId) } - }, [closeEntry]) + }, []) const value = useMemo( () => ({ diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index ed47bbdb2..7ada7965e 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -32,7 +32,14 @@ export type RpcClient = { close: () => void } -const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000] +// Why: capped at 4s so the worst-case "stuck reconnecting" window the +// user perceives is short. Prior 16s ceiling combined with Android's +// suspended-timer behaviour during background → foreground transitions +// often felt like the app would just sit on 'Reconnecting…' forever +// (the timer was queued, the OS had simply not run it yet). Tapping the +// manual Reconnect button bypassed the timer, which is why it felt +// "magic". Shorter backoff makes the auto-recovery path feel as fast. +const RECONNECT_DELAYS = [500, 1000, 2000, 4000] const REQUEST_TIMEOUT_MS = 30_000 const HANDSHAKE_TIMEOUT_MS = 5_000