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