diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index c92fc98..33d5835 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -153,24 +153,6 @@ class MainActivity : AudioServiceActivity() { result.success(null) } } - "confirmFlutterAudio" -> { - val id = call.argument("id") - Log.d(tag, "alarm.channel confirmFlutterAudio id=$id") - if (id == null) { - result.error("INVALID_ALARM", "Missing alarm id", null) - } else { - // Mark the handoff BEFORE triggering the native stop so - // PluriWaveAlarmService.stopAlarm() -- reached via the - // identical ACTION_STOP path a real dismiss uses -- can - // tell "Flutter took over the ring" apart from a true - // ring exit and skip its volume-restore backstop - // accordingly (Requirement: Ring-scoped device-volume - // override, restore only when the ring ends). - PluriWaveAlarmService.flutterOwnsRing = true - PluriWaveAlarmService.stop(this, id) - result.success(null) - } - } "diagnostics" -> { Log.d(tag, "alarm.channel diagnostics") result.success( @@ -225,17 +207,6 @@ class MainActivity : AudioServiceActivity() { } result.success(null) } - "overrideMediaVolumeForRing" -> { - val fraction = call.argument("fraction")?.toFloat() ?: 1.0f - Log.d(tag, "alarm.channel overrideMediaVolumeForRing fraction=$fraction") - overrideMediaVolumeForRing(fraction) - result.success(null) - } - "restoreMediaVolume" -> { - Log.d(tag, "alarm.channel restoreMediaVolume") - restoreMediaVolume() - result.success(null) - } else -> result.notImplemented() } } @@ -307,73 +278,6 @@ class MainActivity : AudioServiceActivity() { ) } - // ------------------------------------------------------------------------- - // Ring-scoped media-volume override (Requirement: Ring-scoped device-volume - // override). Forces STREAM_MUSIC to an audible reference level while an - // alarm rings so the Flutter media-stream player is never silenced by a - // device media volume of 0, then restores the captured value on exit. - // ------------------------------------------------------------------------- - - /** - * Captures the current STREAM_MUSIC volume and raises it to the fixed - * audible reference level (device max) so the alarm cannot be silenced - * by device volume 0. Idempotent: a second call while already overridden - * is a no-op so the originally captured value is never clobbered. - */ - private fun overrideMediaVolumeForRing(fraction: Float) { - if (mediaVolumeOverridden) { - Log.d(tag, "alarm.channel overrideMediaVolumeForRing skipped (already overridden)") - return - } - try { - val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - val current = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - // Cap the stream at the alarm's configured fraction of the device - // maximum (not the raw max): this keeps the ring independent of the - // device's own volume (audible at 0) while "50%" means 50% of max, - // with the player ramping up to full underneath this cap. At least - // 1 so the ring is never silenced by rounding. - val target = Math.round(max * fraction.coerceIn(0f, 1f)).coerceIn(1, max) - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0) - capturedMediaVolume = current - mediaVolumeOverridden = true - Log.d( - tag, - "alarm.channel overrideMediaVolumeForRing captured=$current target=$target max=$max" - ) - } catch (error: Throwable) { - Log.e(tag, "alarm.channel overrideMediaVolumeForRing failed", error) - } - } - - /** - * Restores STREAM_MUSIC to the value captured by - * [overrideMediaVolumeForRing]. Idempotent: a no-op when no override is - * active, so double-exit paths (e.g. dismiss's `_silenciarAudio` + - * `dispose`, or the native backstop firing after Dart already restored) - * never throw and never re-apply a stale value. - */ - private fun restoreMediaVolume() { - if (!mediaVolumeOverridden) { - Log.d(tag, "alarm.channel restoreMediaVolume skipped (not overridden)") - return - } - val target = capturedMediaVolume - try { - if (target != null) { - val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0) - } - Log.d(tag, "alarm.channel restoreMediaVolume restored=$target") - } catch (error: Throwable) { - Log.e(tag, "alarm.channel restoreMediaVolume failed", error) - } finally { - mediaVolumeOverridden = false - capturedMediaVolume = null - } - } - private fun requestExactAlarmPermission(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager @@ -914,18 +818,6 @@ class MainActivity : AudioServiceActivity() { @Volatile private var activeInstance: MainActivity? = null - /** - * Ring-scoped STREAM_MUSIC override state (Requirement: Ring-scoped - * device-volume override). @Volatile, not persisted: does NOT survive - * process death — documented residual gap, best-effort restore only - * via [restoreMediaVolumeBestEffort]. - */ - @Volatile - private var mediaVolumeOverridden: Boolean = false - - @Volatile - private var capturedMediaVolume: Int? = null - /** * Bridge for components without an activity (PluriWaveAlarmService): * forwards alarm events through the existing alarmFired MethodChannel @@ -942,33 +834,5 @@ class MainActivity : AudioServiceActivity() { activity.alarmMethodChannel?.invokeMethod("alarmFired", payload) } } - - /** - * Best-effort backstop for PluriWaveAlarmService teardown paths - * (stopAlarm/onDestroy): restores the ring-scoped media volume - * override when the Flutter engine/activity is alive. No-op and - * never throws when the activity is dead (Decision: Native backstop). - * - * Callers guard this: PluriWaveAlarmService.stopAlarm()/onDestroy() - * only invoke it when PluriWaveAlarmService.flutterOwnsRing is - * false, since stopAlarm() also runs at the native-to-Flutter - * handoff (confirmFlutterAudio) and this method must never restore - * mid-ring -- see [PluriWaveAlarmService.flutterOwnsRing]. The - * idempotent guard in restoreMediaVolume() remains a secondary - * safety net for legitimate double-calls at a true ring end (e.g. - * stopAlarm() then onDestroy() for the same exit). - */ - fun restoreMediaVolumeBestEffort() { - val activity = activeInstance - if (activity == null) { - Log.d(STATIC_TAG, "alarm.channel restoreMediaVolumeBestEffort skipped (engine dead)") - return - } - try { - activity.restoreMediaVolume() - } catch (error: Throwable) { - Log.e(STATIC_TAG, "alarm.channel restoreMediaVolumeBestEffort failed", error) - } - } } } diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt index d90c1ac..7378cc9 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt @@ -8,6 +8,8 @@ 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 @@ -15,7 +17,7 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.PowerManager -import android.provider.Settings +import android.os.SystemClock import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -26,12 +28,12 @@ import java.io.File * Foreground service that owns native alarm audio and the single ringing * notification (NOTIFICATION_ID, full-screen intent). * - * Fade-in ownership boundary: this service ramps volume ONLY for its own - * MediaPlayer audio (station stream, fallback station or bundled WAV). The - * Flutter ringing screen owns the fade for the audio it starts itself - * (radio handler / local fallback player). They never play the same source - * simultaneously: the service stops once Flutter confirms its own audio via - * confirmFlutterAudio. + * 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 @@ -39,7 +41,10 @@ class PluriWaveAlarmService : Service() { private var activeAlarmId: String? = null private val mainHandler = Handler(Looper.getMainLooper()) private var stationFallbackRunnable: Runnable? = null - private var fadeInRunnable: 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 @@ -90,11 +95,12 @@ class PluriWaveAlarmService : Service() { return } activeAlarmId = alarmId - // Reset for this new ring: flutterOwnsRing must never carry a stale - // `true` forward from a PREVIOUS ring's confirmFlutterAudio handoff, - // or this ring's own backstop restore would be wrongly suppressed - // (Requirement: Ring-scoped device-volume override). - flutterOwnsRing = false + // 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) @@ -157,6 +163,8 @@ class PluriWaveAlarmService : Service() { ) { 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. @@ -206,7 +214,11 @@ class PluriWaveAlarmService : Service() { player?.release() player = null scheduleStationFallback(alarmId, stage, onStageFailed) - val startVolume = initialVolume(volume, fadeInSegundos) + val startVolume = computeFadeVolume( + SystemClock.elapsedRealtime() - fadeAnchorElapsedMs, + fadeInSegundos * 1000L, + volume + ) try { player = MediaPlayer().apply { setAudioAttributes(alarmAudioAttributes()) @@ -220,8 +232,19 @@ class PluriWaveAlarmService : Service() { 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() - startFadeIn(alarmId, it, volume, fadeInSegundos) Log.d( TAG, "alarm.service $stage started id=$alarmId station=$stationName url=$stationUrl" @@ -264,7 +287,11 @@ class PluriWaveAlarmService : Service() { player = null val source = fallbackAssetPath(fallbackSound) - val startVolume = initialVolume(volume, fadeInSegundos) + val startVolume = computeFadeVolume( + SystemClock.elapsedRealtime() - fadeAnchorElapsedMs, + fadeInSegundos * 1000L, + volume + ) try { player = MediaPlayer().apply { setAudioAttributes(alarmAudioAttributes()) @@ -273,8 +300,15 @@ class PluriWaveAlarmService : Service() { 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() - startFadeIn(alarmId, it, volume, fadeInSegundos) Log.d(TAG, "alarm.service fallback started id=$alarmId source=$source reason=$reason") } setOnErrorListener { mp, what, extra -> @@ -306,45 +340,40 @@ class PluriWaveAlarmService : Service() { mainHandler.postDelayed(runnable, STATION_START_TIMEOUT_MILLIS) } - private fun initialVolume(volume: Float, fadeInSegundos: Int): Float = - if (fadeInSegundos > 0) { - (volume * FADE_IN_START_FRACTION).coerceIn(0f, 1f) - } else { - volume - } - - private fun startFadeIn( - alarmId: String, - mediaPlayer: MediaPlayer, - targetVolume: Float, - fadeInSegundos: Int - ) { - cancelFadeIn() + /** + * 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 steps = ((fadeInSegundos * 1000L) / FADE_IN_STEP_MILLIS).toInt().coerceAtLeast(1) - val startVolume = initialVolume(targetVolume, fadeInSegundos) - var step = 0 + val fadeMs = fadeInSegundos * 1000L val runnable = object : Runnable { override fun run() { if (activeAlarmId != alarmId) return - step++ - val fraction = step.toFloat() / steps - val current = (startVolume + (targetVolume - startVolume) * fraction) - .coerceIn(0f, 1f) - runCatching { mediaPlayer.setVolume(current, current) } - if (step < steps) { - mainHandler.postDelayed(this, FADE_IN_STEP_MILLIS) + 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) } } } - fadeInRunnable = runnable - mainHandler.postDelayed(runnable, FADE_IN_STEP_MILLIS) - Log.d(TAG, "alarm.service fade-in started id=$alarmId seconds=$fadeInSegundos steps=$steps") + fadeLoopRunnable = runnable + mainHandler.postDelayed(runnable, FADE_TICK_MILLIS) + Log.d(TAG, "alarm.service fade loop started id=$alarmId seconds=$fadeInSegundos") } - private fun cancelFadeIn() { - fadeInRunnable?.let { mainHandler.removeCallbacks(it) } - fadeInRunnable = null + private fun cancelFadeLoop() { + fadeLoopRunnable?.let { mainHandler.removeCallbacks(it) } + fadeLoopRunnable = null } private fun cancelStationFallback() { @@ -379,7 +408,7 @@ class PluriWaveAlarmService : Service() { return } cancelStationFallback() - cancelFadeIn() + cancelFadeLoop() try { player?.stop() } catch (error: Throwable) { @@ -389,19 +418,7 @@ class PluriWaveAlarmService : Service() { player = null activeAlarmId = null releaseWakeLock() - // Best-effort backstop restore (Requirement: Ring-scoped device-volume - // override; Scenario "App killed mid-ring"). Skipped when Flutter has - // taken over the ring (flutterOwnsRing == true): stopAlarm() also runs - // at the native-to-Flutter handoff (confirmFlutterAudio), which is NOT - // a ring exit, and restoring here would silence the Flutter-driven - // remainder of the ring. From handoff onward Dart owns restore via - // _silenciarAudio()/dispose() plus the idempotent restoreMediaVolume() - // guard. Native-only exits (fire-notification STOP, real snooze, - // teardown before any handoff) keep this backstop, since - // flutterOwnsRing is still false for those. - if (!flutterOwnsRing) { - runCatching { MainActivity.restoreMediaVolumeBestEffort() } - } + abandonAlarmAudioFocus() if (alarmId != null) { NotificationManagerCompat.from(this).cancel( PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId) @@ -502,6 +519,48 @@ class PluriWaveAlarmService : Service() { 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 { @@ -536,57 +595,46 @@ class PluriWaveAlarmService : Service() { override fun onDestroy() { stopAlarm(activeAlarmId) - // Defensive-in-depth: covers any onDestroy path that could ever - // bypass stopAlarm() directly (e.g. abrupt service teardown). - // Idempotent, so redundant with the call already inside stopAlarm(). - // Same flutterOwnsRing guard as stopAlarm() -- skip when Flutter has - // taken over the ring, so an onDestroy() racing in after handoff - // (e.g. the system reclaiming the now-idle service) cannot restore - // mid-ring either. - if (!flutterOwnsRing) { - runCatching { MainActivity.restoreMediaVolumeBestEffort() } - } super.onDestroy() } companion object { private const val TAG = "PluriWave" - private const val CHANNEL_ID = "pluriwave_alarm_fire_v2" + 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_V2 = "channels_migrated_v2" + 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_SNOOZE = "es.freetimelab.pluriwave.alarm.SNOOZE_NATIVE" const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes" private const val STATION_START_TIMEOUT_MILLIS = 15_000L - private const val FADE_IN_STEP_MILLIS = 250L - private const val FADE_IN_START_FRACTION = 0.05f + private const val FADE_TICK_MILLIS = 50L + private const val FADE_RANGE_DB = 40.0f /** - * Set by [MainActivity]'s `confirmFlutterAudio` handler immediately - * BEFORE it triggers the native stop, to distinguish "Flutter took - * over the ring" (native-to-Flutter handoff, not a ring exit) from a - * true ring exit (dismiss/snooze/service teardown). [stopAlarm] and - * [onDestroy] read this to skip the best-effort volume-restore - * backstop during handoff -- restoring here would silence the - * Flutter-driven remainder of the ring (Requirement: Ring-scoped - * device-volume override, restore only on true ring end). From - * handoff onward Dart owns restore via `_silenciarAudio()`/ - * `dispose()` and the idempotent `restoreMediaVolume()` guard on - * [MainActivity]. Reset to `false` at the start of every new ring in - * [startAlarm] so a stale `true` left over from a PREVIOUS ring can - * never suppress the CURRENT ring's backstop. - * - * Accepted residual gap: if the Flutter process dies AFTER handoff - * (flag already `true`) but BEFORE Dart's own restore runs, no - * restorer fires at all -- same class of accepted gap as the - * pre-handoff process-death residual already documented on - * [MainActivity.restoreMediaVolumeBestEffort]. + * 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. */ - @Volatile - var flutterOwnsRing: Boolean = false + 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) @@ -626,8 +674,11 @@ class PluriWaveAlarmService : Service() { 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. + // updates name + description on an existing channel; importance + // stays fixed from first creation. No setSound call: this channel + // is silent by construction (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), @@ -635,28 +686,25 @@ class PluriWaveAlarmService : Service() { ).apply { description = AlarmNotificationStrings.fireChannelDescription(context) enableVibration(true) - setSound( - Settings.System.DEFAULT_ALARM_ALERT_URI, - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_ALARM) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build() - ) } manager.createNotificationChannel(channel) } - // Android locks channel sound at creation time; the only way to apply - // USAGE_ALARM on existing installs is deleting the legacy channels and - // recreating under the versioned id. Runs once, guarded by a flag. + // 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_V2, false)) return + if (prefs.getBoolean(KEY_CHANNELS_MIGRATED_V3, false)) return runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_NATIVE) } runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE) } - prefs.edit().putBoolean(KEY_CHANNELS_MIGRATED_V2, true).apply() - Log.d(TAG, "alarm.service legacy notification channels migrated to v2") + 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