feat(alarm): the ringing service, and never degrading to silence

A systemExempted foreground service holds the ring: audio focus, USAGE_ALARM,
the volume ramp, vibration, and the ringtone URI. It goes foreground
synchronously before it reads anything from the database, because the platform
kills a service that reaches startForeground late — including on the path where
there turns out to be no session to ring.

The chain that matters is the one that never ends in silence. A full-screen
intent when canUseFullScreenIntent() allows it; a high-priority heads-up
notification that still rings when it does not; the device's default alarm
ringtone when the configured URI will not open; and forced vibration when no
audio source will play at all.

Preparing a MediaPlayer is done off the main thread — a synchronous prepare at
the moment the alarm fires is an ANR — and a player is released rather than
orphaned on every failure path, so a dismissal cannot be outlived by the ring
it cancelled.

The ring screen here is plumbing and a placeholder: window flags to show over
the lockscreen and turn the screen on. Its UI is M4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 16:05:25 +02:00
co-authored by Claude Opus 5
parent dd9a920c28
commit bf511b47d7
10 changed files with 887 additions and 11 deletions
+83 -6
View File
@@ -3,14 +3,44 @@
xmlns:tools="http://schemas.android.com/tools">
<!--
No permissions yet, deliberately. Clockula's permission set is the alarm
engine's (USE_EXACT_ALARM, POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED,
USE_FULL_SCREEN_INTENT, FOREGROUND_SERVICE*) and it is declared in M3
alongside the receivers and the ringing service that need it — so the
manifest never claims a capability the code cannot yet honour. See
docs/PLAN.md §4.
Clockula's permission set is the alarm engine's, and nothing more. Each
one is here because a specific path would otherwise fail silently — see
docs/ARCHITECTURE.md §9 and docs/PLAN.md §4.
-->
<!-- The exact-alarm ladder. USE_EXACT_ALARM is install-granted on 33+ for an
app whose core function is alarms; SCHEDULE_EXACT_ALARM covers 31–32,
where it is pre-granted, and is bounded there so the two never overlap.
Below 31 an exact alarm needs no permission at all. -->
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission
android:name="android.permission.SCHEDULE_EXACT_ALARM"
android:maxSdkVersion="32" />
<!-- Re-register the next alarm after a reboot: AlarmManager keeps nothing. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- The ring screen over the lock screen. Denied, the ring degrades to a
heads-up notification that still sounds — never to silence. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- The ring notification. Denied, the alarm still sounds: the audio belongs
to the foreground service, not to the notification. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- The ringing service. systemExempted is Android's own answer for an app
holding an exact-alarm permission that continues an alarm in the
background, including a haptics-only one. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<!-- Keep the CPU awake for the length of a ring; the screen is the window's
business, not this one's. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- The alarm vibrates, and vibrates on its own when no audio source opens. -->
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:name=".ClockulaApp"
android:allowBackup="true"
@@ -42,6 +72,53 @@
android:excludeFromRecents="true"
android:launchMode="singleTask" />
<!-- The ring screen. Shown over the lock screen with the display turned
on; excluded from recents so a dismissed alarm leaves nothing
behind. M4 replaces its contents, not its declaration. -->
<activity
android:name=".ui.ring.AlarmRingActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:showOnLockScreen="true"
android:showWhenLocked="true"
android:taskAffinity=""
android:turnScreenOn="true" />
<!-- The ring itself. systemExempted per D17: not mediaPlayback (an alarm
is not the user's media session) and emphatically not shortService,
which caps at ~3 minutes against a 10-minute ring window. -->
<service
android:name=".alarm.ring.AlarmRingService"
android:exported="false"
android:foregroundServiceType="systemExempted" />
<!-- The two AlarmManager slots arriving. Unexported: a PendingIntent the
app created is delivered regardless, and nothing else may fake a fire. -->
<receiver
android:name=".alarm.receiver.AlarmFireReceiver"
android:exported="false" />
<!-- The notification's Snooze and Dismiss buttons. -->
<receiver
android:name=".alarm.receiver.AlarmActionReceiver"
android:exported="false" />
<!-- Protected system broadcasts are delivered to unexported receivers —
this is how androidx.work declares its own RescheduleReceiver.
LOCKED_BOOT_COMPLETED is deliberately absent: the database and the
preferences live in credential-encrypted storage. -->
<receiver
android:name=".alarm.receiver.SystemEventReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- Persists the per-app language on API < 33, where the platform
per-app-languages API is unavailable. On 33+ this is a no-op. -->
<service