From 9c24425504e6025a9cdd4eb5855bde4f236a3f70 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 6 May 2026 11:57:42 -0700 Subject: [PATCH] fix(mobile): handle notification permission changes (#1506) Co-authored-by: Orca --- mobile/app/notifications.tsx | 88 ++++++++++++++++--- .../src/notifications/mobile-notifications.ts | 28 ++++-- 2 files changed, 97 insertions(+), 19 deletions(-) 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 {