Local notifications and push tokens on both platforms
Local notifications and remote push tokens through One.Notifications.
Platform enums are string unions.
import { One } from 'one'
const current = await One.Notifications.getPermissions()if (!current.granted) { await One.Notifications.requestPermissions()}await One.Notifications.setBadgeCount(3)Permission reads resolve
{ status: 'granted' | 'denied' | 'undetermined', granted, canAskAgain },
with an ios.status carrying the authorization as
'notDetermined' | 'denied' | 'authorized' | 'provisional' | 'ephemeral'
on iOS. requestPermissions accepts
{ ios: { allowAlert, allowBadge, allowSound, allowProvisional } }; unset
fields default to true except allowProvisional. On Android below 13 there is
no runtime prompt, so requesting resolves the current status. setBadgeCount
resolves false and getBadgeCount resolves 0 on Android: the launcher
owns badges there.
Android delivery targets notification channels. Create one before scheduling onto it; settings apply on first create, afterwards the user owns them in system settings.
await One.Notifications.setChannel('reminders', { name: 'Reminders', importance: 'default',})getChannel, getChannels, and deleteChannel round out the set. On iOS
they resolve null, [], and nothing: there are no channels there.
Importance is 'none' | 'min' | 'low' | 'default' | 'high' | 'max', and
the numbers 0 to 5 map to those in order.
schedule delivers a local notification; trigger: null shows it
immediately. Every arrival fires addReceivedListener while the app runs in
the foreground, and every tap fires addResponseReceivedListener with the
notification and the default action identifier:
One.Notifications.setHandler({ handleNotification: async () => ({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: false, shouldSetBadge: false, }),})
const id = await One.Notifications.schedule({ content: { title: 'Hello', body: 'world' }, trigger: null,})Until the app sets a handler the notification shows fully: banner, list,
sound, and badge. If a handler stalls, native presents after a 3 s backstop
rather than dropping the notification; JS itself never times out, so a late
answer from a stalled handler is a no-op. With no listeners mounted at all
native presents immediately instead of waiting out the backstop. Setting the handler to null leaves native
undecided, which hides the notification on both platforms while listeners
still fire. On Android the banner flag raises the priority but the channel
importance governs what the system actually shows, and shouldSetBadge is
ignored: launcher badges are out of scope.
A tap that cold-starts the app cannot reach a listener: JS is not running
yet. It surfaces through the synchronous getLastResponse instead, which
the app reads on launch; clearLastResponse dismisses it. The iOS delegate
installs at launch, before React Native starts, so the cold-start tap is cached
no matter how early it arrives.
A tap on the notification itself reports One.Notifications.DEFAULT_ACTION_IDENTIFIER as its actionIdentifier:
import { One } from 'one'
const response = One.Notifications.getLastResponse()if (response?.actionIdentifier === One.Notifications.DEFAULT_ACTION_IDENTIFIER) { // the user tapped the notification body}Categories add action buttons to a notification. Register one before scheduling onto it; registering an identifier replaces it:
await One.Notifications.setCategory( 'replycat', [ { identifier: 'reply', buttonTitle: 'Reply' }, { identifier: 'snooze', buttonTitle: 'Snooze', options: { isDestructive: true, opensAppToForeground: false }, }, { identifier: 'comment', buttonTitle: 'Comment', textInput: { placeholder: 'Say something' }, }, ], { customDismissAction: true })
await One.Notifications.schedule({ content: { title: 'Hello', categoryIdentifier: 'replycat' }, trigger: null,})An action tap arrives on the response listener with the action’s identifier:
const sub = One.Notifications.addResponseReceivedListener((response) => { if (response.actionIdentifier === 'reply') { // the user tapped Reply } if (response.userText !== undefined) { // the user typed into a text-input action }})Action options are isAuthenticationRequired, isDestructive (an iOS effect; Android
shows the button the same way), and opensAppToForeground (defaulting to true).
textInput turns the action into a text field: the typed text arrives as userText on
the response. Category options are iOS-only extras (allowInCarPlay,
customDismissAction, showTitle, showSubtitle, categorySummaryFormat,
intentIdentifiers, previewPlaceholder); allowAnnouncement is accepted and ignored. Identifiers must be non-empty and contain neither : nor -.
getCategories resolves every registered category and deleteCategory removes one,
resolving true, or false when the identifier was unknown. Scheduling onto an
unknown category posts without buttons. On Android, taps on a dead process still land:
the response is recorded and getLastResponse answers it on the next launch. On web
setCategory echoes the registered category, getCategories resolves [], and
deleteCategory resolves false.
Two more triggers schedule into the future: { type: 'timeInterval', seconds, repeats? } and { type: 'date', date }, where the date is a timestamp or a
Date. A past date delivers immediately. Both take an Android channelId;
unknown ids fall back to an owned Default channel rather than posting
nowhere. Rescheduling an identifier replaces it.
await One.Notifications.schedule({ identifier: 'tea', content: { title: 'Tea', body: 'ready' }, trigger: { type: 'timeInterval', seconds: 300 },})cancelScheduled removes one pending notification by identifier,
cancelAllScheduled clears the pending list, and getAllScheduled
resolves the pending requests. getPresented resolves the notifications
currently on screen, dismiss removes one by identifier, and dismissAll
clears them.
On web there is no native runtime: permission reads resolve denied, lists
are empty, listeners are inert, and schedule rejects. Input checks match
native exactly, so bad input throws on web the same way.
On Android the triggers ride AlarmManager.setAndAllowWhileIdle: one inexact
path, no SCHEDULE_EXACT_ALARM, so a delivery deferred by doze is expected,
not a bug. Schedules persist across restarts and a boot receiver re-arms
them; one-shots that expired while the device was off are dropped and repeats
restart from boot. iOS has two platform rules worth knowing: a repeating
interval must be at least 60 seconds, and the calendar trigger resolves to
whole seconds.
getDevicePushToken resolves { type: 'ios' | 'android', data }: the
APNs hex token on iOS (via registerForRemoteNotifications, which works on
the simulator on iOS 16+) and the FCM registration token on Android.
addPushTokenListener returns a subscription that fires when the token
refreshes:
const token = await One.Notifications.getDevicePushToken()// { type: 'ios', data: 'a1b2...' }
const sub = One.Notifications.addPushTokenListener((next) => { console.log(next.data)})sub.remove()Push is opt-in per platform. On Android it needs
native.app.notifications.push, which compiles the Firebase Messaging
source set in; without it the app carries no Firebase classes and the call
rejects. On iOS the same flag stamps the aps-environment entitlement;
without it the call rejects as well, since the OS never answers the
registration. One receives the token from the app delegate itself, so the
host needs no delegate code.
Declare the capability in native.app so prebuild stamps the Android
permissions and receiver plus the iOS delegate gate. Without it the iOS
delegate never installs, so an app that uses its own push library keeps
that library’s delegate.
Set native.app.notifications.apsEnvironment to production for a build signed for TestFlight or the App Store; iOS push uses development otherwise. Use notifications: {} for local notifications without push.
Edit this page on GitHub.