Completion gating
Build an alarm that keeps ringing until the user finishes something in your app.
A normal alarm has a stop button. A completion-gated alarm does not: the only way out is a button
that opens your app while the alarm keeps ringing, and the ring stops when your app calls
completeNativeAlarmAsync().
Use this mode when the app must confirm completion before the alarm stops and pressing a native stop control is not sufficient.
Schedule with openAppOnly
await AlarmScheduler.scheduleAlarmAsync({
id: alarmId, // must be a UUID string on iOS
hour: 7,
minute: 0,
title: 'Wake up',
ios: {
metadata: { routine: 'morning' },
alertActionMode: 'openAppOnly',
secondaryButtonTitle: 'Open app',
stopIntentBehavior: 'rescheduleImmediate',
},
android: {
launchUri: 'myapp://alarm/ring',
maxRingDurationSeconds: 0, // ring until the app completes it
},
});maxRingDurationSeconds: 0 disables Android’s ring timeout, so nothing but your app ends the alarm.
Route from the native record on launch
Do this on cold launch and on every foreground. The handoff slot is written by native code before any JS listener runs, which makes it the right source for app-launch routing:
const handoff = await AlarmScheduler.getPendingNativeAlarmHandoffAsync();
const context = handoff ? null : await AlarmScheduler.getCurrentAlarmContextAsync();
const alarmId = handoff?.alarmId ?? context?.id;
if (alarmId) {
router.replace(`/alarm/${alarmId}`);
await AlarmScheduler.clearPendingNativeAlarmHandoffAsync();
}Complete only when the user actually finishes
await AlarmScheduler.completeNativeAlarmAsync(alarmId);
await AlarmScheduler.cancelAlarmAsync(alarmId); // then reschedule if the alarm repeatscompleteNativeAlarmAsync stops future native stop intents from scheduling backup alarms for that
id, cancels the original native alarm when active, cancels the deterministic backup alarm, and clears
pending action records for that alarm.
On Android this call is what silences the ringing service — it is mandatory, not advisory. With
alertActionMode: 'openAppOnly' the alarm plays until this call lands.
How strong the guarantee is per platform
Android — absolute. The package owns the ringing surface outright. openAppOnly removes the
stop button from both the ringing screen and the notification, back gestures are swallowed, and the
notification is ongoing so it cannot be swiped away. There is no exit but your app.
iOS — best effort. The ringing surface belongs to AlarmKit. openAppOnly prefers AlarmKit’s
newer secondary-only alert presentation when the runtime supports it, which omits the
package-configured stop button. But AlarmKit may still expose system-owned close or stop affordances
that never invoke the package’s App Intents.
The mitigation on iOS is stopIntentBehavior: 'rescheduleImmediate', which re-arms behind a stop:
- Records a
nativeStopaction. - Asks iOS to foreground the app.
- Schedules a short backup AlarmKit timer, which keeps re-arming until JS calls
completeNativeAlarmAsync(alarmId)orresetNativeAlarmCompletionAsync(alarmId).
Backup alarms use a deterministic native UUID derived from the logical alarmId, so each re-arm
cancels and replaces the previous backup rather than accumulating retry alarms.
Use getNativeAlarmDebugStateAsync to check which presentation the runtime actually
gave you:
const state = await AlarmScheduler.getNativeAlarmDebugStateAsync(alarmId);
if (state.alertActionMode === 'openAppOnly' && state.alertInitializer === 'legacyStopButton') {
// The runtime required the legacy stop-button presentation. The package
// cannot remove that AlarmKit stop affordance.
}Re-arming manually
If your app processes a handoff and needs a moment before it can present its UI, re-arm explicitly:
await AlarmScheduler.scheduleNativeAlarmBackupAsync(handoff.alarmId, 0.1);Calling this repeatedly replaces the same backup timer instead of stacking new ones. See Backup alarms.