Android-first, local-first, open source alarm app built with Flutter and a native Android alarm engine.
NeoAlarm exists to provide a mission-driven alarm experience without ads, subscriptions, cloud dependency, or a fragile "Flutter-only" runtime model. The product surface is Flutter. The alarm-critical behavior is native Android.
Status: alpha
Platform: Android 10+ (minSdk 29)
Application ID:dev.neoalarm.app
Most alarm apps are easy to style and hard to trust. NeoAlarm is designed as a reliability product first:
- exact alarm scheduling through Android's native alarm stack
- foreground ringing service that survives Flutter isolate loss
- direct-boot-aware persistence for reboot recovery before first unlock
- mission-based dismissal that is enforced natively, not just visually
- fully local-first behavior with no backend and no account requirement
Implemented today:
- multiple one-time and repeating alarms
- per-alarm time mode: follow device time or pin to a specific timezone
- exact scheduling via
AlarmManager.setAlarmClock() - native persistence and reschedule on boot, package replace, time change, and timezone change
- foreground ringing service with looping audio, vibration, and full-screen recovery
Skip nextfor repeating alarms without disabling the full schedule- per-alarm
Volume ramp uptoggle - per-alarm
Extra loud modewith conservative speaker-only boost - reusable custom tone imports with per-alarm tone selection
- snooze duration and max-snooze limits
- active ring-session recovery after process death
- diagnostics and permission repair flows
- location alarms powered by MapLibre rendering with OpenFreeMap Liberty, Photon search, and optional OpenCage reverse geocoding for dropped pins
- first-run onboarding flow for exact alarms, notifications, and battery optimization
- math mission with configurable difficulty and problem count
- steps mission with
TYPE_STEP_DETECTORprogress and cadence filtering - QR mission backed by a reusable native vision pipeline
- quiet timer sourced from the persisted native timeout deadline
MediaPlayer-based alarm playback with a bundled direct-boot-safe fallback tone for reboot-before-unlock alarms- custom tone validation with MIME checks, provider-reported size checks, and fallback-to-warning behavior when a tone source disappears
Current playback note:
Volume ramp upandExtra loud modeare independent per-alarm options- when both are enabled, the player ramps upward normally and the conservative speaker-only loudness boost is applied on top of that playback session
- minified release builds, CodeQL, dependency review, and release artifact workflows
![]() Home · Light |
![]() Settings · Dark |
![]() Home · Dark |
![]() Alarm Editor |
![]() Time Picker · Dark |
![]() Time Picker · Light |
![]() Active Alarm |
![]() Math Mission |
![]() Steps Mission |
![]() QR Mission |
![]() Set QR Target |
- Local-first: no backend, no account, no ads, no subscription model
- Native authority: dismissal, ringing, scheduling, and recovery stay on Android-native code
- Honest anti-cheat: mission silence is conditional and enforced by native state
- Permission discipline: first-run onboarding covers alarm-critical Android controls, while mission-specific permissions stay contextual
- Extensible architecture: new missions should plug into the platform without rewriting the scheduler
NeoAlarm is intentionally split into two execution domains.
flowchart LR
subgraph Flutter["Flutter Shell"]
Dashboard["Dashboard / Settings"]
Editor["Alarm Editor"]
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"]
Runtime["Mission Runtimes"]
Vision["CameraX + ML Kit Vision Pipeline"]
end
Dashboard -->|"alarm CRUD + diagnostics"| Scheduler
Editor -->|"AlarmSpec + MissionSpec"| Scheduler
Scheduler -->|"exact wake-up"| Service
Service --> SessionStore
Service --> Runtime
Runtime --> SessionStore
Coordinator --> SessionStore
Coordinator --> Scheduler
Vision --> Runtime
MissionUI -->|"user intents only"| Runtime
SessionStore -->|"top active session stream"| SessionStream
This boundary is the most important architectural rule in the project.
| Concern | Flutter | Native Android |
|---|---|---|
| Mission configuration | Owns editor UI and user-facing summaries | Mirrors config for native execution |
| Mission rendering | Owns active mission screens and input widgets | Does not render Flutter UI |
| Mission runtime state | Reads snapshots only | Owns authoritative runtime state and persistence |
| Mission progress validation | Never authoritative | Validates progress and completion |
| Quiet timer | Displays native deadline | Owns and updates the real deadline |
| Dismissal authority | Sends intents only | Decides whether dismissal is allowed |
| Recovery after process death | Re-renders current session | Restores session and keeps enforcement alive |
In practical terms:
- Flutter can configure a mission.
- Flutter can render a mission.
- Flutter can send mission input back to native code.
- Flutter cannot decide that a mission is complete.
- Flutter cannot dismiss a mission alarm on its own.
That keeps mission behavior reliable even if the app process is reclaimed or the Flutter route stack is lost.
NeoAlarm is built around a persisted stack of live ring sessions. Flutter renders only the current top active session, but native Android preserves interrupted sessions beneath it so overlapping alarms can preempt safely and then resume.
Each live session still uses the same three persisted states:
ringingmission_activesnoozed
Key guarantees:
- alarm delivery does not depend on a live Flutter isolate
- ringing starts natively before any mission UI is required
- mission-active silence is temporary and enforced by native inactivity timers
- reboot recovery works from device-protected storage, including
LOCKED_BOOT_COMPLETED - reboot and Doze resilience have both been validated on-device
- lock-screen/full-screen alarm UI is authorized by persisted active-session state, not by a forgeable intent action
For the full model, read docs/architecture/active-session-lifecycle.md.
NeoAlarm is local-first, but it still controls high-impact device behavior. Current posture:
- no backend dependency
- no account system
- no telemetry dependency
- Android auto-backup disabled for MVP
- device-protected storage only for alarm-critical state
- exported reschedule surfaces narrowed to expected system actions
- release builds minified and shrink-resources enabled
Security and release decisions are tracked in the ADR set under docs/adr.
- Flutter
3.41.x - Dart
3.11.x - Riverpod for app-side state wiring
- Kotlin on Android for the alarm engine
AlarmManager.setAlarmClock()for exact schedulingMediaPlayerfor controllable alarm playback and per-instance ramping- MapLibre with OpenFreeMap Liberty for location-alarm maps
- Photon for place search
- optional OpenCage reverse geocoding for dropped-pin labels
- CameraX + ML Kit for the QR mission pipeline
- Flutter
3.41.xor newer on the stable channel - Android SDK configured locally
- Java 21-compatible Android build environment
- a physical Android device is strongly recommended for real validation
flutter pub get
flutter analyze
flutter testLocation alarms now use:
- MapLibre for interactive map rendering
- OpenFreeMap Liberty for the map style
- Photon for place search
- optional OpenCage reverse geocoding for readable labels when you drop a pin manually
OpenCage is optional and configured in-app from Settings -> Location map stack. If you leave it blank, dropped pins still work; they just keep the fallback label until you rename or move them.
Location alarms are currently in MVP hardening. The user-facing flow exists, but the release checklist still tracks native reliability hardening for geofence cleanup, retry behavior, and Android lint release readiness.
flutter run -d <device-id>Debug:
flutter build apk --debugRelease:
flutter build apk --releaseLocal release signing is driven by android/key.properties.example.
To use a real signing key:
- Copy
android/key.properties.exampletoandroid/key.properties - Point
storeFileat your keystore - Fill in:
storePasswordkeyAliaskeyPassword
If android/key.properties is absent, Gradle falls back to debug signing so CI and local verification builds still work.
Current local release setup:
- the repository now supports a real locally generated release keystore at
android/app/release-keystore.jks - live signing values are kept only in
android/key.properties, which is ignored by Git - a base64 export suitable for GitHub Actions secrets can be generated or stored under
.artifacts/signing - public release artifacts should use the real signing path, not the debug-sign fallback
To sync the local signing material into GitHub Actions secrets with the authenticated GitHub CLI:
powershell -ExecutionPolicy Bypass -File scripts/github/sync_android_signing_secrets.ps1Optional GitHub Actions secrets for signed release artifacts:
ANDROID_SIGNING_KEYSTORE_BASE64ANDROID_KEY_ALIASANDROID_KEYSTORE_PASSWORDANDROID_KEY_PASSWORD
GitHub Actions currently provides:
Android CIflutter analyzeflutter test- pull requests: debug APK build + debug artifact upload
mainand manual runs: release verification APK build + artifact upload
CodeQL- source-level SAST for Android code and workflow code
Dependency Review- dependency-risk / CVE review on pull requests
Distribute Android Release- signed universal APK, ABI split APKs, and app bundle build
- checksum and build metadata generation
- GitHub release publishing on
v*tags - manual
workflow_dispatchdistribution for a chosen tag
Release builds are not considered verified until the minified APK has been installed and smoke-tested on a real device.
GitHub releases publish a universal APK for easiest sideloading plus smaller ABI split APKs for arm64-v8a, armeabi-v7a, and x86_64. Users who are unsure should install the universal APK; users who know their device ABI can choose the smaller split artifact.
There are two supported distribution paths:
- push a tag like
v0.1.0to trigger the signed release workflow automatically - run the
Distribute Android Releaseworkflow manually with atag_name
The distribution workflow requires all four Android signing secrets. Unlike local release verification builds, it fails closed if those secrets are missing.
NeoAlarm uses a layered test model:
- unit tests for recurrence, serialization, mission rules, and config validation
- Flutter/native boundary tests for session shape and runtime contracts
- CI APK builds and release verification
- real-device manual testing for alarm, lock-screen, Doze, reboot, and OEM-specific behavior
The full test model is documented in docs/testing/test-strategy.md.
The repository includes a real Android performance workflow, not just ad hoc adb commands:
- Macrobenchmark module in
android/benchmark - manual Perfetto capture scripts in
scripts/android - dependency audit script for tracing unexpected Android background work back to the Gradle graph
Current notes:
- the benchmark target is a release-like
benchmarkapp variant that stays debug-signed for local device installation - the benchmark variant intentionally skips minification and resource shrinking because the real minified
releaseAPK is validated separately, while the synthetic benchmark build type needs to stay stable for repeated measurements android:profileable="shell=true"now lives only in the benchmark target manifest, not in the shipped app manifest- the shipped app still pins
androidx.profileinstaller, because profile-guided startup optimization is useful in production and required by the benchmark toolchain - the current QR stack pulls in ML Kit, which in turn pulls in
com.google.android.datatransport.runtime; that background work is tracked and documented rather than ignored
See docs/testing/performance-workflow.md for the exact commands, outputs, and current baseline numbers.
- docs/Doc Index.md: documentation index
- docs/architecture/overview.md: stable system model
- docs/architecture/engineering-story.md: engineering rationale
- docs/architecture/active-session-lifecycle.md: authoritative alarm session lifecycle
- docs/contributing/mission-authoring.md: how to add missions safely
- docs/testing/performance-workflow.md: Macrobenchmark, Perfetto, and dependency-audit workflow
- docs/planning/overall-plan.md: implementation roadmap
- docs/planning/sprint-plan.md: sprint breakdown
- docs/adr/README.md: architecture decision records
screenshots/: current app screenshots used in repository documentation
Before making behavior or architecture changes, read:
- docs/contributing/engineering-standards.md
- docs/architecture/overview.md
- docs/architecture/active-session-lifecycle.md
If your change alters subsystem ownership, mission contracts, camera ownership, scheduling semantics, or release/security policy, add or update an ADR.
Not currently in scope:
- iOS support
- cloud sync
- server-side analytics
- account system
- guaranteed blocking of the stock Android power menu
NeoAlarm is licensed under GNU General Public License v3.0.
This repository treats documentation as part of the engineering surface, not cleanup. If you change system behavior, update the docs in the same patch.










