@klyftig/alarm-core
v0.4.0
Published
Klyftig shared alarm engine for Expo / React Native: reliable killed-state alarms (notifee on Android, AlarmKit + notification fallback on iOS), mission-gated dismissal model, snooze/escalation, permissions & OEM guidance, per-fire + missed-alarm telemetr
Maintainers
Readme
@klyftig/alarm-core
The Klyftig shared alarm engine for Expo / React Native. Extracted from Sleepwise (2026-09-15) so that Sleepwise and the habit app run the same engine: one place to fix reliability bugs, one device test protocol.
What it does:
- Schedules alarms that fire from a killed state, after reboot and over the
lock screen — notifee on Android (exact
TIMESTAMPtrigger,allowWhileIdle, full-screen intent), AlarmKit on iOS 26+ with a time-sensitive-notification fallback below that (one path per alarm, chosen byiosCoordinator). - Rings through silent mode via
@klyftig/alarm-audio(AndroidSTREAM_ALARMforeground service with volume ramp / extra-loud / vibration; iOSAVAudioSession .playback). - Snooze (
snoozeTracker), skip-next-once, "prevent last-minute edits" (editLock), Emergency Escape pledges, missed-alarm detection, permission / OEM-battery guidance (permissions,oemGuidance), a boot-reschedule headless task, and the built-in alarm tone catalog. - The mission data model (
MissionType,MissionConfig,AlarmMission) — the screens live in@klyftig/missions.pushup(0.4.0,{ repCount }, default 10, premium, escapable) is a type whose screen is opt-in:@klyftig/missions/pushup(camera + on-device pose model).
It deliberately has no UI, no analytics and no storage of its own. The app supplies those through the hooks below.
Install
npm i @klyftig/alarm-core @klyftig/alarm-audio @klyftig/alarm-kit @klyftig/android-perms \
@notifee/react-native expo-notifications expo-asset expo-document-picker expo-file-system \
@react-native-async-storage/async-storageapp.config.js:
plugins: [
'@klyftig/alarm-core', // Android perms + lock-screen flags + boot receiver; iOS AlarmKit usage + audio background mode
['expo-notifications', { sounds: ['./node_modules/@klyftig/alarm-core/assets/sounds/alarms/classic.wav'] }],
]The iOS notification fallback uses classic.wav as its bundled sound
(IOS_ALARM_NOTIFICATION_SOUND), which is why expo-notifications must copy it.
Custom entry file (index.js; set "main": "index.js" in package.json):
import 'expo-router/entry';
import { registerBootTask, registerNotifeeBackground } from '@klyftig/alarm-core';
import { loadAlarms } from './src/storage/alarmStorage';
registerBootTask(loadAlarms); // re-arms alarms after reboot / app update
registerNotifeeBackground([]); // notifee needs a background handler; pass your own handlersWire analytics once:
configureAlarmCore({
onMissedAlarm: (m) => analytics.capture('alarm_missed_suspected', { alarm_id: m.alarmId, expected_at: m.expectedAt, delay_ms: m.delayMs }),
});Requires a dev client (native modules) — never Expo Go. Both Klyftig apps build
locally (expo prebuild → Xcode / Gradle).
Using the engine
import { alarmEngine, makeDefaultAlarmInput, nextFireTime, type Alarm } from '@klyftig/alarm-core';
await alarmEngine.setupChannels(); // idempotent, on every launch
const occurrence = await alarmEngine.scheduleAlarm(alarm); // cancels + re-arms; null if disabled / no future occurrence
await alarmEngine.cancelAlarm(alarm.id);
await alarmEngine.snoozeAlarm(alarm, 9);
await alarmEngine.fireNow(alarm, 'Smart Wake • 07:00'); // immediate fire on the alarm's own id
await alarmEngine.scheduleWakeUpCheck(alarm, 3); // one-off re-fire N minutes laterAlarm is the product-agnostic shape. Apps extend it (Sleepwise adds
smartWake / wakeUpCheck) — the engine only reads the core fields, so the
extended object passes straight through. normalizeAlarm is generic and keeps
the extra fields.
Detecting a fired alarm on Android: use notifee.getDisplayedNotifications()
(the full-screen-intent launch leaves getInitialNotification() empty) plus a
foreground poll — see Sleepwise useAlarmFireHandler.
Telemetry hooks (0.2)
The engine emits three signals through configureAlarmCore (one wiring site
per app; the config merges, a hook given twice is replaced):
configureAlarmCore({
onMissedAlarm: (m) => …, // alarm_missed_suspected { alarmId, expectedAt, delayMs } — from reportMissedAlarms() on launch
onAlarmFired: (e) => …, // alarm_fired { alarmId, kind, firedAt, scheduledFor, delayMs } — from recordFired()
onAlarmDismissed: (e) => …, // alarm_dismissed { alarmId, kind, missionAttempts, snoozeCount, msToDismiss, … } — from recordDismissed()
});The dismissal screen drives them:
const kind = fireKindOf(notificationId); // 'alarm' | 'snooze' | 'wakecheck'
const fired = await recordFired(alarm.id, kind); // clears the missed-fire expectation (base fires only), opens the cycle
…
await recordDismissed(alarm.id, 'mission', { missionAttempts }); // BEFORE resetSnoozeCount — it reads the tracker
resetSnoozeCount(alarm.id);recordFired matches the stored expectation only when it is not further than
FIRE_EARLY_TOLERANCE_MS in the future, so a store that re-armed the NEXT
occurrence before the screen mounted never has that expectation wiped. A
snooze / wake-check re-fire continues the cycle opened by the first ring, so
msToDismiss measures from the first ring.
Notification copy (0.3)
The engine writes a few strings itself: the title of an unlabelled alarm, the ringing / snoozed / wake-up-check bodies and the two Android channel names. They default to English; apps pass their own through the same config:
configureAlarmCore({
strings: {
untitled: t('alarm:untitled'), // title when the alarm has no label
alarmBody: (time) => t('alarm:body', { time }), // time = formatAlarmTime(alarm.time)
snoozedBody: (minutes) => t('alarm:snoozed', { count: minutes }),
wakeUpCheckBody: t('alarm:wakeUpCheck'),
alarmChannelName: t('alarm:channel'),
smartWakeChannelName: t('alarm:smartWakeChannel'),
},
});Unset keys stay English. The copy is baked into a notification when it is
scheduled, so call configureAlarmCore again after a language switch and
re-arm every alarm; channel names follow on the next setupChannels().
AlarmKit's own Stop / Open buttons (iOS 26+) are still English — they live in
@klyftig/alarm-kit's Swift. oemGuide() steps are English and name
Sleepwise.
Time and timezone changes (0.2)
A notifee trigger is an absolute epoch-ms computed for a wall-clock time in
the zone current at scheduling. The config plugin's receiver therefore also
listens for ACTION_TIMEZONE_CHANGED and ACTION_TIME_CHANGED
(android.intent.action.TIME_SET) and runs the same headless
AlarmBootReschedule task as after a reboot, so the closed-app case is
covered on Android. For the open-app case on both platforms (and for iOS,
which has no receiver) call reconcileScheduled(alarms) on launch and on
every return to the foreground: it compares listScheduled() with each
enabled alarm's nextFireTime and re-arms only what drifted (findDrift is
the pure part). An app that already re-arms every alarm on foreground does
not need it.
Hard-won device gotchas (read before touching the Android path)
POST_NOTIFICATIONSmust be granted or the whole alarm is silently suppressed — ask for it first in onboarding.- Alarm audio must play on
STREAM_ALARM; notifee's notification sound is muted on vibrate. Never route the ring through expo-audio. - DND bypass needs notification-policy access (
@klyftig/android-permsisNotificationPolicyAccessGranted), asked separately. - Android 14+ grants
USE_FULL_SCREEN_INTENTautomatically only to alarm/calling-category apps. ProbecanUseFullScreenIntent()and deep-link to the grant screen when false (openPermissionScreen.fullScreenIntent). - OxygenOS force-kills the process when the user swipes the ringing
notification away.
@klyftig/alarm-audio's service usesstopWithTask=falseSTART_REDELIVER_INTENTso audio self-heals in ~5 s; a determined swipe-escape is not solvable in-app. Don't rabbit-hole.
- notifee refuses a trigger whose timestamp is not strictly in the future, so
an immediate fire must go through
displayNotification— that is whatfireNowdoes on Android (0.2.1; before that it silently never rang). - Under
--configure-on-demand, notifee's local maven repo may not be registered before:appresolves its classpath; the config plugin adds it to the rootbuild.gradlesoapp.notifee:corealways resolves.
Validated on a OnePlus CPH2769 (Android 16): fires from killed state, after
reboot, over the lock screen, on silent. The iOS path has never run on a
physical iPhone (simulator build only) — see TEST_PROTOCOL.md.
Known debt
- Kotlin/Swift module identifiers still carry the
Sleepwise…names (expo.modules.sleepwisealarmaudio,SleepwiseAlarmKit, …). Renaming is churn with no user value; do it only with a coordinated release of all four packages. - Smart-Wake checkpoint id helpers (
smartWakeCheckId,SMARTWAKE_CHECK_CHANNEL_ID) are generic silent-checkpoint primitives with a product-flavoured name. - The timezone / clock-change receiver and the foreground reconcile (0.2) have
not been exercised on a device yet — see
TEST_PROTOCOL.md.
Development
npm install # devDependencies; the three native packages are local tarballs until published
npm test # jest-expo, 133 tests
npm run typecheck
npm run pack-local # → ~/Code/.local-packages/klyftig-alarm-core-<v>.tgz for consumersPublish like @klyftig/mobile-ui: bump version → npm publish → bump in the
app → rm -rf node_modules/@klyftig/* node_modules/.cache && npm install && npx expo start --clear.
Metro reads the TypeScript source directly; there is no build step.
