About Projects Experience Contact
Back to Projects

NeoAlarm

Reliability-first Android alarm app. Flutter UI shell backed by a native Kotlin alarm engine that survives Doze, process death, reboots, and lock-screen constraints. Mission-based dismissal enforced natively, not just visually.

NeoAlarm Home Screen Active Alarm Screen

Overview

Most alarm apps are easy to style and hard to trust. NeoAlarm is designed as a reliability product, not a clock widget with extra screens. The app is intentionally split into two execution domains: Flutter owns the product surface — dashboard, alarm editing, mission UI, diagnostics. Kotlin owns everything that must remain correct even if Flutter isn't alive — exact scheduling via AlarmManager.setAlarmClock(), foreground ringing, session persistence, lock-screen launch, inactivity enforcement, and dismissal authorization.

That split is a systems boundary, not a convenience choice. When the authority line between UI and engine is blurry, alarm apps accumulate hidden failure modes: UI state acts like source of truth, mission progress becomes route-dependent, and background enforcement depends on a live isolate.

The Scale-Up Struggle & Engineering Highlights

This system wasn't just built; it was hardened against the reality of Android alarm execution: overnight Doze, reclaimed processes, reboots before unlock, and sensor-driven missions that demand real-time enforcement.

The Flutter Trust Boundary

The Problem: Flutter is great for UI velocity, but an alarm app has to survive overnight Doze, reclaimed app processes, boot events, and lock-screen launch constraints. If alarm-critical behavior depends on a live Flutter isolate, the alarm is unreliable.

The Fix: A strict Dart/Kotlin systems boundary. Flutter sends intents only — it can configure missions and render progress, but it cannot decide that a mission is complete and cannot dismiss an alarm on its own. The native alarm engine starts audio, vibration, and session persistence before any Flutter UI is required. Recovery after process death restores directly into the active alarm flow from persisted native state.

Mission Anti-Cheat — Earned Silence

The Problem: Naive mission alarm: screen opens → alarm goes silent → user ignores the mission. Opening a screen is not evidence of real engagement. A user can tap into the mission and then do nothing, stalling forever.

The Fix: A three-layer enforcement model. First, a confirmation step creates a deliberate transition from coercion to effort — the alarm stays loud until the user explicitly accepts the mission. Second, native inactivity enforcement revokes silence after 30 seconds of no mission-valid activity. Third, mission-specific activity validation — math uses answer submission, steps uses TYPE_STEP_DETECTOR events, generic screen taps don't count.

The Overlapping Alarm Bug

The Problem: The original ringing engine used a single persisted session slot. If one alarm was already active and another fired, the second alarm overwrote the stored session. The interrupted alarm was lost, in-progress mission state was stomped, and stale timers could fire for sessions that no longer existed. This is a systems correctness bug, not a missing feature.

The Fix: A persisted session stack instead of a single slot. A newly fired alarm preempts by pushing to the top. The interrupted session is preserved beneath it with its inactivity timer canceled. Once the top alarm is dismissed or snoozed, the next preserved session resumes ringing. Flutter renders only the top active session; native Android preserves everything else.

Direct-Boot Persistence

The Problem: Android clears all scheduled alarms on reboot. If the app reads alarm definitions from credential-protected storage, it can't restore schedules until after the user's first unlock. That's too late — the 6 AM alarm fires before the user unlocks the phone.

The Fix: Alarm definitions and active ring-session state live in device-protected storage. Reschedule receivers handle LOCKED_BOOT_COMPLETED, BOOT_COMPLETED, time changes, and timezone changes. Flutter startup before first unlock stays minimal — no nonessential plugin initialization, no credential-protected assumptions. The native alarm engine can rebuild exact schedules before the user ever touches the lock screen.

Reusable Native Vision Pipeline

The Problem: The QR mission needs camera access and barcode scanning. A disposable plugin tied to one screen would make future object-recognition missions require rewriting the camera stack.

The Fix: A reusable native vision pipeline. CameraX handles preview and frame analysis, ML Kit barcode scanning runs as a swappable VisionAnalyzer, and session startup is idempotent — re-entering the same camera config reuses the existing session. Vision resources are disposed explicitly with the activity lifecycle. Future TinyML or TFLite missions become an analyzer swap, not a camera rebuild.

Playback Engine Migration

The Problem: The original alarm playback used Android's Ringtone API — simple but incapable of per-instance volume control. Users wanted gradual volume ramp, but Ringtone offers no volume API worth using. Worse, after reboot before first unlock, the user's default alarm tone fails to resolve reliably on some OEMs.

