diff --git a/mobile/app/notification-opt-in.tsx b/mobile/app/notification-opt-in.tsx
index 41f2c2168e2..a4476687e09 100644
--- a/mobile/app/notification-opt-in.tsx
+++ b/mobile/app/notification-opt-in.tsx
@@ -1,5 +1,13 @@
import { useCallback, useState } from 'react'
-import { ActivityIndicator, BackHandler, Pressable, StyleSheet, Text, View } from 'react-native'
+import {
+ ActivityIndicator,
+ BackHandler,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View
+} from 'react-native'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { SafeAreaView } from 'react-native-safe-area-context'
import { BellRing } from 'lucide-react-native'
@@ -49,63 +57,65 @@ export default function NotificationOptInScreen() {
return (
-
-
- Orca
-
-
-
-
-
+
+
+
+ Orca
- Notifications
- Stay updated while away
-
- Get notified on this device when an agent needs your input or finishes a task.
-
-
-
- {error ? (
-
- {error}
+
+
+
+
+ Notifications
+ Stay updated while away
+
+ Get notified on this device when an agent needs your input or finishes a task.
- ) : null}
- [
- styles.primaryButton,
- pressed && styles.buttonPressed,
- busyChoice !== null && styles.buttonDisabled
- ]}
- onPress={() => void choose('enable')}
- >
- {busyChoice === 'enable' ? (
-
- ) : (
- Enable notifications
- )}
-
- [
- styles.secondaryButton,
- pressed && styles.buttonPressed,
- busyChoice !== null && styles.buttonDisabled
- ]}
- onPress={() => void choose('skip')}
- >
- {busyChoice === 'skip' ? (
-
- ) : (
- Not now
- )}
-
- You can change this any time in Settings.
-
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ [
+ styles.primaryButton,
+ pressed && styles.buttonPressed,
+ busyChoice !== null && styles.buttonDisabled
+ ]}
+ onPress={() => void choose('enable')}
+ >
+ {busyChoice === 'enable' ? (
+
+ ) : (
+ Enable notifications
+ )}
+
+ [
+ styles.secondaryButton,
+ pressed && styles.buttonPressed,
+ busyChoice !== null && styles.buttonDisabled
+ ]}
+ onPress={() => void choose('skip')}
+ >
+ {busyChoice === 'skip' ? (
+
+ ) : (
+ Not now
+ )}
+
+ You can change this any time in Settings.
+
+
)
}
@@ -116,6 +126,11 @@ const styles = StyleSheet.create({
backgroundColor: colors.bgBase,
paddingHorizontal: spacing.xl
},
+ // Why: this decision screen cannot be dismissed with Back, so every action
+ // must remain reachable in landscape and with accessibility text scaling.
+ scrollContent: {
+ flexGrow: 1
+ },
brandRow: {
minHeight: 52,
flexDirection: 'row',
@@ -128,10 +143,10 @@ const styles = StyleSheet.create({
fontWeight: '700'
},
content: {
- flex: 1,
+ flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
- paddingBottom: spacing.xl
+ paddingVertical: spacing.xl
},
iconSurface: {
width: 64,
@@ -173,11 +188,12 @@ const styles = StyleSheet.create({
paddingBottom: spacing.lg
},
primaryButton: {
- height: 44,
+ minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
- backgroundColor: colors.surfaceBright
+ backgroundColor: colors.surfaceBright,
+ paddingVertical: spacing.sm
},
primaryButtonText: {
color: colors.bgBase,
@@ -185,11 +201,12 @@ const styles = StyleSheet.create({
fontWeight: '600'
},
secondaryButton: {
- height: 44,
+ minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
- marginTop: spacing.xs
+ marginTop: spacing.xs,
+ paddingVertical: spacing.sm
},
secondaryButtonText: {
color: colors.textSecondary,
diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx
index 4adacfc016f..d9696251a94 100644
--- a/mobile/app/notifications.tsx
+++ b/mobile/app/notifications.tsx
@@ -17,7 +17,8 @@ import {
const DEFAULT_PERMISSION_STATE: NotificationPermissionState = {
granted: false,
status: 'undetermined',
- canAskAgain: true
+ canAskAgain: true,
+ authorizationReflectsUserChoice: false
}
export default function NotificationsScreen() {
diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts
index 2d6cf868461..b1f7c89578e 100644
--- a/mobile/src/notifications/mobile-notifications.test.ts
+++ b/mobile/src/notifications/mobile-notifications.test.ts
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
+import { Platform } from 'react-native'
import {
+ getNotificationPermissionState,
setScheduledNotificationsMaxForTests,
subscribeToDesktopNotifications
} from './mobile-notifications'
@@ -18,7 +20,7 @@ vi.mock('expo-notifications', () => ({
}))
vi.mock('react-native', () => ({
- Platform: { OS: 'ios' }
+ Platform: { OS: 'ios', Version: 18 }
}))
// Why: mobile-notifications now persists the catch-up watermark to
@@ -35,6 +37,32 @@ vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
+beforeEach(() => {
+ Object.assign(Platform, { OS: 'ios', Version: 18 })
+})
+
+describe('getNotificationPermissionState', () => {
+ it.each([
+ { os: 'android', version: 32, expected: false },
+ { os: 'android', version: 33, expected: true },
+ { os: 'ios', version: 18, expected: true }
+ ])(
+ 'reports whether a granted $os $version authorization reflects user choice',
+ async ({ os, version, expected }) => {
+ Object.assign(Platform, { OS: os, Version: version })
+ vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
+ status: 'granted',
+ canAskAgain: true
+ } as never)
+
+ await expect(getNotificationPermissionState()).resolves.toMatchObject({
+ granted: true,
+ authorizationReflectsUserChoice: expected
+ })
+ }
+ )
+})
+
describe('subscribeToDesktopNotifications', () => {
beforeEach(() => {
vi.clearAllMocks()
diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts
index f91e5981366..d3866393116 100644
--- a/mobile/src/notifications/mobile-notifications.ts
+++ b/mobile/src/notifications/mobile-notifications.ts
@@ -82,6 +82,7 @@ export type NotificationPermissionState = {
granted: boolean
status: string
canAskAgain: boolean
+ authorizationReflectsUserChoice: boolean
}
export async function getNotificationPermissionState(): Promise {
@@ -89,7 +90,11 @@ export async function getNotificationPermissionState(): Promise= 33)
}
}
diff --git a/mobile/src/notifications/notification-opt-in-gate.test.ts b/mobile/src/notifications/notification-opt-in-gate.test.ts
index fb5a95ac642..ded3d7eca39 100644
--- a/mobile/src/notifications/notification-opt-in-gate.test.ts
+++ b/mobile/src/notifications/notification-opt-in-gate.test.ts
@@ -27,7 +27,8 @@ describe('notification opt-in gate', () => {
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: false,
status: 'undetermined',
- canAskAgain: true
+ canAskAgain: true,
+ authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
@@ -46,19 +47,34 @@ describe('notification opt-in gate', () => {
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: true,
status: 'granted',
- canAskAgain: true
+ canAskAgain: true,
+ authorizationReflectsUserChoice: true
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(true)
})
+ it('still presents when a pre-Android 13 default grant is not an opt-in decision', async () => {
+ vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
+ vi.mocked(getNotificationPermissionState).mockResolvedValue({
+ granted: true,
+ status: 'granted',
+ canAskAgain: true,
+ authorizationReflectsUserChoice: false
+ })
+
+ await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
+ expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
+ })
+
it('skips the gate when iOS has already denied permission', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: false,
status: 'denied',
- canAskAgain: false
+ canAskAgain: false,
+ authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
diff --git a/mobile/src/notifications/notification-opt-in-gate.ts b/mobile/src/notifications/notification-opt-in-gate.ts
index 9d7b4eed6cc..a909fad16d7 100644
--- a/mobile/src/notifications/notification-opt-in-gate.ts
+++ b/mobile/src/notifications/notification-opt-in-gate.ts
@@ -13,6 +13,9 @@ export async function shouldPresentNotificationOptIn(): Promise {
try {
const permission = await getNotificationPermissionState()
if (permission.granted) {
+ if (!permission.authorizationReflectsUserChoice) {
+ return true
+ }
// Why: an already-authorized device should inherit the useful default
// without seeing an onboarding decision it has effectively made.
await savePushNotificationsEnabled(true)