ProjectR Docs
Mobile

Notifications

The permission pre-prompt, push token registration, and the Android channel.

The backend side of notifications (policies, quiet hours, delivery) is on Notification Pipeline. This page covers what the app does before a push can reach it.

The pre-prompt rule

The pre-prompt is the notification-permission modal ("Know when your order ships"). useOfferNotificationsAfterOrder pushes it from the order confirmation screen, so a customer meets it once an order is confirmed, never during sign-in. Whether it shows comes from shouldShowNotificationPermission() in src/shared/utils/notification-utils.tsx. It reads the OS permission and a dismissal marker, then applies the pure shouldOfferPrePrompt rule:

  • Never once the permission is granted, and never after the user has closed the pre-prompt on this install.
  • On iOS, while the status is undetermined, which is before the system dialog has run.
  • On Android 13+, while the status is denied and canAskAgain is true. Android reports denied until POST_NOTIFICATIONS is granted, even before the app has asked. canAskAgain turns false after the user denies the system dialog twice, when Android stops showing it.

Only closing the pre-prompt writes the marker. markNotificationPermissionShown runs on Turn on notifications, Not now, back or swipe, and sets notification_preprompt_dismissed_v2. Reading the permission never writes it. The old key, notification_permission_shown, was also written by the permission check whenever the OS said denied, so every Android install carried it without having seen the pre-prompt. Android ignores that key now. iOS still honours it, so iOS users who dismissed the old prompt stay dismissed.

Push token registration

The notification orchestrator (src/features/notifications/stores/notification-orchestrator-store.ts) registers the push token after sign-in. Its zustand persist store sits on MMKV, which is synchronous, so it rehydrates inside create(), before useOrchestratorStore is assigned. onRehydrateStorage therefore acts on the state zustand hands it (state.setHasHydrated(true)), never on the store variable. The earlier form called useOrchestratorStore.setState, threw, and zustand swallowed the error. _hasHydrated never turned true, so builds from 26 February 2026 until the fix never registered a push token. Store version 1.10.0 shipped with that bug. Any MMKV-backed persist store must follow the same rule.

Tokens register only on a physical device. NotificationService returns early on a simulator or emulator (Device.isDevice), so a token check there proves nothing.

The Android channel

Every push lands in the default channel, because the backend's Expo push messages carry no channelId yet. DEFAULT_NOTIFICATION_CHANNEL in notification-utils.tsx defines that channel once, at MAX importance, and both the permission check and NotificationService create it from that one definition. Android renames a channel on every write and lets an app lower its importance but never raise it again. A second definition at a lower importance would therefore drop every install's default channel from MAX to DEFAULT for good, and its name in system Settings would change with call order. Sending a channelId from the backend is a gated follow-up.

Checking it on an Android emulator

The pre-prompt only appears from a clean permission state, so reset that state before each run. Force-stop the app, then run pm revoke on android.permission.POST_NOTIFICATIONS through adb shell, followed by pm clear-permission-flags for the same permission with user-set user-fixed. pm revoke alone leaves user-fixed set after a second denial, and Android then never shows the system dialog again. With run-as, also delete two files from the app's data directory. shared_prefs/expo.modules.permissions.asked.xml is where Expo's permissions module records which permissions it has asked for and which it treats as blocked. The files/mmkv/notification_permissions* files are the MMKV instance that holds the dismissal markers. A Maestro flow that denies twice must tap permission_deny_and_dont_ask_again_button on the second system dialog, not permission_deny_button.

adb shell dumpsys notification --noredact prints the channel. Find NotificationChannel{mId='default' and read its name and importance, which should be "Default Notifications" at 5 (MAX).

On an emulator the permission check still writes the channel, but NotificationService returns before its own channel write and before token registration. To see both writers or get a real Expo token there, lift the two Device.isDevice guards in src/features/notifications/services/notification-service.ts in a local edit, and never commit that edit.

On this page