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 }