One Logo Pool Ball
wwwwwwwwwwwwwwwwwww

Notifications

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.

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.

Expo-prebuilt apps pass the same choice to vxrn/expo-plugin, which writes the same entries during expo prebuild. mode sets aps-environment, as in expo-notifications, and defaults to development:

{
"expo": {
"plugins": [
["vxrn/expo-plugin", { "notifications": { "push": true, "mode": "production" } }]
]
}
}

Use { "notifications": {} } for local notifications without push.

Edit this page on GitHub.