diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx
index d20aacabb..f7922074d 100644
--- a/mobile/app/notifications.tsx
+++ b/mobile/app/notifications.tsx
@@ -1,5 +1,5 @@
-import { useState, useCallback } from 'react'
-import { View, Text, StyleSheet, Pressable, Switch } from 'react-native'
+import { useState, useCallback, useEffect } from 'react'
+import { AppState, Linking, View, Text, StyleSheet, Pressable, Switch } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useFocusEffect } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
@@ -8,28 +8,69 @@ import {
loadPushNotificationsEnabled,
savePushNotificationsEnabled
} from '../src/storage/preferences'
-import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications'
+import {
+ ensureNotificationPermissions,
+ getNotificationPermissionState,
+ type NotificationPermissionState
+} from '../src/notifications/mobile-notifications'
+
+const DEFAULT_PERMISSION_STATE: NotificationPermissionState = {
+ granted: false,
+ status: 'undetermined',
+ canAskAgain: true
+}
export default function NotificationsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [pushEnabled, setPushEnabled] = useState(false)
+ const [permissionState, setPermissionState] = useState(DEFAULT_PERMISSION_STATE)
+
+ const refreshSettings = useCallback(async () => {
+ const [enabled, permission] = await Promise.all([
+ loadPushNotificationsEnabled(),
+ getNotificationPermissionState()
+ ])
+ setPushEnabled(enabled)
+ setPermissionState(permission)
+ }, [])
useFocusEffect(
useCallback(() => {
- void loadPushNotificationsEnabled().then(setPushEnabled)
- }, [])
+ void refreshSettings()
+ }, [refreshSettings])
)
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', (state) => {
+ if (state === 'active') {
+ void refreshSettings()
+ }
+ })
+ return () => subscription.remove()
+ }, [refreshSettings])
+
const togglePush = async (value: boolean) => {
if (value) {
const granted = await ensureNotificationPermissions()
- if (!granted) return
+ const permission = await getNotificationPermissionState()
+ setPermissionState(permission)
+ if (!granted) {
+ setPushEnabled(false)
+ await savePushNotificationsEnabled(false)
+ return
+ }
}
setPushEnabled(value)
await savePushNotificationsEnabled(value)
}
+ const switchEnabled = pushEnabled && permissionState.granted
+ const notificationsBlocked = permissionState.status === 'denied'
+ const hint = notificationsBlocked
+ ? 'Notifications are disabled in system settings.'
+ : 'Receive notifications when an agent task completes on your desktop.'
+
return (
@@ -43,15 +84,25 @@ export default function NotificationsScreen() {
Push Notifications
void togglePush(v)}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
-
- Receive notifications when an agent task completes on your desktop.
-
+ {hint}
+ {notificationsBlocked && (
+ [
+ styles.settingsButton,
+ pressed && styles.settingsButtonPressed
+ ]}
+ onPress={() => void Linking.openSettings()}
+ >
+ Open Settings
+
+ )}
)
@@ -105,5 +156,22 @@ const styles = StyleSheet.create({
lineHeight: 18,
paddingHorizontal: spacing.md + 2,
paddingBottom: spacing.md
+ },
+ settingsButton: {
+ alignSelf: 'flex-start',
+ marginHorizontal: spacing.md + 2,
+ marginBottom: spacing.md,
+ paddingVertical: spacing.xs,
+ paddingHorizontal: spacing.sm,
+ borderRadius: 8,
+ backgroundColor: colors.bgRaised
+ },
+ settingsButtonPressed: {
+ opacity: 0.6
+ },
+ settingsButtonText: {
+ color: colors.textPrimary,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
}
})
diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts
index 287de38a0..987eed64e 100644
--- a/mobile/src/notifications/mobile-notifications.ts
+++ b/mobile/src/notifications/mobile-notifications.ts
@@ -16,22 +16,32 @@ type SubscribeResult = {
subscriptionId: string
}
-let permissionGranted: boolean | null = null
+export type NotificationPermissionState = {
+ granted: boolean
+ status: string
+ canAskAgain: boolean
+}
+
+export async function getNotificationPermissionState(): Promise {
+ const { status, canAskAgain } = await Notifications.getPermissionsAsync()
+ return {
+ granted: status === 'granted',
+ status,
+ canAskAgain
+ }
+}
// Why: permissions must be requested before scheduling any local notification.
-// Cache the result so we only prompt once per app session.
+// Read the OS state every time because users can change it in Settings while
+// Orca remains alive in the background.
export async function ensureNotificationPermissions(): Promise {
- if (permissionGranted !== null) return permissionGranted
-
- const { status: existingStatus } = await Notifications.getPermissionsAsync()
- if (existingStatus === 'granted') {
- permissionGranted = true
+ const existing = await getNotificationPermissionState()
+ if (existing.granted) {
return true
}
const { status } = await Notifications.requestPermissionsAsync()
- permissionGranted = status === 'granted'
- return permissionGranted
+ return status === 'granted'
}
function configureNotificationChannel(): void {