fix(mobile): handle notification permission changes (#1506)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-06 11:57:42 -07:00 committed by GitHub
parent bba744a8cb
commit 9c24425504
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 97 additions and 19 deletions

View File

@ -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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
@ -43,15 +84,25 @@ export default function NotificationsScreen() {
<View style={styles.row}>
<Text style={styles.rowLabel}>Push Notifications</Text>
<Switch
value={pushEnabled}
value={switchEnabled}
disabled={notificationsBlocked}
onValueChange={(v) => void togglePush(v)}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
<Text style={styles.hint}>
Receive notifications when an agent task completes on your desktop.
</Text>
<Text style={styles.hint}>{hint}</Text>
{notificationsBlocked && (
<Pressable
style={({ pressed }) => [
styles.settingsButton,
pressed && styles.settingsButtonPressed
]}
onPress={() => void Linking.openSettings()}
>
<Text style={styles.settingsButtonText}>Open Settings</Text>
</Pressable>
)}
</View>
</View>
)
@ -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'
}
})

View File

@ -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<NotificationPermissionState> {
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<boolean> {
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 {