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.
246 lines
9.4 KiB
Kotlin
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
|
|
}
|