The Fix: Full migration to MediaPlayer with a dedicated AlarmPlaybackController extracted from the ringing service. Volume ramp is per-alarm opt-in, ramping the player instance rather than mutating the global alarm stream. Extra Loud mode attaches a LoudnessEnhancer (+200 mB) only on speaker-safe output routes — wired headphones, Bluetooth, and USB audio are excluded. Before first unlock, the engine uses a bundled direct-boot-safe fallback tone from raw resources instead of trusting OEM-dependent media resolution.

Custom Tone Import Pipeline

The Problem: User-selected tones can disappear, lose permission, point at dead URIs, be oversized, or simply fail to play at alarm time. If a custom tone silently fails, the alarm is effectively muted — the worst possible outcome for a reliability product.

The Fix: A conservative import pipeline. Tone selection via Android's document picker with MIME-type validation (MP3/WAV only, checked via ContentResolver.getType(), not file extension). Imports capped at 15 MB. The app copies into app-managed storage first, falling back to a persistable URI reference only on copy failure (partial files cleaned up). If a configured tone later becomes unavailable, the alarm still fires with the bundled fallback tone and Flutter surfaces a repair warning. Before first unlock after reboot, custom tones are never trusted — always the direct-boot-safe fallback.

Device Performance & Storage Audit

The Problem: Debug builds reported ~300 MB of storage. Was the app actually bloated? Were there hidden CPU costs or background work leaking from dependencies?

The Investigation: A full adb-driven audit on a real Samsung device proved the storage scare was debug-only: the release APK is 68 MB vs 214 MB debug (Flutter kernel blob + duplicated debug assets). Cold-start timing via am start -W came in at 212–229 ms. Idle CPU: 0.0%. No lingering foreground service on the dashboard. dumpsys alarm showed clean exact-alarm scheduling with no background timer noise. The only recurring background work came from ML Kit's datatransport.runtime — tracked and documented, not ignored. A long-idle Doze test confirmed alarm delivery after 75 minutes of screen-off idle.

Architecture

flowchart LR
  subgraph Flutter["Flutter Shell"]
    Dashboard["Dashboard / Settings"]
    Editor["Alarm Editor + Tone UI"]
    MissionUI["Mission UI Drivers"]
    SessionStream["Active Session Rendering"]
  end

  subgraph Native["Native Android Alarm Engine"]
    Scheduler["AlarmManager + Reschedule Receivers"]
    SessionStore["AlarmStore + RingSessionStore"]
    Coordinator["AlarmSessionCoordinator"]
    Service["AlarmRingingService"]
    Playback["AlarmPlaybackController"]
    ToneLib["ToneLibraryManager"]
    Runtime["Mission Runtimes"]
    Vision["CameraX + ML Kit Vision Pipeline"]
  end

  Dashboard -->|"alarm CRUD + diagnostics"| Scheduler
  Editor -->|"AlarmSpec + MissionSpec"| Scheduler
  Editor -->|"tone import"| ToneLib
  Scheduler -->|"exact wake-up"| Service
  Service --> Playback
  Service --> SessionStore
  Service --> Runtime
  Playback --> ToneLib
  Runtime --> SessionStore
  Coordinator --> SessionStore
  Coordinator --> Scheduler
  Vision --> Runtime
  MissionUI -->|"user intents only"| Runtime
  SessionStore -->|"top active session stream"| SessionStream
                    

Runtime Model

  • Alarm Delivery: AlarmManager.setAlarmClock() exact scheduling. Native foreground service starts immediately on broadcast — audio, vibration, and wakelock begin before Flutter UI is required.
  • Playback: MediaPlayer-based with per-alarm volume ramp, speaker-only LoudnessEnhancer, custom tone import with MIME validation, and a bundled direct-boot-safe fallback tone.
  • Session State Machine: Three persisted states — ringing, mission_active, snoozed. Each state changes what the system is allowed to do: whether audio plays, whether dismissal is legal, whether timeouts re-trigger.
  • Mission Platform: Modular mission contract — the alarm engine decides when a mission is required, a mission driver evaluates completion, and the UI renders progress. New missions plug in without re-auditing the scheduler.
  • Recovery: Process death → native session state is authoritative. Relaunch restores into the active mission flow. Reboot → direct-boot receivers rebuild exact schedules from device-protected storage.
  • Performance: 229 ms cold start on device, 0.0% idle CPU, 68 MB release APK. Macrobenchmark + Perfetto for repeatable measurement. Gradle dependency audit to track ML Kit background work.