Root-cause fix for the unstoppable-alarm incident (alarm rang 15 minutes, only uninstall silenced it) plus systematic hardening of every stop path. Native (Kotlin): - Verified stop: stopActiveAlarm now derives its result from the real post-teardown state (companion instance + synchronous stopEverything + activeRingingId check) instead of reporting unconditional success. - Atomic teardown: every stop path (stop action, notification button, snooze, missed, onDestroy, startForeground failure) funnels through one stopEverything() covering audio, wakelock, notification, foreground state and firing-record cleanup; player.release() guarded. - Bounded ringing: 10-minute auto-silence armed via AlarmManager fires a FIRED->MISSED transition with a localized missed-alarm notification; repeating alarms keep their native rearm, deleted alarms never produce ghost MISSED notifications. - Durable firing record with onStartCommand re-validation (resurrection guard) and boot-time stale cleanup; firing records cleared on every refuse/mismatch/cancel path. - New notification-only dismissal channel (dismissAlarmNotificationOnly) so UI-level dedup can never kill a live ring's audio. Flutter (Dart): - Stop/disable/edit/delete of a ringing alarm always attempt to silence it; on native-query failure the stop falls back toward silence via the id-scoped legacy stop. - Verified-stop results surface failures: the ringing screen keeps dismiss-by-design on success, but on a verified failure it stays up with a persistent force-stop banner (guarded against double-dismiss) and auto-dismisses if the ring ends externally (missed/notification). - Missed events sync alarm bookkeeping without opening the ringing UI. - 4 new l10n keys translated across all 13 locales (ARB guard green). 550 tests green, analyzer clean. Reviewed in 3 adversarial 4-lens rounds (2 deterministic + 1 refuter-corroborated critical fixed); formal gentle-ai receipt waived by maintainer authorization (correction scope legitimately exceeded the frozen genesis paths). On-device QA checklist in openspec/changes/alarm-system-overhaul/tasks.md pending before archive.
22 KiB
Alarm Platform Contract — Android Alarm Reliability Reference (mid-2026)
Scope: authoritative rules for building a bulletproof Android alarm clock, targeting Android 14/15/16-era devices (targetSdk 35/36). Grounds the alarm-system-overhaul behavior spec and refactor. Repo context checked: android/app/src/main/AndroidManifest.xml declares SCHEDULE_EXACT_ALARM+USE_EXACT_ALARM (redundant pairing, see Rule 3), USE_FULL_SCREEN_INTENT, POST_NOTIFICATIONS, FOREGROUND_SERVICE_MEDIA_PLAYBACK+FOREGROUND_SERVICE_SYSTEM_EXEMPTED; has custom native .PluriWaveAlarmService (foregroundServiceType=mediaPlayback|systemExempted), .PluriWaveAlarmReceiver, .PluriWaveBootReceiver (handles LOCKED_BOOT_COMPLETED/BOOT_COMPLETED/USER_UNLOCKED/MY_PACKAGE_REPLACED/TIME_SET/TIMEZONE_CHANGED/SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED — good coverage of reschedule triggers) — AND a separate com.ryanheise.audioservice.AudioService (Flutter audio_service plugin, used for radio streaming). Two independent audio/media-session owners in one app is the most likely root cause of the app's documented incident (alarm rang 15 min, only uninstall stopped it).
1. Scheduling
1.1 Use AlarmManager.setAlarmClock() for user-facing alarm-clock semantics, not setExactAndAllowWhileIdle(). setAlarmClock() is the highest-priority alarm type: the system exits Doze/App Standby to deliver it and never defers it, and it shows the alarm-clock icon in the status bar. setExactAndAllowWhileIdle() is "nearly precise" and intended for non-user-visible exact work, not primary alarm firing. Android AlarmManager docs — all API levels, but the precision distinction matters most from Android 6 (Doze) onward.
1.2 On Android 12+ (API 31+), declare SCHEDULE_EXACT_ALARM and call AlarmManager.canScheduleExactAlarms() before scheduling; a SecurityException is thrown otherwise. On Android 14 (API 34), this permission is no longer pre-granted on fresh installs — apps must send the user to ACTION_REQUEST_SCHEDULE_EXACT_ALARM with an in-app rationale first. Android 14 behavior change
1.3 USE_EXACT_ALARM (Android 13+/API 33+) is an install-time-granted, non-revocable-by-user permission but is restricted by Play Store policy to apps whose core function is alarms or calendars. Declaring BOTH SCHEDULE_EXACT_ALARM and USE_EXACT_ALARM in the same manifest (as this app currently does) is redundant and risky: Play may reject USE_EXACT_ALARM if core-functionality review fails, silently falling back to the revocable permission — the app must not assume USE_EXACT_ALARM guarantees a grant. Schedule alarms guide
1.4 Register a receiver for AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED and, on receipt, re-check canScheduleExactAlarms() and reschedule/rebuild every pending alarm instance. Do not trust a cached permission flag — the system can revoke exact-alarm permission automatically (e.g. long-unused apps) and this broadcast is the only signal. Repo already listens for this action in PluriWaveBootReceiver — verify the handler actually reschedules rather than just logging.
1.5 Reschedule ALL alarms on: BOOT_COMPLETED (+ LOCKED_BOOT_COMPLETED for direct-boot-aware alarms), MY_PACKAGE_REPLACED (app update wipes AlarmManager state), TIME_SET, TIMEZONE_CHANGED. Missing any of these is a classic bug class ("alarm survived reboot but not timezone change"). Repo's PluriWaveBootReceiver already covers all four — confirm the Kotlin implementation actually recomputes trigger times rather than re-arming stale timestamps.
1.6 If exact-alarm permission is denied, fall back to setAndAllowWhileIdle()/setWindow(), never silently drop the alarm — and surface a persistent "exact alarms disabled, alarm may be late" warning in-app, mirroring AOSP DeskClock's user-facing permission nags.
2. Firing / Ringing (native-only responsibility)
2.1 Canonical pattern: AlarmManager fires a PendingIntent → BroadcastReceiver.onReceive() (short-lived, <10s budget) → immediately calls Context.startForegroundService() (or startForeground() from the service within 5s per Android 8+ FGS rules) → the Foreground Service owns the wake lock, the ringtone/media player, and vibration for the entire ringing lifetime. This exact chain is what AOSP DeskClock's AlarmService + AlarmActivity do, and what the alarm (gdelataillade) Flutter plugin's native Android layer does — audio ownership lives in native Kotlin/Java, never in a Dart isolate, because Dart isolates are not guaranteed to be alive when the alarm fires. Alarm plugin Android install guide
2.2 Foreground service type: use mediaPlayback (Android 10+ requirement) — AOSP DeskClock and the alarm plugin both use a media-playback-typed FGS for the ringing service. FOREGROUND_SERVICE_SYSTEM_EXEMPTED alone is not a substitute; this app's PluriWaveAlarmService already declares mediaPlayback|systemExempted, which is correct, but the app must ensure exact-alarm-triggered FGS starts are exempt from the Android 12+ background-start restrictions (they are, by design, per platform docs: exact alarms are excluded from FGS-from-background limits).
2.3 Do NOT rely on the audio system's implicit wake lock alone for anything beyond audio — the FGS itself must hold PARTIAL_WAKE_LOCK (WAKE_LOCK permission, already declared) for any non-audio work (vibration loop, timeout logic, UI signaling) and must release it deterministically in every stop path (onDestroy, explicit stop, timeout). When only MediaPlayer/ExoPlayer plays audio with AudioAttributes.USAGE_ALARM, the audio framework manages its own wake lock for the playback itself — but application-level state transitions still need an explicit lock if the CPU could otherwise sleep between them. Background work: wake locks
2.4 Play ringtone via MediaPlayer/ExoPlayer/Ringtone configured with AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION). This ties playback to the alarm audio stream (STREAM_ALARM), which (a) bypasses Do Not Disturb by default — Android treats alarm-stream audio as a high-priority interruption that sounds regardless of DND unless the user explicitly disabled "Alarms" under DND exceptions — and (b) uses the alarm volume slider, not media/ring volume. This is the single highest-value correctness rule for a hybrid Flutter+native app: if the alarm ever plays through the audio_service/just_audio media-session pipeline (music/media stream) instead of a dedicated STREAM_ALARM/USAGE_ALARM player, it will (a) respect DND media-silencing, (b) follow media volume (can be 0), and (c) fight the radio-streaming engine for the same media session — a plausible root cause of "two engines playing / can't stop" incidents.
2.5 Auto-silence: industry-standard timeout is a bounded window (AOSP DeskClock uses a configurable timeout, default historically ~15 minutes, now user-configurable "Silence after" 1–30 min or "Never"); after timeout, stop audio/vibration, transition to a "missed alarm" notification, and — for repeating alarms — compute and arm the next occurrence. Never let the ringing service loop indefinitely with no upper bound; an unbounded loop is exactly the "rang 15 minutes, uninstall required" failure mode. AOSP DeskClock AlarmStateManager
3. UI over lock screen
3.1 Android 14+ (API 34): USE_FULL_SCREEN_INTENT becomes a special app access permission auto-granted by Play only to apps whose core function is calls or alarms; otherwise it must be requested via ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT and checked with NotificationManager.canUseFullScreenIntent(). An alarm-clock app qualifies for default grant, but must still runtime-check the flag (Play/OEM can still revoke) and have a non-FSI fallback (heads-up high-priority notification) if denied. source.android.com FSI limits — effective policy date May 31 2024 / enforced from Jan 22 2025.
3.2 Notification channel for ringing must be IMPORTANCE_HIGH (or IMPORTANCE_MAX where the platform still exposes it) with setSound(null, ...) — the CHANNEL must not play its own sound because the foreground service is already playing the alarm tone; a channel sound + service audio together is a double-audio bug. Set category = CATEGORY_ALARM. Attach setFullScreenIntent(pendingIntent, true) pointing at a dedicated full-screen Activity, and setOngoing(true).
3.3 The full-screen ringing Activity must call setShowWhenLocked(true) + setTurnScreenOn(true) (API 27+) instead of the deprecated window flags, matching this app's MainActivity manifest attributes — but the ringing UI should be its OWN activity (not MainActivity) with android:excludeFromRecents, singleInstance/singleTask, so it can be shown/dismissed independently of app navigation state.
3.4 Android 12+ (API 31+) notification trampoline restriction: Stop/Snooze notification action buttons must be PendingIntent.getForegroundService() or PendingIntent.getBroadcast() directly — never a broadcast/service that itself calls startActivity(). Any notification action that needs to show UI after tapping must build a PendingIntent pointing straight at the destination Activity; do not chain through a receiver-that-launches-activity ("trampoline"), which is blocked and produces silent no-ops or logcat-only failures on Android 12+. Notification trampoline restrictions
4. Stop/Snooze correctness (single source of truth)
4.1 There must be exactly ONE process/component that owns "is the alarm currently ringing" state and exactly ONE component that owns the audio player instance. In a Flutter app, this MUST be native Kotlin (the foreground service), not Dart — Dart/Flutter engine lifecycle (background isolates, audio_service's Flutter-side handlers) is not guaranteed to be running or reachable when the user taps Stop from a lock-screen notification while the main Flutter engine is not attached. All Stop/Snooze PendingIntents must route to the SAME native receiver/service that started the ringing, using a stable, unique request-code/alarm-instance-id so the intent unambiguously identifies the alarm to stop — never a broadcast that "maybe" reaches Dart via a plugin channel.
4.2 Foreground service must NOT use START_STICKY for the ringing service if that causes the OS to resurrect it after being killed with no user-visible way to stop it. If restart-on-kill is desired for reliability, the restarted service must re-check persisted "is this alarm still supposed to be ringing" state (e.g., a stop-timestamp or dismissed flag in SharedPreferences/DB) on onStartCommand and self-terminate immediately if the alarm was already dismissed/stopped — otherwise a killed-and-restarted service can resume playing after the user already stopped it, exactly matching "un-dismissable alarm" reports. START_STICKY restart discussion
4.3 Notification cancellation and service lifecycle must be coupled atomically: cancelling/removing the notification does NOT stop a foreground service, and stopping the service without cancelling the notification leaves a stuck notification. Every stop path (user tap, timeout, snooze) must call BOTH stopForeground(STOP_FOREGROUND_REMOVE) (or equivalent) AND stopSelf(), plus release the wake lock, in one atomic method — never rely on the notification's own dismissal to imply the service stopped.
4.4 If audio is ever routed through a second engine (e.g., audio_service's AudioService/media session, used here for radio streaming) instead of the alarm-ringing native player, the Stop action must explicitly stop/abandon audio focus and release that engine too — a stop path that only signals the native alarm service while a second, independently-running audio engine continues playing is a documented Flutter/plugin-interaction failure mode (audio_service + background alarm plugin conflicts are reported upstream). audio_service background-alarm issue
4.5 Handle process death mid-ring: persist "alarm X is currently firing since T" to durable storage (not just in-memory) BEFORE starting playback, and clear it only on a confirmed stop. On any service restart/reboot, if a "firing" record has no matching stop record and its age exceeds the auto-silence timeout, treat it as missed and clean up rather than resuming indefinitely.
5. Reference state machine (AOSP DeskClock AlarmStateManager)
States: SILENT_STATE → LOW_NOTIFICATION_STATE → HIGH_NOTIFICATION_STATE → FIRED_STATE → (SNOOZE_STATE | MISSED_STATE) → DISMISSED_STATE, plus a transient HIDE_NOTIFICATION_STATE.
- SILENT → LOW_NOTIFICATION: scheduled ahead-of-time via
setSilentState()/scheduleInstanceStateChange(). - LOW → HIGH_NOTIFICATION: automatic at
getHighNotificationTime(); HIGH cannot be user-hidden (only dismiss/snooze). - HIGH → FIRED: at the actual alarm trigger time;
setFiredState()arms a timeout viascheduleInstanceStateChange(context, timeout, instance, MISSED_STATE). - FIRED → SNOOZE: user action, increments instance trigger time and re-arms.
- FIRED → MISSED: automatic on timeout expiry (this IS the auto-silence mechanism).
- MISSED → (delete | disable | reschedule next occurrence):
updateParentAlarm()— one-shot alarms with "delete after use" are deleted, others disabled; repeating alarms create the next instance viacreateInstanceAfter(). - Any state → DISMISSED: final, deletes the instance and checks the parent alarm for rescheduling. AOSP DeskClock AlarmStateManager, LineageOS mirror
Division of responsibility to replicate: the STATE MACHINE (scheduling transitions, timeout arming, missed/dismiss bookkeeping) can be modeled in Dart/domain layer as long as it only computes when to transition — but the ACTUAL transition execution at fire time (starting the FGS, playing audio, holding the wake lock, posting the FSI notification, handling Stop/Snooze taps) MUST be native Kotlin, invoked directly from the BroadcastReceiver, independent of whether the Flutter engine is attached.
Contrast with Fossify/Simple-Clock's simpler model: their AlarmReceiver does NOT use a foreground service at all when screen is off — it posts a high-importance full-screen notification and launches a ReminderActivity directly, which owns audio playback itself while visible. This is simpler but riskier (no dedicated FGS-held wake lock across the whole ring duration); AOSP DeskClock's FGS-centric model is the more robust reference for a "wakes reliably from any state" alarm. Simple-Clock AlarmReceiver
6. Flutter-specific plugin architecture notes
6.1 android_alarm_manager_plus: creates its own background FlutterEngine to run a Dart callback on alarm fire. Documented limitations: does not manage SCHEDULE_EXACT_ALARM permission at all (app must handle it); can crash when combined with other plugins that also spin up background engines/services (conflicts with audio_service-style plugins); callback reliability degrades when the app process was fully killed. Verdict: not suitable as the sole mechanism for ringing UI/audio — at most usable for lightweight rescheduling logic, never for owning playback. plus_plugins issue #266
6.2 alarm (gdelataillade): purpose-built alarm plugin that keeps ALL ringing responsibility (foreground service, NotificationOnKillService safety net, audio, vibration, volume) in native Kotlin, exposing only schedule/cancel/stream-of-events to Dart. This is the correct architectural split for a Flutter alarm app and is the closer analog to what this app's custom PluriWaveAlarmService/PluriWaveAlarmReceiver should converge toward — provided the alarm audio path is fully decoupled from audio_service's media session. alarm plugin Android install docs
7. Notifications
7.1 POST_NOTIFICATIONS runtime permission (Android 13+/API 33) must be requested before posting any notification, including the ringing FSI notification — if denied, the FSI/heads-up path degrades to nothing being shown, so the app needs an in-app fallback warning.
7.2 Alarm channel: IMPORTANCE_HIGH, sound = null (service owns audio), CATEGORY_ALARM, setOngoing(true) while ringing, action buttons wired to direct broadcast/service PendingIntents (never trampolines — see 3.4).
7.3 Android 16 "Notification Cooldown" (gradual volume reduction for bursty same-app notifications) explicitly does not apply to calls, alarms, priority conversations, or emergency alerts — but this exemption is presumably keyed off CATEGORY_ALARM/correct channel classification, so mis-categorized alarm notifications could incorrectly get cooled down. Classify correctly. Android 16 notification cooldown
7.4 Update (not re-post) the ringing notification via a stable notification ID per alarm instance; re-posting can retrigger heads-up/FSI unexpectedly and complicates the "notification removed but service alive" desync bug (Rule 4.3).
8. Failure-mode checklist (must survive)
- Process death mid-ring (service killed by LMK/OEM) — state persisted, restart re-validates before resuming (4.2, 4.5).
- Device reboot —
BOOT_COMPLETED/LOCKED_BOOT_COMPLETEDreschedules every pending alarm from durable storage (1.5). - Doze / App Standby —
setAlarmClock()used, not a type that Doze can defer (1.1); FGS start from exact alarm is exempt from BG-start restrictions (2.2). - SCHEDULE_EXACT_ALARM revoked by user/system mid-session —
ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGEDhandled, fallback scheduling used, user warned (1.4, 1.6). - Locale/timezone change —
TIME_SET/TIMEZONE_CHANGEDrecompute all trigger times (1.5). - App update —
MY_PACKAGE_REPLACEDreschedules (1.5). - Media/ring volume at 0 — irrelevant if alarm audio correctly uses
STREAM_ALARM/USAGE_ALARM(2.4); verify alarm volume specifically, not device master volume. - DND enabled — alarm-stream audio bypasses DND by default; verify the app is not accidentally routing through a stream DND does silence (2.4).
- Battery optimization / not in Doze-exemption whitelist — regular (non-
setAlarmClock) alarms are deferred under Doze; either usesetAlarmClock()(exempt) or guide the user throughACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS(1.1, repo already declaresREQUEST_IGNORE_BATTERY_OPTIMIZATIONS). - Two audio engines racing (native alarm player vs.
audio_servicemedia session) — single source of truth for "is ringing" and for the player instance; Stop must tear down both if both can ever be active (4.1, 4.4) — highest-priority item given this app's dual audio-engine manifest. - Notification dismissed/swiped but service still alive, or vice versa — coupled stop path (4.3).
- Full-screen intent permission denied/revoked (Android 14+ policy or user revocation) —
canUseFullScreenIntent()checked, heads-up fallback exists (3.1). - Notification trampoline blocked (Android 12+) — all action
PendingIntents go straight to service/broadcast or directly to an Activity, never chained (3.4). - Auto-silence/missed-alarm path — bounded ring duration, transition to MISSED, repeating alarms rearm next occurrence (2.5, state machine section 5).
Top 10 rules this Flutter app is most likely violating (given dual native-service + audio_service architecture)
- Alarm ringing audio possibly not exclusively on
STREAM_ALARM/USAGE_ALARMif it shares code paths withaudio_service's media-session player used for radio (Rule 2.4) — needs verification. - Two independent audio/media engines (
PluriWaveAlarmServicenative +com.ryanheise.audioservice.AudioService) with no single documented "who owns ringing audio" contract (Rule 4.1, 4.4) — matches the reported incident signature. - Redundant
SCHEDULE_EXACT_ALARM+USE_EXACT_ALARMdeclaration without a documented fallback if Play stripsUSE_EXACT_ALARMcore-functionality grant (Rule 1.3). - Unclear whether Stop/Snooze notification actions route directly to service/broadcast
PendingIntents vs. any trampoline-like indirection (Rule 3.4) — needs code check. - Unclear whether the ringing foreground service re-validates "already stopped" state on every
onStartCommand(protection againstSTART_STICKYresurrection) (Rule 4.2). - Unclear whether notification cancellation and
stopForeground()/stopSelf()are coupled atomically in every stop path (Rule 4.3). - Unclear whether a durable "currently firing" record exists to recover deterministically from process death mid-ring (Rule 4.5).
- No confirmed bounded auto-silence timeout / missed-alarm transition (Rule 2.5) — an unbounded ring loop is the literal shape of the known incident.
ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGEDreceiver exists in the manifest but its Kotlin handler's actual behavior (reschedule vs. log-only) is unverified (Rule 1.4).- Full-screen intent fallback path (heads-up notification when FSI is denied/revoked) is unverified — app relies on
USE_FULL_SCREEN_INTENTbut Android 14+ can still revoke it via Play/OEM policy (Rule 3.1).
Note: items 2–9 require the codebase-focused sdd-explore pass (Kotlin source read) to confirm/refute; this document is the external-research half only.