feat(alarm): make the ring immune to device media volume
The alarm's steady-state audio runs on the Flutter media-stream player after the native handoff, so device volume 0 silenced it entirely. The ring now forces STREAM_MUSIC to an audible reference: Dart requests the override before pre-starting alarm audio (fallback WAV included), Kotlin captures the current volume once and restores it idempotently on every exit path (dismiss, snooze, dispose), with a native best-effort backstop in service teardown. The backstop is handoff-aware via PluriWaveAlarmService.flutterOwnsRing: confirmFlutterAudio marks the handoff before triggering the native stop, so the backstop cannot restore the volume mid-ring right as the Flutter player takes over (that would re-silence the alarm at volume 0). The flag resets at every ring start; Flutter process death after handoff remains a documented best-effort gap. The alarm's perceived loudness keeps ramping 5% to the configured volume through the player as before; normal radio playback and call ducking never touch the override. Work unit 2/3 of alarm-volume-ramp-restore (ring volume override).
This commit is contained in:
@@ -159,6 +159,14 @@ class MainActivity : AudioServiceActivity() {
|
||||
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)
|
||||
}
|
||||
@@ -217,6 +225,17 @@ 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()
|
||||
result.success(null)
|
||||
}
|
||||
"restoreMediaVolume" -> {
|
||||
Log.d(tag, "alarm.channel restoreMediaVolume")
|
||||
restoreMediaVolume()
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -288,6 +307,64 @@ 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() {
|
||||
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)
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, max, 0)
|
||||
capturedMediaVolume = current
|
||||
mediaVolumeOverridden = true
|
||||
Log.d(tag, "alarm.channel overrideMediaVolumeForRing captured=$current 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
|
||||
@@ -828,6 +905,18 @@ 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
|
||||
@@ -844,5 +933,33 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,11 @@ 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
|
||||
|
||||
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave"
|
||||
val stationName = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME)
|
||||
@@ -366,6 +371,19 @@ 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() }
|
||||
}
|
||||
if (alarmId != null) {
|
||||
NotificationManagerCompat.from(this).cancel(
|
||||
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
|
||||
@@ -500,6 +518,16 @@ 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()
|
||||
}
|
||||
|
||||
@@ -518,6 +546,30 @@ class PluriWaveAlarmService : Service() {
|
||||
private const val FADE_IN_STEP_MILLIS = 250L
|
||||
private const val FADE_IN_START_FRACTION = 0.05f
|
||||
|
||||
/**
|
||||
* 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].
|
||||
*/
|
||||
@Volatile
|
||||
var flutterOwnsRing: Boolean = false
|
||||
|
||||
fun start(context: Context, source: Intent) {
|
||||
ensureChannel(context)
|
||||
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
||||
|
||||
Reference in New Issue
Block a user