Files
alarm-client/android/app/src/main/java/com/dcntrlized/alarm/AlarmPlayer.kt
T
Admin a804223139 [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.
2026-10-19 02:34:02 +01:00

246 lines
9.4 KiB
Kotlin

package com.dcntrlized.alarm
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
/**
* Max-volume alarm playback.
*
* Escalation ladder, in order:
* 1. `STREAM_ALARM` at `setVolume(1f)` — the only stream that ignores the
* media volume and, on most devices, keeps ringing in silent mode;
* 2. a `Vibrator` waveform alongside the audio, so a muted ringer still
* reaches the user;
* 3. (see [AlarmNotifier]) a high-priority notification with a "Raise your
* volume" action when the OS refuses the alarm stream entirely.
*
* We never report success unless the alarm stream was actually raised, so the
* UI can tell the truth about which rung of the ladder carried the alarm.
*/
object AlarmPlayer {
private const val TAG = "AlarmPlayer"
private var player: MediaPlayer? = null
private var vibrator: Vibrator? = null
/** Vibration pattern: wait 0, buzz 700, rest 400, repeated from index 1. */
private val VIBRATION_PATTERN = longArrayOf(0L, 700L, 400L)
@Synchronized
fun play(context: Context, alertId: String): Boolean {
stop(context)
val volumeRaised = raiseAlarmStream(context)
try {
val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
val mp = MediaPlayer()
mp.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
mp.setDataSource(context, uri)
mp.isLooping = true
mp.setVolume(1f, 1f)
mp.prepare()
mp.start()
player = mp
} catch (e: Exception) {
Log.w(TAG, "alarm audio failed: ${e.message}")
}
startVibration(context)
// 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
}
@Synchronized
fun stop(context: Context) {
try {
player?.let {
if (it.isPlaying) it.stop()
it.release()
}
} catch (e: Exception) {
Log.w(TAG, "player stop failed: ${e.message}")
} finally {
player = null
}
try {
vibrator?.cancel()
} catch (e: Exception) {
Log.w(TAG, "vibrator cancel failed: ${e.message}")
} finally {
vibrator = null
}
}
/**
* Raise the alarm stream and report whether the OS let us.
*
* `setStreamVolume` is a no-op on some OEM builds and throws
* `SecurityException` under DND. Both are reported as `false` rather than
* swallowed, because the notification fallback depends on knowing.
*/
private fun raiseAlarmStream(context: Context): Boolean {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return false
return try {
val max = am.getStreamMaxVolume(AudioManager.STREAM_ALARM)
am.setStreamVolume(AudioManager.STREAM_ALARM, max, 0)
am.getStreamVolume(AudioManager.STREAM_ALARM) >= max
} catch (e: Exception) {
Log.w(TAG, "could not raise STREAM_ALARM: ${e.message}")
false
}
}
/**
* 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
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
if (v == null || !v.hasVibrator()) return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
v.vibrate(VibrationEffect.createWaveform(VIBRATION_PATTERN, 1))
} else {
@Suppress("DEPRECATION")
v.vibrate(VIBRATION_PATTERN, 1)
}
vibrator = v
} catch (e: Exception) {
Log.w(TAG, "vibration failed: ${e.message}")
}
}
}
/**
* 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 {
/** 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 volume = NotificationChannel(
CHANNEL_VOLUME,
"Alarm volume",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Tells you when the alarm volume is not at maximum, so the alert can be heard"
enableVibration(false)
setShowBadge(true)
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
}
nm.createNotificationChannel(volume)
val service = NotificationChannel(
CHANNEL_SERVICE,
"Armed alert",
NotificationManager.IMPORTANCE_MIN
).apply {
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)
}
/**
* "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 showVolumeWarning(context: Context, alertId: String, volumePercent: Int) {
ensureChannels(context)
val nm = context.getSystemService(NotificationManager::class.java) ?: return
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
)
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_RECOMMENDATION)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(raise)
try {
nm.notify(NOTIFICATION_ID_VOLUME, builder.build())
} catch (e: SecurityException) {
Log.w("AlarmNotifier", "notify failed (POST_NOTIFICATIONS?): ${e.message}")
}
}
fun cancelVolumeWarning(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java) ?: return
nm.cancel(NOTIFICATION_ID_VOLUME)
}
/** 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
}