feat(alarm): native-only ring with DeskClock fade curve and silent channel
Build & Deploy PluriWave / Análisis de código (push) Successful in 42s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m1s

PluriWaveAlarmService becomes the sole ring-audio owner for the whole
ring (WU2 of 2, completes the split started at bd7f883): the linear
step ramp (initialVolume/startFadeIn, 250ms steps) is replaced by a
DeskClock-style exponential dB curve (computeFadeVolume: gainDb =
fraction*40-40, curve = 10^(gainDb/20)) on a 50ms Handler loop anchored
at ring start, not audio start, so all three fallback sources (station,
fallback station, bundled WAV) share one clock and a source that joins
mid-fade enters at the elapsed level instead of restarting from
silence. Each source also recomputes and applies the curve immediately
before start() to stay pop-free through prepareAsync's variable
buffering delay.

The STREAM_MUSIC device-volume override/restore is replaced by manual
AUDIOFOCUS_GAIN_TRANSIENT request/abandon on STREAM_ALARM (no-op focus
listener, requested once per ring in startAudio, abandoned
unconditionally in stopAlarm's full-teardown branch): the service never
calls setStreamVolume on any stream. The fire notification channel
migrates pluriwave_alarm_fire_v2 -> pluriwave_alarm_fire_v3, now silent
(no setSound; native MediaPlayer is the only audible source) while
keeping vibration and IMPORTANCE_HIGH for the full-screen intent; the
migration guard folds in a third delete for the v2 id alongside the two
pre-existing legacy ids, guarded by a renamed channels_migrated_v3 flag
so it still runs exactly once.

Since the Dart ringing screen (WU1) no longer calls confirmFlutterAudio,
overrideMediaVolumeForRing or restoreMediaVolume, their native surface
is now dead: deletes the flutterOwnsRing handoff flag and both its
backstop call sites in PluriWaveAlarmService, and the three
MethodChannel handlers plus their backing methods and companion state
in MainActivity.
This commit is contained in:
2026-07-12 12:02:45 +02:00
parent 5bd861d7fb
commit 884567beaa
2 changed files with 159 additions and 247 deletions
@@ -153,24 +153,6 @@ class MainActivity : AudioServiceActivity() {
result.success(null)
}
}
"confirmFlutterAudio" -> {
val id = call.argument<String>("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<Number>("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)
}
}
}
}
@@ -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