[feat](client): deliver a trigger as a real alarm-clock takeover

A notification is not a delivery path: Android 14 restricts full-screen intents to apps it classifies as alarm apps, and setAlarmClock is both the strongest alarm API and the evidence for that classification. Schedule the takeover through AlarmManager instead of startActivity, keep a standing alarm-clock entry while armed, restore it after reboot (the wake also re-arms the socket), and persist the honest listening state so a platform stop is visible. Run the socket as specialUse on API 34+ (dataSync is capped at 6h/24h on 35+) and keep onTimeout as a guard. Restore the Lead release build placeholder so a plain-HTTP instance is reachable, and allow the app to arm only when exact alarms and full-screen intent are actually granted.
This commit is contained in:
2026-10-19 02:34:02 +01:00
parent 5fa1e8e83e
commit a804223139
12 changed files with 959 additions and 109 deletions
@@ -37,7 +37,7 @@ object AlarmPlayer {
private val VIBRATION_PATTERN = longArrayOf(0L, 700L, 400L)
@Synchronized
fun play(context: Context, alertId: String, autoStopSeconds: Int): Boolean {
fun play(context: Context, alertId: String): Boolean {
stop(context)
val volumeRaised = raiseAlarmStream(context)
@@ -62,7 +62,13 @@ object AlarmPlayer {
}
startVibration(context)
AlarmNotifier.showAlarmNotification(context, alertId, volumeRaised, autoStopSeconds)
// The only notification this app posts about a firing alarm: the volume
// warning. Everything else is the real alarm (sound + takeover).
if (!volumeRaised) {
AlarmNotifier.showVolumeWarning(context, alertId, alarmVolumePercent(context))
} else {
AlarmNotifier.cancelVolumeWarning(context)
}
return volumeRaised
}
@@ -106,6 +112,22 @@ object AlarmPlayer {
}
}
/**
* Current alarm-stream volume as a percentage, for the honest warning copy
* ("Alarm volume is at 40%"). Returns 100 when the value cannot be read, so
* the warning never invents a number it does not have.
*/
fun alarmVolumePercent(context: Context): Int {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return 100
return try {
val max = am.getStreamMaxVolume(AudioManager.STREAM_ALARM)
if (max <= 0) return 100
(am.getStreamVolume(AudioManager.STREAM_ALARM) * 100 / max).coerceIn(0, 100)
} catch (e: Exception) {
100
}
}
private fun startVibration(context: Context) {
val v: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
@@ -128,101 +150,96 @@ object AlarmPlayer {
}
}
/** Notification channels + the alarm notification, including the volume fallback. */
/**
* The only two notifications this app is allowed to post.
*
* Product ruling: a notification is never a delivery path. An alert fires as a
* real alarm (sound + takeover through [AlarmScheduler]). Notifications exist
* only for
* 1. the **volume warning** — "your alarm volume is not at maximum", which the
* user explicitly allows and which tells them something true and useful, and
* 2. the **foreground-service notification** — mandatory while the armed
* socket is held; given the quietest treatment the platform offers
* (IMPORTANCE_MIN, no sound, no vibration, no badge, no heads-up) because
* it is a legal requirement, not a delivery mechanism.
*/
object AlarmNotifier {
const val CHANNEL_ALARM = "alarm_trigger"
/** Volume warning. High importance so it is seen, but it is not the alarm. */
const val CHANNEL_VOLUME = "alarm_volume_warning"
/** Foreground-service presence. Deliberately the quietest channel. */
const val CHANNEL_SERVICE = "alarm_armed"
fun ensureChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = context.getSystemService(NotificationManager::class.java) ?: return
val alarm = NotificationChannel(
CHANNEL_ALARM,
"Alarm triggers",
val volume = NotificationChannel(
CHANNEL_VOLUME,
"Alarm volume",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Fires at full volume when an alert is triggered"
enableVibration(true)
description = "Tells you when the alarm volume is not at maximum, so the alert can be heard"
enableVibration(false)
setShowBadge(true)
// The alarm is the payload: bypass DND where the user allows it.
setBypassDnd(true)
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
}
nm.createNotificationChannel(alarm)
nm.createNotificationChannel(volume)
val service = NotificationChannel(
CHANNEL_SERVICE,
"Armed alert",
NotificationManager.IMPORTANCE_LOW
NotificationManager.IMPORTANCE_MIN
).apply {
description = "Keeps the connection to the alarm service open while armed"
description = "Required while the app holds the armed connection; it makes no sound"
setShowBadge(false)
enableVibration(false)
setSound(null, null)
lockscreenVisibility = android.app.Notification.VISIBILITY_SECRET
}
nm.createNotificationChannel(service)
}
/**
* High-priority alarm notification. When `volumeRaised` is false the
* notification carries a "Raise your volume" action, because the OS refused
* the forced alarm stream and the user has to do it.
* "Alarm volume is at 40% — raise it to maximum so this alert can be heard."
* No full-screen intent, no "an alarm is ringing": this is informational.
*/
fun showAlarmNotification(
context: Context,
alertId: String,
volumeRaised: Boolean,
autoStopSeconds: Int
) {
fun showVolumeWarning(context: Context, alertId: String, volumePercent: Int) {
ensureChannels(context)
val nm = context.getSystemService(NotificationManager::class.java) ?: return
val open = android.app.PendingIntent.getActivity(
val raise = android.app.PendingIntent.getActivity(
context,
alertId.hashCode(),
AlarmActivity.intent(context, alertId, "", autoStopSeconds),
alertId.hashCode() + 1,
android.content.Intent(android.provider.Settings.ACTION_SOUND_SETTINGS),
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
)
val builder = android.app.Notification.Builder(context, CHANNEL_ALARM)
.setContentTitle("Alarm")
.setContentText(if (volumeRaised) "Alarm ringing" else "Raise your volume to hear the alarm")
val builder = android.app.Notification.Builder(context, CHANNEL_VOLUME)
.setContentTitle("Alarm volume is at $volumePercent%")
.setContentText("Raise it to maximum so this alert can be heard.")
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setCategory(android.app.Notification.CATEGORY_ALARM)
.setPriority(android.app.Notification.PRIORITY_MAX)
.setAutoCancel(false)
.setOngoing(true)
.setVisibility(android.app.Notification.VISIBILITY_PUBLIC)
.setContentIntent(open)
.setFullScreenIntent(open, true)
if (!volumeRaised) {
val raise = android.app.PendingIntent.getActivity(
context,
alertId.hashCode() + 1,
android.content.Intent(android.provider.Settings.ACTION_SOUND_SETTINGS),
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
)
builder.addAction(
android.app.Notification.Action.Builder(
null,
"Raise your volume",
raise
).build()
)
}
.setCategory(android.app.Notification.CATEGORY_RECOMMENDATION)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(raise)
try {
nm.notify(NOTIFICATION_ID_ALARM, builder.build())
nm.notify(NOTIFICATION_ID_VOLUME, builder.build())
} catch (e: SecurityException) {
Log.w("AlarmNotifier", "notify failed (POST_NOTIFICATIONS?): ${e.message}")
}
}
fun cancelAlarm(context: Context) {
fun cancelVolumeWarning(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java) ?: return
nm.cancel(NOTIFICATION_ID_ALARM)
nm.cancel(NOTIFICATION_ID_VOLUME)
}
const val NOTIFICATION_ID_ALARM = 4711
/** Removes the persistent "Alarm armed" notification. */
fun cancelArmed(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java) ?: return
nm.cancel(NOTIFICATION_ID_SERVICE)
}
const val NOTIFICATION_ID_VOLUME = 4711
const val NOTIFICATION_ID_SERVICE = 4712
}