Root-cause fix for the unstoppable-alarm incident (alarm rang 15 minutes, only uninstall silenced it) plus systematic hardening of every stop path. Native (Kotlin): - Verified stop: stopActiveAlarm now derives its result from the real post-teardown state (companion instance + synchronous stopEverything + activeRingingId check) instead of reporting unconditional success. - Atomic teardown: every stop path (stop action, notification button, snooze, missed, onDestroy, startForeground failure) funnels through one stopEverything() covering audio, wakelock, notification, foreground state and firing-record cleanup; player.release() guarded. - Bounded ringing: 10-minute auto-silence armed via AlarmManager fires a FIRED->MISSED transition with a localized missed-alarm notification; repeating alarms keep their native rearm, deleted alarms never produce ghost MISSED notifications. - Durable firing record with onStartCommand re-validation (resurrection guard) and boot-time stale cleanup; firing records cleared on every refuse/mismatch/cancel path. - New notification-only dismissal channel (dismissAlarmNotificationOnly) so UI-level dedup can never kill a live ring's audio. Flutter (Dart): - Stop/disable/edit/delete of a ringing alarm always attempt to silence it; on native-query failure the stop falls back toward silence via the id-scoped legacy stop. - Verified-stop results surface failures: the ringing screen keeps dismiss-by-design on success, but on a verified failure it stays up with a persistent force-stop banner (guarded against double-dismiss) and auto-dismisses if the ring ends externally (missed/notification). - Missed events sync alarm bookkeeping without opening the ringing UI. - 4 new l10n keys translated across all 13 locales (ARB guard green). 550 tests green, analyzer clean. Reviewed in 3 adversarial 4-lens rounds (2 deterministic + 1 refuter-corroborated critical fixed); formal gentle-ai receipt waived by maintainer authorization (correction scope legitimately exceeded the frozen genesis paths). On-device QA checklist in openspec/changes/alarm-system-overhaul/tasks.md pending before archive.
874 lines
39 KiB
Kotlin
874 lines
39 KiB
Kotlin
package es.freetimelab.pluriwave
|
|
|
|
import android.app.NotificationChannel
|
|
import android.app.NotificationManager
|
|
import android.app.PendingIntent
|
|
import android.app.Service
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.pm.ServiceInfo
|
|
import android.media.AudioAttributes
|
|
import android.media.AudioFocusRequest
|
|
import android.media.AudioManager
|
|
import android.media.MediaPlayer
|
|
import android.net.Uri
|
|
import android.os.Build
|
|
import android.os.Handler
|
|
import android.os.IBinder
|
|
import android.os.Looper
|
|
import android.os.PowerManager
|
|
import android.os.SystemClock
|
|
import android.util.Log
|
|
import androidx.core.app.NotificationCompat
|
|
import androidx.core.app.NotificationManagerCompat
|
|
import androidx.core.content.ContextCompat
|
|
import java.io.File
|
|
|
|
/**
|
|
* Foreground service that owns native alarm audio and the single ringing
|
|
* notification (NOTIFICATION_ID, full-screen intent).
|
|
*
|
|
* Sole ring-audio ownership: this service is the ONLY audio source for the
|
|
* whole ring, from start to dismiss/snooze/timeout, on STREAM_ALARM via its
|
|
* own MediaPlayer (station stream, fallback station, or bundled WAV). The
|
|
* Flutter ringing screen is display-only: it never starts a player and
|
|
* never touches system volume, only EstadoAlarmas.finalizarEjecucion /
|
|
* posponerAlarma from Stop/Snooze/back.
|
|
*/
|
|
class PluriWaveAlarmService : Service() {
|
|
private var player: MediaPlayer? = null
|
|
private var wakeLock: PowerManager.WakeLock? = null
|
|
private var activeAlarmId: String? = null
|
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
private var stationFallbackRunnable: Runnable? = null
|
|
private var fadeLoopRunnable: Runnable? = null
|
|
private var fadeAnchorElapsedMs: Long = 0L
|
|
private var audioFocusRequest: AudioFocusRequest? = null
|
|
private val noopAudioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { }
|
|
|
|
override fun onBind(intent: Intent?): IBinder? = null
|
|
|
|
/**
|
|
* Paired-write helper (feedback item, READ-6): the instance-scoped
|
|
* [activeAlarmId] and the same-process companion [activeRingingId] must
|
|
* always move together -- setting one without the other would let
|
|
* [stopActiveVerified] read a stale/wrong ring state. Used at every write
|
|
* site instead of assigning each field separately.
|
|
*/
|
|
private fun setActiveIds(id: String?) {
|
|
activeAlarmId = id
|
|
activeRingingId = id
|
|
}
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
// Same-process companion instance (feedback item 1, RISK-1/RES-1/REL-2):
|
|
// lets stopActiveVerified() call stopEverything() SYNCHRONOUSLY instead
|
|
// of trusting an async startService dispatch to have completed.
|
|
instance = this
|
|
}
|
|
|
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
val action = intent?.action
|
|
val requestedId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
|
|
Log.d(TAG, "alarm.service onStartCommand action=$action id=$requestedId active=$activeAlarmId")
|
|
|
|
when (action) {
|
|
ACTION_STOP -> {
|
|
stopAlarm(requestedId)
|
|
return START_NOT_STICKY
|
|
}
|
|
ACTION_STOP_ACTIVE -> {
|
|
// Id-agnostic fail-safe stop (Decision 1): silences whatever is
|
|
// ringing regardless of the id the caller passed (or omitted).
|
|
// Used by the ringing UI and the notification Stop action so a
|
|
// stop request can never silently no-op a live ring.
|
|
stopEverything()
|
|
return START_NOT_STICKY
|
|
}
|
|
ACTION_SNOOZE -> {
|
|
val minutes = intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5)
|
|
if (requestedId != null) {
|
|
val snoozed = AlarmScheduler(this).snooze(requestedId, minutes)
|
|
if (snoozed != null) {
|
|
// D1 fix (Decision 2.1): report the native snooze back to
|
|
// Flutter so the canonical config records it. If the engine
|
|
// is dead this is a no-op and the cold-start sync
|
|
// (getNativeSnoozeState) reconciles on next launch.
|
|
MainActivity.notifyAlarmEvent(
|
|
mapOf(
|
|
"alarmId" to requestedId,
|
|
"alarmTitle" to snoozed.title,
|
|
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZED,
|
|
"occurrenceAtMillis" to snoozed.occurrenceAtMillis,
|
|
"snoozeUntilMillis" to snoozed.snoozeUntilMillis,
|
|
"snoozeMinutes" to minutes
|
|
)
|
|
)
|
|
}
|
|
}
|
|
stopAlarm(requestedId)
|
|
return START_NOT_STICKY
|
|
}
|
|
PluriWaveAlarmReceiver.ACTION_FIRE, null -> startAlarm(intent)
|
|
else -> Log.w(TAG, "alarm.service unknown action=$action id=$requestedId")
|
|
}
|
|
return START_NOT_STICKY
|
|
}
|
|
|
|
private fun startAlarm(intent: Intent?) {
|
|
val alarmId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) ?: return
|
|
if (activeAlarmId != null) {
|
|
Log.w(TAG, "alarm.service ignored id=$alarmId because active=$activeAlarmId")
|
|
// Orphaned firing record fix (RES-2): the newcomer's own firing
|
|
// record + auto-silence were already armed by onAlarmFired before
|
|
// this refusal, so they must be cleared here or a false MISSED
|
|
// fires 10 minutes later for an alarm that never actually rang.
|
|
val scheduler = AlarmScheduler(this)
|
|
scheduler.clearFiringRecord(alarmId)
|
|
scheduler.cancelAutoSilence(alarmId)
|
|
return
|
|
}
|
|
// onStartCommand re-validation (Decision 4): a redelivered/resurrected
|
|
// start for a firing record older than the auto-silence bound must
|
|
// never resume audio -- treat it as an already-missed ring instead.
|
|
val scheduler = AlarmScheduler(this)
|
|
val firingAge = scheduler.firingRecordAgeMillis(alarmId)
|
|
if (firingAge != null && firingAge > AlarmScheduler.AUTO_SILENCE_MILLIS) {
|
|
Log.w(TAG, "alarm.service startAlarm stale firing record id=$alarmId ageMs=$firingAge; treating as missed")
|
|
scheduler.onAlarmMissed(alarmId)
|
|
stopSelf()
|
|
return
|
|
}
|
|
// Durable firing record (Decision 4): written before MediaPlayer.start()
|
|
// (via startAudio below) so a process death mid-ring leaves proof the
|
|
// ring was in flight for the re-validation above / boot cleanup.
|
|
scheduler.recordFiring(alarmId)
|
|
setActiveIds(alarmId)
|
|
// Anchor the fade curve at RING start, not audio start (design D2):
|
|
// every source in the 3-stage fallback chain shares this ONE clock,
|
|
// so a source that begins mid-fade (e.g. after a station timeout)
|
|
// joins at the already-elapsed gain instead of restarting from
|
|
// silence (Requirement: Exponential dB fade-in ceiling).
|
|
fadeAnchorElapsedMs = SystemClock.elapsedRealtime()
|
|
|
|
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave"
|
|
val stationName = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME)
|
|
val stationUrl = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL)
|
|
val fallbackStationName =
|
|
intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_NAME)
|
|
val fallbackStationUrl =
|
|
intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_URL)
|
|
val fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND)
|
|
val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f)
|
|
val fadeInSegundos =
|
|
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_FADE_IN_SECONDS, 0).coerceIn(0, 60)
|
|
val snoozeMinutes = sanitizeSnoozeMinutes(
|
|
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
|
|
)
|
|
|
|
acquireWakeLock()
|
|
// The FSI notification must be visible BEFORE audio prepares (prepareAsync is
|
|
// slow); startForeground runs first so the ringing surface never lags audio.
|
|
try {
|
|
val notification = buildNotification(alarmId, title, stationName, snoozeMinutes)
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
startForeground(
|
|
NOTIFICATION_ID,
|
|
notification,
|
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
|
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED
|
|
)
|
|
} else {
|
|
startForeground(NOTIFICATION_ID, notification)
|
|
}
|
|
} catch (error: Throwable) {
|
|
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
|
|
releaseWakeLock()
|
|
// Second documented clear site (feedback item, READ-5): this
|
|
// branch never reaches stopEverything(), so without the same
|
|
// cleanup below the receiver-armed auto-silence timer + durable
|
|
// firing record for alarmId would survive and fire a ghost
|
|
// MISSED notification ~10 minutes later for a ring that never
|
|
// actually started.
|
|
scheduler.clearFiringRecord(alarmId)
|
|
scheduler.cancelAutoSilence(alarmId)
|
|
setActiveIds(null)
|
|
stopSelf()
|
|
return
|
|
}
|
|
startAudio(
|
|
alarmId,
|
|
stationName,
|
|
stationUrl,
|
|
fallbackStationName,
|
|
fallbackStationUrl,
|
|
fallbackSound,
|
|
volume,
|
|
fadeInSegundos
|
|
)
|
|
}
|
|
|
|
private fun startAudio(
|
|
alarmId: String,
|
|
stationName: String?,
|
|
stationUrl: String?,
|
|
fallbackStationName: String?,
|
|
fallbackStationUrl: String?,
|
|
fallbackSound: String?,
|
|
volume: Float,
|
|
fadeInSegundos: Int
|
|
) {
|
|
player?.release()
|
|
player = null
|
|
requestAlarmAudioFocus()
|
|
startFadeLoop(alarmId, volume, fadeInSegundos)
|
|
|
|
// Three-stage ordered fallback: primary station -> fallback station -> bundled WAV.
|
|
// Each stage owns its own 15s timeout window via scheduleStationFallback.
|
|
val startBundled: (String) -> Unit = { reason ->
|
|
startFallbackAudio(alarmId, fallbackSound, volume, fadeInSegundos, reason)
|
|
}
|
|
val startFallbackStation: (String) -> Unit = { reason ->
|
|
if (fallbackStationUrl.isNullOrBlank()) {
|
|
startBundled(reason)
|
|
} else {
|
|
startStationAudio(
|
|
alarmId,
|
|
fallbackStationName,
|
|
fallbackStationUrl.trim(),
|
|
volume,
|
|
fadeInSegundos,
|
|
"fallback-station",
|
|
startBundled
|
|
)
|
|
}
|
|
}
|
|
|
|
if (stationUrl.isNullOrBlank()) {
|
|
startFallbackStation("station url missing")
|
|
return
|
|
}
|
|
startStationAudio(
|
|
alarmId,
|
|
stationName,
|
|
stationUrl.trim(),
|
|
volume,
|
|
fadeInSegundos,
|
|
"station",
|
|
startFallbackStation
|
|
)
|
|
}
|
|
|
|
private fun startStationAudio(
|
|
alarmId: String,
|
|
stationName: String?,
|
|
stationUrl: String,
|
|
volume: Float,
|
|
fadeInSegundos: Int,
|
|
stage: String,
|
|
onStageFailed: (String) -> Unit
|
|
) {
|
|
player?.release()
|
|
player = null
|
|
scheduleStationFallback(alarmId, stage, onStageFailed)
|
|
val startVolume = computeFadeVolume(
|
|
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
|
|
fadeInSegundos * 1000L,
|
|
volume
|
|
)
|
|
try {
|
|
player = MediaPlayer().apply {
|
|
setAudioAttributes(alarmAudioAttributes())
|
|
isLooping = false
|
|
setVolume(startVolume, startVolume)
|
|
setDataSource(
|
|
this@PluriWaveAlarmService,
|
|
Uri.parse(stationUrl),
|
|
mapOf("User-Agent" to "PluriWave/0.1.0 (native alarm)")
|
|
)
|
|
setOnPreparedListener {
|
|
if (activeAlarmId != alarmId) return@setOnPreparedListener
|
|
cancelStationFallback()
|
|
// Recompute at prepare-time (not the stale value captured
|
|
// before prepareAsync): buffering can take seconds, during
|
|
// which the fade clock keeps advancing. Setting volume
|
|
// BEFORE start() avoids an audible pop (Requirement:
|
|
// No-fade path starts pop-free; same principle applies
|
|
// mid-fade).
|
|
val current = computeFadeVolume(
|
|
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
|
|
fadeInSegundos * 1000L,
|
|
volume
|
|
)
|
|
it.setVolume(current, current)
|
|
it.start()
|
|
Log.d(
|
|
TAG,
|
|
"alarm.service $stage started id=$alarmId station=$stationName url=$stationUrl"
|
|
)
|
|
}
|
|
setOnCompletionListener {
|
|
if (activeAlarmId != alarmId) return@setOnCompletionListener
|
|
Log.w(TAG, "alarm.service $stage completed id=$alarmId url=$stationUrl")
|
|
onStageFailed("$stage completed")
|
|
}
|
|
setOnErrorListener { mp, what, extra ->
|
|
Log.e(
|
|
TAG,
|
|
"alarm.service $stage error id=$alarmId what=$what extra=$extra url=$stationUrl"
|
|
)
|
|
runCatching { mp.reset() }
|
|
if (activeAlarmId == alarmId) {
|
|
onStageFailed("$stage error")
|
|
}
|
|
true
|
|
}
|
|
prepareAsync()
|
|
}
|
|
Log.d(TAG, "alarm.service $stage preparing id=$alarmId station=$stationName url=$stationUrl")
|
|
} catch (error: Throwable) {
|
|
Log.e(TAG, "alarm.service $stage prepare failed id=$alarmId url=$stationUrl", error)
|
|
onStageFailed("$stage prepare failed")
|
|
}
|
|
}
|
|
|
|
private fun startFallbackAudio(
|
|
alarmId: String,
|
|
fallbackSound: String?,
|
|
volume: Float,
|
|
fadeInSegundos: Int,
|
|
reason: String
|
|
) {
|
|
cancelStationFallback()
|
|
player?.release()
|
|
player = null
|
|
|
|
val source = fallbackAssetPath(fallbackSound)
|
|
val startVolume = computeFadeVolume(
|
|
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
|
|
fadeInSegundos * 1000L,
|
|
volume
|
|
)
|
|
try {
|
|
player = MediaPlayer().apply {
|
|
setAudioAttributes(alarmAudioAttributes())
|
|
isLooping = true
|
|
setVolume(startVolume, startVolume)
|
|
setFallbackAssetDataSource(this, fallbackSound)
|
|
setOnPreparedListener {
|
|
if (activeAlarmId != alarmId) return@setOnPreparedListener
|
|
// Recompute at prepare-time; see the matching comment in
|
|
// startStationAudio's setOnPreparedListener.
|
|
val current = computeFadeVolume(
|
|
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
|
|
fadeInSegundos * 1000L,
|
|
volume
|
|
)
|
|
it.setVolume(current, current)
|
|
it.start()
|
|
Log.d(TAG, "alarm.service fallback started id=$alarmId source=$source reason=$reason")
|
|
}
|
|
setOnErrorListener { mp, what, extra ->
|
|
Log.e(TAG, "alarm.service fallback error id=$alarmId what=$what extra=$extra source=$source")
|
|
mp.reset()
|
|
true
|
|
}
|
|
prepareAsync()
|
|
}
|
|
Log.d(TAG, "alarm.service fallback preparing id=$alarmId source=$source reason=$reason")
|
|
} catch (error: Throwable) {
|
|
Log.e(TAG, "alarm.service fallback prepare failed id=$alarmId source=$source", error)
|
|
}
|
|
}
|
|
|
|
private fun scheduleStationFallback(
|
|
alarmId: String,
|
|
stage: String,
|
|
onStageFailed: (String) -> Unit
|
|
) {
|
|
cancelStationFallback()
|
|
val runnable = Runnable {
|
|
if (activeAlarmId == alarmId) {
|
|
Log.w(TAG, "alarm.service $stage timeout id=$alarmId; advancing audio chain")
|
|
onStageFailed("$stage timeout")
|
|
}
|
|
}
|
|
stationFallbackRunnable = runnable
|
|
mainHandler.postDelayed(runnable, STATION_START_TIMEOUT_MILLIS)
|
|
}
|
|
|
|
/**
|
|
* Single ring-anchored fade loop (Requirement: Exponential dB fade-in
|
|
* ceiling; design D1). Ticks every [FADE_TICK_MILLIS] and reads [player]
|
|
* FRESH on each tick -- not a captured MediaPlayer reference -- so the
|
|
* SAME loop survives the 3-stage source swap (station -> fallback
|
|
* station -> bundled WAV) instead of needing a fresh ramp per source.
|
|
* Guarded by [activeAlarmId] so a stale loop from a superseded ring can
|
|
* never touch a new one's player. Stops rescheduling once elapsed
|
|
* reaches the fade window; further ticks would be redundant since
|
|
* [computeFadeVolume] already clamps to the ceiling past that point.
|
|
*/
|
|
private fun startFadeLoop(alarmId: String, ceiling: Float, fadeInSegundos: Int) {
|
|
cancelFadeLoop()
|
|
if (fadeInSegundos <= 0) return
|
|
val fadeMs = fadeInSegundos * 1000L
|
|
val runnable = object : Runnable {
|
|
override fun run() {
|
|
if (activeAlarmId != alarmId) return
|
|
val elapsed = SystemClock.elapsedRealtime() - fadeAnchorElapsedMs
|
|
val current = computeFadeVolume(elapsed, fadeMs, ceiling)
|
|
runCatching { player?.setVolume(current, current) }
|
|
if (elapsed < fadeMs) {
|
|
mainHandler.postDelayed(this, FADE_TICK_MILLIS)
|
|
}
|
|
}
|
|
}
|
|
fadeLoopRunnable = runnable
|
|
mainHandler.postDelayed(runnable, FADE_TICK_MILLIS)
|
|
Log.d(TAG, "alarm.service fade loop started id=$alarmId seconds=$fadeInSegundos")
|
|
}
|
|
|
|
private fun cancelFadeLoop() {
|
|
fadeLoopRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
fadeLoopRunnable = null
|
|
}
|
|
|
|
private fun cancelStationFallback() {
|
|
stationFallbackRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
stationFallbackRunnable = null
|
|
}
|
|
|
|
private fun alarmAudioAttributes(): AudioAttributes =
|
|
AudioAttributes.Builder()
|
|
.setUsage(AudioAttributes.USAGE_ALARM)
|
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
|
.build()
|
|
|
|
private fun stopAlarm(alarmId: String?) {
|
|
Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId")
|
|
// Scope the teardown to the alarm that is actually ringing: a stop
|
|
// request for a DIFFERENT id (e.g. a second alarm firing while this
|
|
// one rings — Dart hides the newcomer's notification, which routes
|
|
// through ACTION_STOP with the newcomer's id) must not kill the
|
|
// active ring, release its wake lock, or prematurely restore the
|
|
// device volume. Only the id-specific notification cancel below is
|
|
// honored for the mismatched id. A null alarmId (internal callers,
|
|
// onDestroy) keeps full-teardown semantics.
|
|
if (alarmId != null && activeAlarmId != null && alarmId != activeAlarmId) {
|
|
Log.d(
|
|
TAG,
|
|
"alarm.service stop ignored for id=$alarmId (active=$activeAlarmId)"
|
|
)
|
|
NotificationManagerCompat.from(this).cancel(
|
|
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
|
|
)
|
|
// Orphaned firing record fix (RES-2): this mismatched id is not
|
|
// being torn down by stopEverything() below (that only tears down
|
|
// activeAlarmId), so its own firing record + auto-silence must be
|
|
// cleared here to avoid a false MISSED 10 minutes later.
|
|
val scheduler = AlarmScheduler(this)
|
|
scheduler.clearFiringRecord(alarmId)
|
|
scheduler.cancelAutoSilence(alarmId)
|
|
return
|
|
}
|
|
stopEverything()
|
|
}
|
|
|
|
/**
|
|
* Atomic full teardown (Decision 2, NA "Atomic Stop Coupling"): every stop
|
|
* entry point (ACTION_STOP id-match/null, ACTION_STOP_ACTIVE, ACTION_SNOOZE
|
|
* via [stopAlarm], onDestroy via [stopAlarm]) funnels through this ONE
|
|
* method so no path can perform a partial teardown. Id-agnostic by design:
|
|
* it always tears down whatever [activeAlarmId] currently is.
|
|
*/
|
|
private fun stopEverything() {
|
|
val stoppingId = activeAlarmId
|
|
cancelStationFallback()
|
|
cancelFadeLoop()
|
|
try {
|
|
player?.stop()
|
|
} catch (error: Throwable) {
|
|
Log.w(TAG, "alarm.service stop player failed", error)
|
|
}
|
|
try {
|
|
player?.release()
|
|
} catch (error: Throwable) {
|
|
// Non-atomic release fix (RES-4): a throw here must not abort the
|
|
// rest of the teardown below (state reset, wakelock, firing-record
|
|
// clear, stopForeground, stopSelf all still need to run).
|
|
Log.w(TAG, "alarm.service release player failed", error)
|
|
}
|
|
player = null
|
|
setActiveIds(null)
|
|
releaseWakeLock()
|
|
abandonAlarmAudioFocus()
|
|
if (stoppingId != null) {
|
|
NotificationManagerCompat.from(this).cancel(
|
|
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(stoppingId)
|
|
)
|
|
val scheduler = AlarmScheduler(this)
|
|
scheduler.clearFiringRecord(stoppingId)
|
|
scheduler.cancelAutoSilence(stoppingId)
|
|
}
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
} else {
|
|
@Suppress("DEPRECATION")
|
|
stopForeground(true)
|
|
}
|
|
stopSelf()
|
|
}
|
|
|
|
private fun buildNotification(
|
|
alarmId: String,
|
|
title: String,
|
|
stationName: String?,
|
|
snoozeMinutes: Int
|
|
) =
|
|
NotificationCompat.Builder(this, CHANNEL_ID)
|
|
.setSmallIcon(R.drawable.ic_stat_pluriwave)
|
|
.setColor(NotificationBrand.CYAN)
|
|
.setContentTitle(AlarmNotificationStrings.ringTitle(this))
|
|
.setContentText(
|
|
if (stationName.isNullOrBlank()) title else "$title - $stationName"
|
|
)
|
|
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
|
.setPriority(NotificationCompat.PRIORITY_MAX)
|
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
.setOngoing(true)
|
|
.setAutoCancel(false)
|
|
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes), true)
|
|
.setContentIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes))
|
|
.addAction(0, AlarmNotificationStrings.snoozeLabel(this), snoozePendingIntent(alarmId, snoozeMinutes))
|
|
.addAction(0, AlarmNotificationStrings.stopLabel(this), stopPendingIntent(alarmId))
|
|
.build()
|
|
|
|
private fun openAlarmPendingIntent(
|
|
alarmId: String,
|
|
title: String,
|
|
snoozeMinutes: Int
|
|
): PendingIntent =
|
|
PendingIntent.getActivity(
|
|
this,
|
|
requestCode(alarmId, 20),
|
|
Intent(this, MainActivity::class.java).apply {
|
|
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
|
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
|
|
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
|
|
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, snoozeMinutes)
|
|
},
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
|
|
private fun stopPendingIntent(alarmId: String): PendingIntent =
|
|
PendingIntent.getService(
|
|
this,
|
|
requestCode(alarmId, 21),
|
|
Intent(this, PluriWaveAlarmService::class.java).apply {
|
|
// Fail-safe fix (feedback item 1, SS-4a/NA-1a): the notification
|
|
// Stop action must route through the id-agnostic stop so it can
|
|
// never no-op a live ring; the extra id is kept only for logs.
|
|
action = ACTION_STOP_ACTIVE
|
|
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
|
},
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
|
|
private fun snoozePendingIntent(alarmId: String, minutes: Int): PendingIntent =
|
|
PendingIntent.getService(
|
|
this,
|
|
requestCode(alarmId, 30 + minutes),
|
|
Intent(this, PluriWaveAlarmService::class.java).apply {
|
|
action = ACTION_SNOOZE
|
|
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
|
putExtra(EXTRA_SNOOZE_MINUTES, minutes)
|
|
},
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
|
|
private fun acquireWakeLock() {
|
|
if (wakeLock?.isHeld == true) return
|
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
|
wakeLock = powerManager.newWakeLock(
|
|
PowerManager.PARTIAL_WAKE_LOCK,
|
|
"PluriWave:AlarmWakeLock"
|
|
).apply {
|
|
setReferenceCounted(false)
|
|
acquire(10 * 60 * 1000L)
|
|
}
|
|
}
|
|
|
|
private fun releaseWakeLock() {
|
|
try {
|
|
if (wakeLock?.isHeld == true) wakeLock?.release()
|
|
} catch (error: Throwable) {
|
|
Log.w(TAG, "alarm.service wakeLock release failed", error)
|
|
}
|
|
wakeLock = null
|
|
}
|
|
|
|
/**
|
|
* Requests transient alarm-scoped audio focus (Requirement: Manual
|
|
* transient focus; no system volume writes; design D3). Manual instead
|
|
* of relying on MediaPlayer's implicit focus handling so the service
|
|
* keeps STREAM_ALARM audible without ever writing another app's stream
|
|
* volume. AUDIOFOCUS_GAIN_TRANSIENT signals "temporary, give it back
|
|
* when I'm done" -- the OS pauses/ducks other playback for the ring and
|
|
* resumes it automatically once focus is abandoned. No-op listener:
|
|
* this service never reacts to focus loss (an alarm should keep
|
|
* ringing regardless of what else wants focus).
|
|
*/
|
|
private fun requestAlarmAudioFocus() {
|
|
val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
|
.setAudioAttributes(alarmAudioAttributes())
|
|
.setOnAudioFocusChangeListener(noopAudioFocusChangeListener)
|
|
.build()
|
|
audioFocusRequest = request
|
|
audioManager.requestAudioFocus(request)
|
|
} else {
|
|
@Suppress("DEPRECATION")
|
|
audioManager.requestAudioFocus(
|
|
noopAudioFocusChangeListener,
|
|
AudioManager.STREAM_ALARM,
|
|
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
|
|
)
|
|
}
|
|
}
|
|
|
|
/** Abandons the focus request from [requestAlarmAudioFocus]; a safe no-op if none is held. */
|
|
private fun abandonAlarmAudioFocus() {
|
|
val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
|
|
audioFocusRequest = null
|
|
} else {
|
|
@Suppress("DEPRECATION")
|
|
audioManager.abandonAudioFocus(noopAudioFocusChangeListener)
|
|
}
|
|
}
|
|
|
|
private fun setFallbackAssetDataSource(mediaPlayer: MediaPlayer, sound: String?) {
|
|
val path = fallbackAssetPath(sound)
|
|
try {
|
|
val descriptor = assets.openFd(path)
|
|
mediaPlayer.setDataSource(
|
|
descriptor.fileDescriptor,
|
|
descriptor.startOffset,
|
|
descriptor.length
|
|
)
|
|
descriptor.close()
|
|
} catch (error: Throwable) {
|
|
Log.w(TAG, "alarm.service asset descriptor failed path=$path; copying to cache", error)
|
|
val cached = File(cacheDir, path.substringAfterLast('/'))
|
|
assets.open(path).use { input ->
|
|
cached.outputStream().use { output -> input.copyTo(output) }
|
|
}
|
|
mediaPlayer.setDataSource(cached.absolutePath)
|
|
}
|
|
}
|
|
|
|
private fun fallbackAssetPath(sound: String?): String {
|
|
val fileName = when (sound) {
|
|
"campanaSuave" -> "alarm_campana_suave.wav"
|
|
"pulsoDigital" -> "alarm_pulso_digital.wav"
|
|
else -> "alarm_amanecer.wav"
|
|
}
|
|
return "flutter_assets/assets/audio/$fileName"
|
|
}
|
|
|
|
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
|
|
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
|
|
|
|
override fun onDestroy() {
|
|
stopAlarm(activeAlarmId)
|
|
if (instance === this) instance = null
|
|
super.onDestroy()
|
|
}
|
|
|
|
companion object {
|
|
private const val TAG = "PluriWave"
|
|
private const val CHANNEL_ID = "pluriwave_alarm_fire_v3"
|
|
private const val LEGACY_CHANNEL_NATIVE = "pluriwave_alarm_native"
|
|
private const val LEGACY_CHANNEL_FIRE = "pluriwave_alarm_fire"
|
|
private const val LEGACY_CHANNEL_FIRE_V2 = "pluriwave_alarm_fire_v2"
|
|
private const val CHANNELS_PREFS = "pluriwave_alarm_channels"
|
|
private const val KEY_CHANNELS_MIGRATED_V3 = "channels_migrated_v3"
|
|
private const val NOTIFICATION_ID = 92841
|
|
const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE"
|
|
const val ACTION_STOP_ACTIVE = "es.freetimelab.pluriwave.alarm.STOP_ACTIVE_NATIVE"
|
|
const val ACTION_SNOOZE = "es.freetimelab.pluriwave.alarm.SNOOZE_NATIVE"
|
|
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
|
|
|
|
/**
|
|
* Same-process companion snapshot (Decision 1): `MainActivity` reads
|
|
* this synchronously (no service round-trip) to build a verified stop
|
|
* result. Always written together with the instance-scoped
|
|
* [activeAlarmId] through the paired [setActiveIds] helper (feedback
|
|
* item, READ-6) -- always the id ACTUALLY ringing, never a
|
|
* caller-supplied one. Set in [startAlarm]; cleared in TWO documented
|
|
* sites -- [stopEverything] (confirmed stop/teardown) AND
|
|
* [startAlarm]'s own startForeground-failure catch (feedback item,
|
|
* READ-5), which never reaches [stopEverything] but must still clear
|
|
* the ids for the ring that never actually started.
|
|
*/
|
|
@Volatile
|
|
var activeRingingId: String? = null
|
|
|
|
/**
|
|
* Same-process companion reference (feedback item 1, RISK-1/RES-1/REL-2):
|
|
* set in [onCreate], cleared in [onDestroy]. Lets [stopActiveVerified]
|
|
* call [stopEverything] synchronously instead of trusting an async
|
|
* startService dispatch to have completed before reporting a result.
|
|
*/
|
|
@Volatile
|
|
private var instance: PluriWaveAlarmService? = null
|
|
private const val STATION_START_TIMEOUT_MILLIS = 15_000L
|
|
private const val FADE_TICK_MILLIS = 50L
|
|
private const val FADE_RANGE_DB = 40.0f
|
|
|
|
/**
|
|
* DeskClock-style exponential fade curve (AOSP AsyncRingtonePlayer /
|
|
* VolumeShaper reference shape -- reimplemented here on a plain
|
|
* Handler tick since MediaPlayer.setVolume takes a linear [0,1] gain
|
|
* and this service targets API levels below VolumeShaper's API 26
|
|
* floor). Volume rises from near-silence to [ceiling] over [fadeMs]
|
|
* along a DECIBEL ramp, not a linear amplitude ramp, so the rise
|
|
* SOUNDS smooth: human loudness perception is logarithmic, and a
|
|
* linear amplitude ramp sounds like it "arrives late" and jumps at
|
|
* the end. At elapsedMs<=0 the gain is -40dB (~1% of ceiling); at
|
|
* elapsedMs>=fadeMs the gain is 0dB (exactly ceiling). Pure
|
|
* function -- no side effects -- so it is safe to call from a timer
|
|
* tick, a prepare-time recompute, or a construction-time seed alike.
|
|
*/
|
|
private fun computeFadeVolume(elapsedMs: Long, fadeMs: Long, ceiling: Float): Float {
|
|
if (fadeMs <= 0) return ceiling.coerceIn(0f, 1f)
|
|
val fraction = (elapsedMs.toFloat() / fadeMs.toFloat()).coerceIn(0f, 1f)
|
|
val gainDb = fraction * FADE_RANGE_DB - FADE_RANGE_DB
|
|
val curve = Math.pow(10.0, (gainDb / 20.0).toDouble()).toFloat()
|
|
return (ceiling * curve).coerceIn(0f, 1f)
|
|
}
|
|
|
|
fun start(context: Context, source: Intent) {
|
|
ensureChannel(context)
|
|
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
|
action = PluriWaveAlarmReceiver.ACTION_FIRE
|
|
putExtras(source)
|
|
}
|
|
try {
|
|
ContextCompat.startForegroundService(context, intent)
|
|
Log.d(TAG, "alarm.service start requested")
|
|
} catch (error: Throwable) {
|
|
Log.e(TAG, "alarm.service start failed", error)
|
|
}
|
|
}
|
|
|
|
fun stop(context: Context, alarmId: String) {
|
|
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
|
action = ACTION_STOP
|
|
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
|
}
|
|
try {
|
|
context.startService(intent)
|
|
Log.d(TAG, "alarm.service stop action requested id=$alarmId")
|
|
} catch (error: Throwable) {
|
|
Log.e(TAG, "alarm.service stop request failed id=$alarmId", error)
|
|
try {
|
|
context.stopService(intent)
|
|
} catch (fallbackError: Throwable) {
|
|
Log.e(TAG, "alarm.service stop fallback failed id=$alarmId", fallbackError)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Id-agnostic fail-safe stop (Decision 1): silences whatever is ringing. */
|
|
fun stopActive(context: Context) {
|
|
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
|
action = ACTION_STOP_ACTIVE
|
|
}
|
|
try {
|
|
context.startService(intent)
|
|
Log.d(TAG, "alarm.service stopActive action requested")
|
|
} catch (error: Throwable) {
|
|
Log.e(TAG, "alarm.service stopActive request failed", error)
|
|
try {
|
|
context.stopService(intent)
|
|
} catch (fallbackError: Throwable) {
|
|
Log.e(TAG, "alarm.service stopActive fallback failed", fallbackError)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Same-process VERIFIED stop (feedback item 1, RISK-1/RES-1/REL-2):
|
|
* fixes the hollow verification where [stopActive]'s async
|
|
* startService dispatch made the result a literal `true` decided
|
|
* before teardown ran. When a live [instance] exists, invokes
|
|
* [stopEverything] on it SYNCHRONOUSLY (the MethodChannel caller and
|
|
* this service both run on the main thread of the SAME process, so
|
|
* no round trip is needed) and returns whether teardown actually
|
|
* cleared [activeRingingId]. Falls back to the async [stopActive]
|
|
* dispatch only when no instance is alive -- nothing can be ringing
|
|
* without a live instance, so [activeRingingId] is already null and
|
|
* the fallback trivially succeeds.
|
|
*/
|
|
fun stopActiveVerified(context: Context): Boolean {
|
|
val current = instance
|
|
if (current != null) {
|
|
current.stopEverything()
|
|
return activeRingingId == null
|
|
}
|
|
stopActive(context)
|
|
return activeRingingId == null
|
|
}
|
|
|
|
private fun ensureChannel(context: Context) {
|
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
migrateLegacyChannels(context, manager)
|
|
// Re-create each time (not early-returning when present) so the
|
|
// localized name/description refresh after a locale change. Android
|
|
// updates name + description on an existing channel; importance and
|
|
// sound stay fixed from first creation. setSound(null, null) is
|
|
// REQUIRED for silence: omitting the call leaves the platform
|
|
// DEFAULT notification sound on the channel (same reason the
|
|
// pre-notice channel calls it explicitly). This channel must be
|
|
// silent (Requirement: Fire notification posts with no sound) --
|
|
// the native MediaPlayer on STREAM_ALARM is the only audible
|
|
// source, so a channel sound would double it.
|
|
val channel = NotificationChannel(
|
|
CHANNEL_ID,
|
|
AlarmNotificationStrings.fireChannelName(context),
|
|
NotificationManager.IMPORTANCE_HIGH
|
|
).apply {
|
|
description = AlarmNotificationStrings.fireChannelDescription(context)
|
|
setSound(null, null)
|
|
enableVibration(true)
|
|
}
|
|
manager.createNotificationChannel(channel)
|
|
}
|
|
|
|
// Android locks channel sound/importance at creation time, so the
|
|
// only way to apply a changed shape (USAGE_ALARM in v2, silent in v3)
|
|
// on existing installs is deleting the legacy channels and recreating
|
|
// under a new versioned id. Runs once, guarded by a flag;
|
|
// deleteNotificationChannel is a safe no-op for an id that was never
|
|
// created (fresh installs) or already deleted (re-runs).
|
|
private fun migrateLegacyChannels(context: Context, manager: NotificationManager) {
|
|
val prefs = context.createDeviceProtectedStorageContext()
|
|
.getSharedPreferences(CHANNELS_PREFS, Context.MODE_PRIVATE)
|
|
if (prefs.getBoolean(KEY_CHANNELS_MIGRATED_V3, false)) return
|
|
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_NATIVE) }
|
|
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE) }
|
|
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE_V2) }
|
|
prefs.edit().putBoolean(KEY_CHANNELS_MIGRATED_V3, true).apply()
|
|
Log.d(TAG, "alarm.service legacy notification channels migrated to v3")
|
|
}
|
|
|
|
private fun requestCode(id: String, slot: Int): Int = 67 * id.hashCode() + slot
|
|
}
|
|
}
|