ember-system-alarm-bridge-plugin
v2.8.36
Published
Expo config plugin that injects Ember's native alarm bridge for iOS and Android
Maintainers
Readme
ember-system-alarm-bridge-plugin
Expo config plugin that injects Ember's native alarm bridge into the mobile app.
What It Adds
- iOS AlarmKit bridge source (
IOS26AlarmBridge.swift). - Objective-C React Native module declarations (
IOS26AlarmBridge.m). - Android AlarmManager bridge source (
SystemAlarmBridgeModule.kt, receiver, scheduler, and store). - AlarmKit usage description in the iOS Info.plist.
- Xcode project source-file wiring for the generated bridge files.
- Android manifest permissions/receiver wiring and React Native package registration.
- Android exact-alarm compatibility:
SCHEDULE_EXACT_ALARMthrough API 32 andUSE_EXACT_ALARMon API 33+.
Foreground Alarm Rules
Versioned inactivity expiry
New schedules explicitly carry inactivityPolicyVersion: 3. Their occurrence
expires after 30 minutes without genuine activity. Policy 2 retains its original
15-minute timeout. Each deadline is measured from the configured
ring time until genuine activity is observed. Validated challenge interaction
and live native step events move that deadline; opening or backgrounding the
app, context heartbeats, and replayed historical step totals do not. Schedules
without either marker retain their legacy 20-hour lifetime. Updating the plugin
does not change an existing occurrence's policy or persisted deadline.
Explicit alarm dismissals also count as activity: the iOS Stop action, and on Android the alarm screen's volume keys or an observed alarm-volume reduction. Android validates the ringing alarm's captured occurrence and lifecycle before crediting a dismissal. Automatic retries, audio-focus loss, playback failures, screen-off broadcasts, and service teardown never extend the inactivity window. Repeated user dismissals can keep an unfinished occurrence alive, but cannot revive one that has already expired or completed.
An accepted one-time snooze preserves pressure until its chosen deadline and
allows its policy's 15 or 30 further minutes without activity. This expiry baseline survives the
snooze ring being consumed and is exposed as inactivitySnoozeUntilMs, separate
from the currently armed oneTimeSnoozeUntilMs. Offering snooze alone changes
nothing. Ordinary 10-second retries and the existing walk-fuse timing are
unchanged while an occurrence remains live.
Recovery preserves the original policy, schedule, activity, and snooze timestamps. It never starts a fresh timeout from app launch; already retired or completed occurrences stay closed.
getActiveWakeOccurrence reports the policy version, last meaningful activity,
effective expiry, and inactivityClockOffsetMs. Clock correction preserves the
original occurrence start/token; only deadline/activity timestamps move.
expireWakeOccurrence({alarmIds, occurrenceStartedAtMs,
occurrenceToken?}) validates the exact native occurrence and its deadline,
journals an inactivity miss before silencing pressure, and preserves the next
scheduled occurrence and unrelated roots. Miss receipts remain available through
the abandoned-occurrence journal with reason: "inactivity", expiredAtMs,
the original inactivityPolicyVersion, and inactivityClockOffsetMs until
acknowledged. Subtract the offset from expiredAtMs to compare with the original
scheduled time. Older inactivity
receipts without a policy field are reported as policy 2. Native delivery, intent, rearm, and recovery paths check
expiry so a later dismissal cannot restart an expired occurrence.
This is a durable deadline, not a guarantee of an exact background callback. iOS may leave an already accepted AlarmKit alert visible after process death; the next native execution retires it without starting another retry. Explicit force-quit also prevents guaranteed observation of subsequent steps.
The bridge observes native alarm state and applies Ember's foreground rules immediately:
- Ordinary wake alarms never present while Ember is active; foregrounding silences an alert and consumes an imminent ordinary successor.
- Leaving an unfinished non-walk challenge arms one owner-scoped +10-second successor even when JavaScript has not finished restoring its session. On iOS this begins when the app resigns active, including an unanswered snooze offer, before the app switcher can terminate the process. A started iOS walk first arms one provisional fuse while resigning active, then converges to one background fuse after the real background edge.
- Session retries are suppressed after mission completion, during recent walking, or once the wake-up challenge is actually rendered.
challengeScreenVisibleis the rendered handoff signal;onChallengeScreenremains the later user-activity acknowledgement used by older bundles.
The app schedules one native alarm per occurrence. Android arms step-suppression tracking before each alarm so recent walking can suppress ringing even when JavaScript is backgrounded.
Alarm Sound Order
Each scheduled root persists a flat sound pool, an ordered list of selected
category groups, and either progressive or random playback. Progressive
starts with a random sound from the lowest selected group, advances one group
for each following audible ring in the same occurrence, and keeps rolling inside
the highest selected group thereafter. Random rolls across the complete pool on
every distinct ring. JavaScript places the system default in the Momentum group.
The native sequence survives process death and reboot. Re-delivering the same ring reuses its exact chosen sound, while an unheard cancellation or failed schedule does not advance the sequence. Older records default to progressive with one flat group. Both platforms retry the system alarm sound if a custom sound cannot be scheduled or played, so sequencing can never turn an alarm silent. An audible system fallback counts as Momentum; selecting a sound alone never advances the sequence.
On iOS 26+, ordinary one-shot alarms and repeating alarms use AlarmKit's relative wall-clock schedules. Weekly recurrence receives the intended hour and minute separately from the first timestamp, so a first occurrence normalized by a daylight-saving gap cannot permanently move the chosen alarm time. A repeating alarm with one or more skipDates uses concrete fixed occurrences because AlarmKit does not support exclusion dates. The native bridge always seeds one valid next scheduled occurrence, rolls another from AlarmKit intents/updates, and reconciles fixed wall-clock times when the running process observes a timezone or significant clock change. That next-occurrence seed is recurrence durability, not a same-morning retry. AlarmKit's numeric alarm limit is undocumented, so the bridge deliberately avoids an unbounded skip-date horizon.
Before every direct iOS schedule, the bridge reconciles AlarmKit's daemon inventory in both directions: unknown daemon entries are cancelled, while stale future registry rows with no daemon alarm are removed after a short in-flight publication grace. Past rows remain with the observer so it can still distinguish a real dismissal. The new schedule and its exact primary ring ID are then published atomically before the daemon write, so background reconciliation cannot turn a successful competing reservation into a false save failure. Explicit alarm deletion and active-occurrence abandonment also wait for daemon cancellation before resolving; latency-sensitive challenge completion remains detached.
Direct iOS saves verify the exact primary UUID in AlarmKit after scheduling, with at most three reads separated by 100 milliseconds. A saved schedule, another root, or a following-occurrence seed cannot satisfy this check. An immediately delivered primary may finish its foreground handoff only when that exact UUID was observed in AlarmKit and its matching native occurrence is still live. The imminent-foreground sweep waits while this bounded verification is pending; actual alerting alarms are still silenced immediately. Failed verification rejects the save and cancels only the newly prepared root, leaving the app's previous schedule intact.
getScheduledAlarmTimes reads AlarmKit and its registries under one lock. Root
rows retain recovery metadata and expose registered, which is true only when
the configured primary for that exact root and fire time is present. Physical
ring rows are returned only while AlarmKit confirms them. Read failures reject
instead of returning saved records or an empty successful inventory.
foregroundSuppressed separately identifies an exact primary intentionally
consumed while Ember remains foregrounded; it never claims physical registration.
That proof is process-local, occurrence-scoped, and cleared on backgrounding,
clock/timezone changes, replacement, and deletion. It prevents repair from
repeatedly replacing a deliberately silent imminent alarm.
Completion is scoped to the one native occurrence that was solved, never every weekday root of its logical alarm. Native code independently rejects ambiguous future-root completion batches, and rearm repairs legacy future tombstones by restoring every missing primary immediately, including primaries more than 26 hours away. Completion and walk/rearm gates are root-scoped, so one alarm's completed occurrence cannot suppress another alarm. Alerting wake rings are exposed to JavaScript as their schedule root, preventing a physical AlarmKit ring UUID from borrowing a different alarm's current clock window.
AlarmKit capacity is shared and dynamic, so iOS reconciliation prioritizes live and earliest configured primaries globally before any optional next-occurrence seed. Seeds are added only after the app backgrounds and every primary is ready. If AlarmKit reports its limit while establishing an earlier primary, Ember may reclaim only a later non-alerting seed and retry once; it never sacrifices another configured primary, live occurrence, snooze ring, or walk ring. Direct editor scheduling arms only the requested primary, preventing a partial multi-weekday save from seeding ahead of roots that have not been registered yet.
iOS does not pre-arm a same-morning cadence. An observed AlarmKit dismissal, the Stop action, the Start wake-up check handoff, or backgrounding an unfinished non-walk challenge arms at most one future owner-scoped successor for 10 seconds later. A started walk follows the separate lifecycle-safe fuse policy below. Concurrent lifecycle and observer work reuse each allowed successor instead of stacking another. Returning to the foreground suppresses the ordinary wake ring, while challenge presentation, completion, or explicit cancellation removes the successor. Existing installs migrate by collapsing surplus ordinary cadence rings while preserving an alert already sounding, a valid configured primary even when it is more than 26 hours away, one live-occurrence pressure ring, the next scheduled occurrence seed, every snooze ring, and one valid walk fuse per root. Foreground maintenance alarms, paired walk fuses, safety relays, and walk cadences are not preserved.
One-Time Snooze
Each wake occurrence may buy exactly one user-chosen delay through beginOneTimeSnooze({untilMs, occurrenceToken?, alarmIds}), which resolves the accepted deadline. getActiveWakeOccurrence reports oneTimeSnoozeUsed, plus oneTimeSnoozeUntilMs while a snooze is still armed.
- Native truth decides. The request is validated against the live native occurrence, its token and its root — never against JavaScript alarm configuration, which is mutable and advisory. A deadline must be at least a few seconds and at most 30 minutes away.
- Exactly once per native occurrence. The latch is keyed by the occurrence token and is spent before the successor is attempted, so ambiguity about whether a request landed can never become two stacked snoozes. It is cleared only by completion, abandonment/cancellation, or a genuinely new occurrence — never by a dismissal, a rearm or a reboot.
- Arm before silencing. The OS-durable successor (an AlarmKit ring of kind
one_time_snoozeon iOS, aPendingRetrywith a durableoneTimeSnoozeflag on Android) is established first. Only once it is confirmed are the occurrence's current ring, ordinary retries, walk fuse and in-app burst silenced. A failed schedule leaves the prior pressure exactly as it was; the next scheduled occurrence is never cancelled. - It is the occurrence's pressure while armed. Ordinary rearm, reconciliation, foreground-watchdog and context paths stand down instead of stacking a +10-second ring on top of the chosen window. Android's
repairPendingRetriespreserves the up-to-30-minute deadline instead of collapsing it to +10 seconds, and boot restore keeps it. - Offering snooze is not snoozing. Until accepted, and after its ring is consumed, leaving the unfinished check uses ordinary rearm. A spent iOS snooze is retired even when its delivery and stop updates coalesce; reconciliation repairs stale rows without clearing the spent occurrence token. On Android, recent steps may defer delivery but never prevent the durable retry from being armed.
- A running snooze can be skipped.
skipOneTimeSnooze({occurrenceToken, alarmIds})validates the exact live owner, arms ordinary wake-check recovery before retiring its snooze, and leaves the once-per-occurrence latch spent. A failed handoff retains the snooze; completion, future scheduled occurrences, and unrelated alarms are unchanged. - It rings even if you are using the phone. Unlike an ordinary wake ring it may sound while the app is active, it counts as audible wake evidence, and on Android the fired attempt bypasses the recent-activity and challenge-visible suppressions once (propagated through receiver, ringing service, notification and alarm activity). It is stopped once the real wake-up challenge becomes visible, and dismissing it resumes the ordinary 10-second cadence.
- Clock and timezone changes preserve the remaining duration, rebasing by the wall-versus-monotonic delta. Every failure mode resolves toward ringing early — a lost monotonic anchor clamps the deadline into the next 30 minutes, and a replacement that cannot be armed keeps the existing one. Nothing about a clock change may make the occurrence silent.
Foreground wake-check bursts use the same native alarm lifecycle on both platforms. The bridge records a burst as audible only after native playback begins, keeps the current alarm alive until it stops, and lets JavaScript start the 10-second retry only after that audible alarm is dismissed or muted.
Android also keeps an unattended wake alarm ringing continuously with its current sound, matching iOS's single-ring policy. It does not stop or advance sounds after 30 seconds. Dismissal or detected interruption still arms the next attempt after 10 seconds, subject to the existing started-walk and snooze rules. Completion, cancellation, challenge handling, and inactivity expiry retain their existing stop behavior.
Fresh scheduled roots always begin a new wake session, including an alarm edited to ring again inside the previous wake window. Completion suppression remains scoped to retries from the completed session.
Android alarm surfaces use a dedicated high-importance full-screen notification channel. The foreground-service notification remains visually interruptive while alarm audio and vibration are owned by the ringing service, preventing notification grouping from suppressing the heads-up or lock-screen surface. In-app sound-only bursts use a separate low-importance channel and do not interrupt the active wake-up check.
Android schedules, occurrence ownership, and per-alarm retries are persisted in device-protected storage. They are restored after locked boot, normal boot, app replacement, clock/timezone changes, and exact-alarm permission changes. Existing credential-protected bridge state is moved forward automatically. Stored schedules are also repaired whenever JavaScript requests native inventory, covering process death and force-stop recovery after the user next opens Ember.
The plugin disables Android Auto Backup and device-transfer restore for app data. Ember uses its explicit account backup instead; restoring JavaScript storage without the matching native alarm store would otherwise create a stale split-brain schedule after reinstall.
The alarm surface wakes above Android's lock screen without requesting keyguard dismissal. Choosing Start wake-up check hands the lock-screen presentation to MainActivity only for that challenge launch; the bridge removes that access as soon as the challenge ends or leaves the foreground. Ember never replaces the wake check with Android's PIN/password prompt.
Only one alarm may own a wake occurrence at a time. The first due alarm keeps ringing and owns the wake-up challenge until it is completed or explicitly abandoned. If it leaves AlarmKit's alerting state without opening Ember, the native bridge schedules exactly one owner-scoped retry for 10 seconds later. A different scheduled alarm that becomes due while that occurrence is active is reported as a silent pass-over notification and never replaces or queues a second challenge. Its next independent scheduled occurrence remains armed. Showing the wake-up challenge or completing it cancels the active session retry. Merely foregrounding Ember does not acknowledge an alarm.
abandonWakeOccurrence(alarmIds) explicitly retires an incomplete occurrence
only when one of the supplied alarm IDs owns it. It cannot clear another alarm's
incumbent occurrence and does not write mission completion. Android also
reasserts shared pre-fire step tracking for any other alarm that remains due
inside the suppression window.
The JavaScript app keeps the native bridge updated through setForegroundAlarmContext.
appActive is advisory; the native activity/application lifecycle is authoritative
so a delayed JavaScript update cannot restore stale foreground state.
wakeSessionEstablished distinguishes a delivered or entered wake session from a
wall-clock-only anchor window. A clock-derived context may preserve an existing
native occurrence but can never create one.
challengeScreenVisible confirms that the wake-up check rendered;
onChallengeScreen confirms subsequent activity.
The rendered signal or mission completion may cancel a pending handoff retry.
Foreground context is heartbeat-scoped and wake-session state carries an explicit, bounded deadline. Native monotonic clock references preserve an active session when the running process observes a manual clock correction. A process crash or device restart cannot leave a stale "challenge visible" flag suppressing later alarms. Alarm actions expire after two minutes and support non-destructive read plus conditional acknowledgement, so a delayed bridge response cannot lose an action or clear a newer one.
While a challenge is visible, native lifecycle state and a resettable inactivity watchdog mirror the app's 30-second activity window plus 15-second countdown. Each real challenge interaction refreshes it. If JavaScript stalls, native code schedules the next burst; if the app backgrounds, the foreground watchdog yields immediately to background pressure. That pressure uses the normal 10-second rearm except for a started walk, which owns the 30-second inactivity fuse below.
During an iOS walk challenge, Ember starts a privacy-minimal HealthKit workout session with its associated live builder, without reading or saving workout metrics. The config plugin declares com.bonkalarm.app.healthkit-workout as a permitted background-task identifier and registers its launch handler before app launch completes. Ember never submits a standalone background-processing request. A walk is not reported as started until HealthKit confirms both the session and live collection in the foreground. Health access is therefore required for saving and starting an iOS walk check. Starting the workout alone does not maintain AlarmKit walk pressure while Ember remains steadily foregrounded. A bounded native state/error journal makes device failures diagnosable without recording health data. On a supported HealthKit process recovery, Ember reattaches the in-progress workout, restarts the native repair poll, and queries only the post-handoff portion of the last 30 seconds for real Core Motion steps.
applicationWillResignActive arms one provisional, root-scoped walk fuse with a 60-second recovery window. This closes the lifecycle gap and gives HealthKit time to preserve or recover step delivery before the alarm becomes audible, but it is not a persistent foreground guard. Returning active from Control Center or a system sheet cancels it. A real applicationDidEnterBackground edge persists the same handoff deadline. The first accepted post-handoff step ends the recovery window and transactionally moves the single steady-state fuse to 30 seconds after the latest step; the five-second scheduling tolerance makes delivery normally land within about 30–35 seconds after walking stops. There is no periodic foreground maintenance alarm, prearmed pair, safety relay, or walk cadence.
While normal iOS background execution and the HealthKit workout remain alive, accepted native movement keeps the step-aware deadline current, including movement delivered during the provisional app-switcher transition. Every rotation is transactional: AlarmKit must accept the replacement before the predecessor is retired. Steady state therefore has exactly one root-scoped fuse; old and new may coexist only for the bounded in-flight transaction, never as a durable pair, relay, or cadence. If a newer activity generation arrives during an AlarmKit await, Ember commits the safe replacement, queues a refresh to the newest deadline, and performs the next rotation with the same arm-before-retire rule. A failed arm leaves the predecessor as loud, OS-durable pressure, while the two-second background step-poll repair may retry as long as execution remains available. If a recovering process sees recent authorized motion before HealthKit has republished the workout, a recovery phase moves the same walk fuse and silences elapsed pressure only after its replacement is accepted. Generic rearm treats recovery as walk ownership, and a generation-scoped takeover serializes recovery/background pressure against workout-loss fallback so neither path can retire the other's only accepted alarm. Publishing the recovered workout transactionally converges that fuse to normal background mode; it never leaves an ordinary fallback beside it. Workout loss returns the occurrence to its ordinary unfinished-challenge policy. Unstarted walks, unrelated alarm roots, and one-time snoozes keep their own timing.
Force-quitting can end the workout-backed execution window. iOS does not promise to relaunch Ember before the accepted fuse fires, so post-force-quit movement cannot be guaranteed to reset its deadline. If iOS does recover the workout process, Ember can credit only authorized Core Motion evidence inside the bounded 30-second post-handoff window and transactionally replace any elapsed pressure; otherwise the daemon-owned fuse fails loud at its committed deadline. No relay, pair, cadence, or maintenance loop pretends to guarantee post-termination step awareness.
Android retries remain bound to the incomplete root occurrence that created them. A retry is discarded when that occurrence completes, expires, is cancelled, or is replaced by another owner; a delayed AlarmManager delivery can therefore never resurrect a previous morning. If a second scheduled root becomes due while another root owns the wake session, it remains in its own durable queue until the current root completes or its bounded occurrence window expires.
Every scheduled-root occurrence is also kept as a bounded, durable incomplete owner for up to 20 hours. Its persisted occurrence token stays unchanged across retries and observed wall-clock corrections, so reopening cannot revive completed challenge progress from an older occurrence of the same repeating alarm. Opening Ember during a silent retry gap can therefore recover the correct alarm and challenge instead of relying on a currently alerting ID or the original 30-minute anchor window. Starting the challenge arms exactly one 10-second native handoff successor before the current alarm is stopped; displaying or completing that owner cancels the successor.
On update, AlarmKit's daemon inventory is authoritative. Existing live alarm IDs are adopted, legacy group ownership is retained, missing bookkeeping is rebuilt, and stale action/context/pending metadata is bounded or discarded without cancelling valid scheduled roots.
AlarmKit does not expose whether a still-alerting alarm's system-owned audio was silenced by the physical mute switch. Ember reliably rearms when a lock/volume-button dismissal, its AlarmKit Stop action, or another observable alarm-state change reports that the alert ended. Under the single-ring policy there is no proactive same-morning backup when the mute switch leaves the alarm in alerting, so that path must be verified on each supported iOS release and evaluated on a real device during the policy trial.
When iOS confirms that a wake occurrence began, Ember also writes a device-only Keychain receipt. Completion, abandonment, schedule retirement, and data reset remove it. If uninstall deletes the app container while the occurrence is unfinished, a fresh install can convert the surviving receipt into the failed wake day before releasing startup, then acknowledges it only after the app-state write succeeds.
Audible Alarm Counting
The bridge records an audible alarm ID only when the alarm reaches alerting state and is not suppressed. JavaScript can also explicitly record the trigger alarm when the app opens from an already-ringing alarm.
Available native methods include:
setForegroundAlarmContext(payload)startForegroundAlarmSound(alarmId, title, body)getAudibleAlarmCount()getAudibleAlarmIds()recordAudibleAlarm(alarmId)resetAudibleAlarmCount()peekLastAlarmAction()acknowledgeLastAlarmAction(alarmId, action, occurredAtMs)getAlertingAlarmIds()getScheduledAlarmTimes()getActiveWakeOccurrence()abandonWakeOccurrence(alarmIds)reconcileStepSuppressionTracking()(Android)stopAlertingAlarms(alarmIds)cancelSystemAlarms(alarmIds)
Packaging
The mobile app may consume this plugin from npm or from a local tarball. When using the local package, repack after source changes:
npm packThen update the mobile dependency or lockfile integrity as needed.
