From 1db8d184a0c5053f23c6a5d924eaf02c709797dd Mon Sep 17 00:00:00 2001 From: felixer Date: Fri, 4 Sep 2026 10:04:37 -0700 Subject: [PATCH 1/4] fix: play rest alerts through active headphones --- .../utils/WorkoutNotificationManager.kt | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt index f05c5503e..d639f53a0 100644 --- a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt +++ b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt @@ -3,6 +3,14 @@ package expo.modules.workoutworker.utils import android.app.Notification import android.app.NotificationManager import android.content.Context +import android.media.AudioAttributes +import android.media.AudioDeviceInfo +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.media.MediaPlayer +import android.media.RingtoneManager +import android.os.Build +import android.util.Log import androidx.annotation.DrawableRes import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE @@ -31,12 +39,34 @@ enum class RestWindow(@DrawableRes val icon: Int) { class WorkoutNotificationManager(private val context: Context) { + private val audioManager = context.getSystemService(AudioManager::class.java) + private var restTonePlayer: MediaPlayer? = null + private var restToneAudioFocusRequest: AudioFocusRequest? = null + private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> + if (focusChange == AudioManager.AUDIOFOCUS_LOSS || focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) { + releaseRestTonePlayer(audioManager) + } + } + companion object { const val PERSISTENT_NOTIFICATION_ID = 123 const val REST_NOTIFICATION_ID = 1234 const val PERSISTENT_CHANNEL_ID = "workout_channel" const val REST_CHANNEL_ID = "rest_channel" + + private val HEADPHONE_AUDIO_DEVICE_TYPES = setOf( + AudioDeviceInfo.TYPE_BLE_HEADSET, + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + AudioDeviceInfo.TYPE_USB_HEADSET, + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_WIRED_HEADSET, + ) + + private val REST_TONE_AUDIO_ATTRIBUTES = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() } // Set once the user swipes the Live Update away; from then on we stop requesting promotion so @@ -117,9 +147,94 @@ class WorkoutNotificationManager(private val context: Context) { fun notifyRest(notification: Notification) { val manager = context.getSystemService(NotificationManager::class.java) manager.notify(REST_NOTIFICATION_ID, notification) + playRestToneThroughHeadphonesWhenNotificationsAreMuted() + } + + /** + * Android mutes notification audio in vibrate and silent modes, even when media is playing through + * headphones. In that specific situation, play the same system notification tone as media so the + * rest alert reaches the headphones without making a vibrate-only phone audible in the room. + */ + private fun playRestToneThroughHeadphonesWhenNotificationsAreMuted() { + if ( + audioManager.ringerMode == AudioManager.RINGER_MODE_NORMAL || + !audioManager.hasHeadphoneOutput() + ) { + return + } + + val toneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) ?: return + releaseRestTonePlayer(audioManager) + if (!requestRestToneAudioFocus(audioManager)) return + + try { + restTonePlayer = MediaPlayer().apply { + setAudioAttributes(REST_TONE_AUDIO_ATTRIBUTES) + setDataSource(context, toneUri) + setOnPreparedListener { it.start() } + setOnCompletionListener { player -> releaseRestTonePlayer(player, audioManager) } + setOnErrorListener { player, _, _ -> + releaseRestTonePlayer(player, audioManager) + true + } + prepareAsync() + } + } catch (e: Exception) { + releaseRestTonePlayer(audioManager) + Log.e("WorkoutNotificationManager", "Failed to play rest tone through headphones", e) + } + } + private fun requestRestToneAudioFocus(audioManager: AudioManager): Boolean { + val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + .setAudioAttributes(REST_TONE_AUDIO_ATTRIBUTES) + .setOnAudioFocusChangeListener(audioFocusChangeListener) + .build() + restToneAudioFocusRequest = request + audioManager.requestAudioFocus(request) + } else { + @Suppress("DEPRECATION") + audioManager.requestAudioFocus( + audioFocusChangeListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK, + ) + } + val granted = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + if (!granted) restToneAudioFocusRequest = null + return granted } + private fun releaseRestTonePlayer(player: MediaPlayer, audioManager: AudioManager) { + player.release() + if (restTonePlayer === player) { + restTonePlayer = null + abandonRestToneAudioFocus(audioManager) + } + } + + private fun releaseRestTonePlayer(audioManager: AudioManager) { + restTonePlayer?.release() + restTonePlayer = null + abandonRestToneAudioFocus(audioManager) + } + + private fun abandonRestToneAudioFocus(audioManager: AudioManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + restToneAudioFocusRequest?.let(audioManager::abandonAudioFocusRequest) + restToneAudioFocusRequest = null + } else { + @Suppress("DEPRECATION") + audioManager.abandonAudioFocus(audioFocusChangeListener) + } + } + + private fun AudioManager.hasHeadphoneOutput(): Boolean = + getDevices(AudioManager.GET_DEVICES_OUTPUTS).any { device -> + device.type in HEADPHONE_AUDIO_DEVICE_TYPES + } + fun clearPersistentNotification() { val manager = context.getSystemService(NotificationManager::class.java) manager.cancel(PERSISTENT_NOTIFICATION_ID) From 33aa79f92983110aa5e0eb19e61f5f15136991e9 Mon Sep 17 00:00:00 2001 From: felixer Date: Fri, 4 Sep 2026 11:07:23 -0700 Subject: [PATCH 2/4] refactor: generate headphone rest tone --- .../utils/WorkoutNotificationManager.kt | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt index d639f53a0..b43f9b463 100644 --- a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt +++ b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt @@ -7,9 +7,10 @@ import android.media.AudioAttributes import android.media.AudioDeviceInfo import android.media.AudioFocusRequest import android.media.AudioManager -import android.media.MediaPlayer -import android.media.RingtoneManager +import android.media.ToneGenerator import android.os.Build +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.annotation.DrawableRes import androidx.core.app.NotificationCompat @@ -40,11 +41,12 @@ enum class RestWindow(@DrawableRes val icon: Int) { class WorkoutNotificationManager(private val context: Context) { private val audioManager = context.getSystemService(AudioManager::class.java) - private var restTonePlayer: MediaPlayer? = null + private val restToneHandler = Handler(Looper.getMainLooper()) + private var restToneGenerator: ToneGenerator? = null private var restToneAudioFocusRequest: AudioFocusRequest? = null private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> if (focusChange == AudioManager.AUDIOFOCUS_LOSS || focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) { - releaseRestTonePlayer(audioManager) + releaseRestToneGenerator(audioManager) } } @@ -55,6 +57,10 @@ class WorkoutNotificationManager(private val context: Context) { const val PERSISTENT_CHANNEL_ID = "workout_channel" const val REST_CHANNEL_ID = "rest_channel" + private const val REST_TONE_DURATION_MS = 600 + private const val REST_TONE_VOLUME_PERCENT = 100 + private const val REST_TONE_TYPE = ToneGenerator.TONE_PROP_BEEP + private val HEADPHONE_AUDIO_DEVICE_TYPES = setOf( AudioDeviceInfo.TYPE_BLE_HEADSET, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, @@ -152,8 +158,8 @@ class WorkoutNotificationManager(private val context: Context) { /** * Android mutes notification audio in vibrate and silent modes, even when media is playing through - * headphones. In that specific situation, play the same system notification tone as media so the - * rest alert reaches the headphones without making a vibrate-only phone audible in the room. + * headphones. In that specific situation, play a generated tone as media so the rest alert reaches + * the headphones without making a vibrate-only phone audible in the room. */ private fun playRestToneThroughHeadphonesWhenNotificationsAreMuted() { if ( @@ -163,24 +169,25 @@ class WorkoutNotificationManager(private val context: Context) { return } - val toneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) ?: return - releaseRestTonePlayer(audioManager) + releaseRestToneGenerator(audioManager) if (!requestRestToneAudioFocus(audioManager)) return try { - restTonePlayer = MediaPlayer().apply { - setAudioAttributes(REST_TONE_AUDIO_ATTRIBUTES) - setDataSource(context, toneUri) - setOnPreparedListener { it.start() } - setOnCompletionListener { player -> releaseRestTonePlayer(player, audioManager) } - setOnErrorListener { player, _, _ -> - releaseRestTonePlayer(player, audioManager) - true - } - prepareAsync() + val generator = ToneGenerator(AudioManager.STREAM_MUSIC, REST_TONE_VOLUME_PERCENT) + restToneGenerator = generator + if (!generator.startTone(REST_TONE_TYPE, REST_TONE_DURATION_MS)) { + releaseRestToneGenerator(audioManager) + Log.e("WorkoutNotificationManager", "Failed to start generated rest tone") + return } + + restToneHandler.postDelayed({ + if (restToneGenerator === generator) { + releaseRestToneGenerator(audioManager) + } + }, REST_TONE_DURATION_MS.toLong()) } catch (e: Exception) { - releaseRestTonePlayer(audioManager) + releaseRestToneGenerator(audioManager) Log.e("WorkoutNotificationManager", "Failed to play rest tone through headphones", e) } } @@ -206,17 +213,10 @@ class WorkoutNotificationManager(private val context: Context) { return granted } - private fun releaseRestTonePlayer(player: MediaPlayer, audioManager: AudioManager) { - player.release() - if (restTonePlayer === player) { - restTonePlayer = null - abandonRestToneAudioFocus(audioManager) - } - } - - private fun releaseRestTonePlayer(audioManager: AudioManager) { - restTonePlayer?.release() - restTonePlayer = null + private fun releaseRestToneGenerator(audioManager: AudioManager) { + restToneGenerator?.stopTone() + restToneGenerator?.release() + restToneGenerator = null abandonRestToneAudioFocus(audioManager) } From 6cdf3d3746c14a660d829d8d48e905fdd5f7a59c Mon Sep 17 00:00:00 2001 From: felixer Date: Fri, 4 Sep 2026 11:26:09 -0700 Subject: [PATCH 3/4] fix: use continuous generated rest tone --- .../modules/workoutworker/utils/WorkoutNotificationManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt index b43f9b463..adc086c7e 100644 --- a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt +++ b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt @@ -59,7 +59,7 @@ class WorkoutNotificationManager(private val context: Context) { private const val REST_TONE_DURATION_MS = 600 private const val REST_TONE_VOLUME_PERCENT = 100 - private const val REST_TONE_TYPE = ToneGenerator.TONE_PROP_BEEP + private const val REST_TONE_TYPE = ToneGenerator.TONE_DTMF_5 private val HEADPHONE_AUDIO_DEVICE_TYPES = setOf( AudioDeviceInfo.TYPE_BLE_HEADSET, From 449ee728054d67f31ac3572de2aa8c84f23f3870 Mon Sep 17 00:00:00 2001 From: felixer Date: Fri, 4 Sep 2026 15:56:00 -0700 Subject: [PATCH 4/4] Fix notification while on silent Add Android headphone rest alerts and an opt-in three-second DTMF countdown that obeys the notification setting. Preserve rest deadlines and stop playback on cancellation or audio route/focus changes. --- .../handlers/WorkoutUpdatedHandler.kt | 116 ++++++++++---- .../workoutworker/utils/RestTonePcm.kt | 35 +++++ .../utils/WorkoutNotificationManager.kt | 144 ++++++++++++------ app/src/app/(tabs)/settings/notifications.tsx | 24 ++- .../foundation/ms-icon-source.tsx | 2 + .../foundation/segmented-list-switch.tsx | 5 +- app/src/i18n/en.json | 2 + .../models/workout-worker-messages.spec.ts | 21 ++- app/src/models/workout-worker-messages.ts | 1 + app/src/services/preference-service.spec.ts | 1 + app/src/services/workout-worker.ts | 1 + app/src/store/settings/index.ts | 1 + app/src/store/settings/preferences.spec.ts | 1 + app/src/store/settings/registry.ts | 1 + .../workout-worker/AppConfiguration.json | 9 +- 15 files changed, 280 insertions(+), 84 deletions(-) create mode 100644 app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/RestTonePcm.kt diff --git a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/handlers/WorkoutUpdatedHandler.kt b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/handlers/WorkoutUpdatedHandler.kt index 96ed86515..d6e392ded 100644 --- a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/handlers/WorkoutUpdatedHandler.kt +++ b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/handlers/WorkoutUpdatedHandler.kt @@ -2,12 +2,12 @@ package expo.modules.workoutworker.handlers import android.annotation.SuppressLint -import android.app.Notification import android.util.Log import com.limajuice.liftlog.DistanceCardioTarget import com.limajuice.liftlog.RecordedCardioExercise import com.limajuice.liftlog.RecordedCardioExerciseSet import com.limajuice.liftlog.RecordedWeightedExercise +import com.limajuice.liftlog.RestTimerInfo import com.limajuice.liftlog.TimeCardioTarget import com.limajuice.liftlog.Translations import com.limajuice.liftlog.Weight @@ -17,7 +17,10 @@ import com.limajuice.liftlog.WorkoutUpdatedEvent import expo.modules.workoutworker.utils.RepeatingTimerAction import expo.modules.workoutworker.utils.RestWindow import expo.modules.workoutworker.utils.WorkoutNotificationManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.time.Clock @@ -36,8 +39,12 @@ class WorkoutUpdatedHandler( val timer = RepeatingTimerAction(MainScope(), {}) - // Every workout update restarts the timer callback, so "have we announced this target yet" has to - // outlive it - otherwise a set left running past its target re-announces on every update. + private val restAlertScope = MainScope() + private var restAlertJob: Job? = null + private var scheduledRestAlert: Triple? = null + + // Every workout update restarts the timer callback, so "have we announced this target yet" has + // to outlive it - otherwise a set left running past its target re-announces on every update. private var announcedCardioTarget: Pair? = null override suspend fun handle( @@ -46,9 +53,16 @@ class WorkoutUpdatedHandler( ) { try { val workoutUpdatedEvent = event.payload as WorkoutUpdatedEvent + if (workoutUpdatedEvent.restTimerInfo == null) { + cancelRestAlerts() + } when { - workoutUpdatedEvent.restTimerInfo != null -> showRestTimerNotification(event.translations, workoutUpdatedEvent) + workoutUpdatedEvent.restTimerInfo != null -> showRestTimerNotification( + event.translations, + workoutUpdatedEvent, + event.appConfiguration.restCountdownTonesEnabled, + ) workoutUpdatedEvent.cardioTimerInfo != null -> showCardioTimerNotification(event.translations, workoutUpdatedEvent) workoutUpdatedEvent.currentExerciseDetails != null -> showCurrentExerciseNotification( event.translations, @@ -99,9 +113,12 @@ class WorkoutUpdatedHandler( private fun showRestTimerNotification( translations: Translations, workoutUpdatedEvent: WorkoutUpdatedEvent, + restCountdownTonesEnabled: Boolean, ) { val restTimerInfo = workoutUpdatedEvent.restTimerInfo ?:return + scheduleRestAlerts(translations, restTimerInfo, restCountdownTonesEnabled) + fun getProgress(): Long { val timeStartSecs = restTimerInfo.startedAt.epochSeconds val now = Clock.System.now().epochSeconds @@ -109,7 +126,6 @@ class WorkoutUpdatedHandler( } val currentExerciseMessage = getCurrentExerciseMessage(translations, workoutUpdatedEvent) - var previousProgress = getProgress() timer.updateCallback { val timeStartSecs = restTimerInfo.startedAt.epochSeconds val timePartiallyEndSecs = restTimerInfo.partiallyEndAt.epochSeconds @@ -124,29 +140,6 @@ class WorkoutUpdatedHandler( partialProgressMax else fullProgressMax - - val restNotif: Notification? = when { - partialProgressMax in (previousProgress + 1)..progress && partialProgressMax != 0L -> notificationManager.createRestNotificationBuilder() - .setContentTitle(translations.workoutPersistentNotificationMinRestOverMessage) - .build() - - fullProgressMax in (previousProgress + 1)..progress && fullProgressMax != 0L -> notificationManager.createRestNotificationBuilder() - .setContentTitle(translations.workoutPersistentNotificationMaxRestOverMessage) - .build() - - else -> null - } - if (restNotif != null) { - notificationManager.notifyRest(restNotif) - - MainScope().launch { - delay(10_000) - notificationManager.clearRestNotification() - } - } - @Suppress("AssignedValueIsNeverRead") - previousProgress = progress - val message = when { now < timePartiallyEndSecs -> translations.workoutPersistentNotificationRestBreakMessage now in timePartiallyEndSecs..timeEndSecs -> translations.workoutPersistentNotificationStartSoonMessage @@ -186,6 +179,70 @@ class WorkoutUpdatedHandler( timer.start() } + @OptIn(ExperimentalTime::class) + private fun scheduleRestAlerts( + translations: Translations, + restTimerInfo: RestTimerInfo, + countdownTonesEnabled: Boolean, + ) { + val schedule = Triple( + restTimerInfo.partiallyEndAt.toEpochMilliseconds(), + restTimerInfo.endAt.toEpochMilliseconds(), + countdownTonesEnabled, + ) + if (scheduledRestAlert == schedule) return + + cancelRestAlerts() + scheduledRestAlert = schedule + restAlertJob = restAlertScope.launch { + val boundaries = listOf( + schedule.first to translations.workoutPersistentNotificationMinRestOverMessage, + schedule.second to translations.workoutPersistentNotificationMaxRestOverMessage, + ).filter { (targetMs) -> targetMs > restTimerInfo.startedAt.toEpochMilliseconds() } + .distinctBy { (targetMs) -> targetMs } + + for ((targetMs, title) in boundaries) { + if (countdownTonesEnabled) { + for (remainingSecs in 3L downTo 1L) { + scheduleAtTime(targetMs - remainingSecs * 1_000) { + notificationManager.playRestCountdownTone(remainingSecs, targetMs) + } + } + } + scheduleAtTime(targetMs) { + notificationManager.notifyRest( + notificationManager.createRestNotificationBuilder() + .setContentTitle(title) + .build(), + targetMs, + ) + MainScope().launch { + delay(10_000) + notificationManager.clearRestNotification() + } + } + } + } + } + + @OptIn(ExperimentalTime::class) + private fun CoroutineScope.scheduleAtTime(targetEpochMs: Long, action: suspend () -> Unit) { + launch { + val delayMs = targetEpochMs - Clock.System.now().toEpochMilliseconds() + if (delayMs <= 0) { + return@launch + } + delay(delayMs) + action() + } + } + + private fun cancelRestAlerts() { + if (scheduledRestAlert != null) notificationManager.cancelRestToneSequence() + restAlertJob?.cancel() + restAlertJob = null + scheduledRestAlert = null + } @OptIn(ExperimentalTime::class) private fun showCardioTimerNotification( @@ -351,5 +408,8 @@ class WorkoutUpdatedHandler( override fun onDestroy() { timer.destroy() + cancelRestAlerts() + restAlertScope.cancel() + notificationManager.cancelRestToneSequence() } } diff --git a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/RestTonePcm.kt b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/RestTonePcm.kt new file mode 100644 index 000000000..26bf66e62 --- /dev/null +++ b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/RestTonePcm.kt @@ -0,0 +1,35 @@ +package expo.modules.workoutworker.utils + +import kotlin.math.PI +import kotlin.math.min +import kotlin.math.sin + +/** One audio buffer keeps playback running through the silent gaps, even without music. */ +internal object RestTonePcm { + const val SAMPLE_RATE = 48_000 + const val FINAL_DURATION_MS = 1_000 + const val COUNTDOWN_DURATION_FRACTION = 0.5 + const val TAIL_MS = 200 + + fun create(remainingSecs: Int): ShortArray { + require(remainingSecs in 0..3) + val totalMs = remainingSecs * 1_000 + FINAL_DURATION_MS + TAIL_MS + val samples = ShortArray(totalMs * SAMPLE_RATE / 1_000) + val fadeFrames = SAMPLE_RATE * 5 / 1_000 + for (second in 0..remainingSecs) { + val durationMs = if (second == remainingSecs) FINAL_DURATION_MS + else (FINAL_DURATION_MS * COUNTDOWN_DURATION_FRACTION).toInt() + val highFrequencyHz = if (second == remainingSecs) 1477 else 1209 + val frames = durationMs * SAMPLE_RATE / 1_000 + val offset = second * SAMPLE_RATE + for (frame in 0 until frames) { + // DTMF 4 for countdown, DTMF 6 for final; short ramps avoid edge clicks. + val time = frame.toDouble() / SAMPLE_RATE + val envelope = min(1.0, min(frame, frames - 1 - frame).toDouble() / fadeFrames) + val wave = sin(2 * PI * 770 * time) + sin(2 * PI * highFrequencyHz * time) + samples[offset + frame] = (wave * envelope * 0.35 * Short.MAX_VALUE).toInt().toShort() + } + } + return samples + } +} diff --git a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt index adc086c7e..5f00421f9 100644 --- a/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt +++ b/app/modules/workout-worker/android/src/main/java/expo/modules/workoutworker/utils/WorkoutNotificationManager.kt @@ -7,10 +7,13 @@ import android.media.AudioAttributes import android.media.AudioDeviceInfo import android.media.AudioFocusRequest import android.media.AudioManager -import android.media.ToneGenerator +import android.media.AudioFormat +import android.media.AudioRouting +import android.media.AudioTrack import android.os.Build import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.util.Log import androidx.annotation.DrawableRes import androidx.core.app.NotificationCompat @@ -42,11 +45,12 @@ class WorkoutNotificationManager(private val context: Context) { private val audioManager = context.getSystemService(AudioManager::class.java) private val restToneHandler = Handler(Looper.getMainLooper()) - private var restToneGenerator: ToneGenerator? = null + private val restTonePlaybacks = mutableMapOf() + private var toneSequence = 0L private var restToneAudioFocusRequest: AudioFocusRequest? = null private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> if (focusChange == AudioManager.AUDIOFOCUS_LOSS || focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) { - releaseRestToneGenerator(audioManager) + releaseAllRestTones() } } @@ -57,10 +61,6 @@ class WorkoutNotificationManager(private val context: Context) { const val PERSISTENT_CHANNEL_ID = "workout_channel" const val REST_CHANNEL_ID = "rest_channel" - private const val REST_TONE_DURATION_MS = 600 - private const val REST_TONE_VOLUME_PERCENT = 100 - private const val REST_TONE_TYPE = ToneGenerator.TONE_DTMF_5 - private val HEADPHONE_AUDIO_DEVICE_TYPES = setOf( AudioDeviceInfo.TYPE_BLE_HEADSET, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, @@ -150,45 +150,89 @@ class WorkoutNotificationManager(private val context: Context) { manager.notify(PERSISTENT_NOTIFICATION_ID, notification) } - fun notifyRest(notification: Notification) { + fun notifyRest(notification: Notification, targetEpochMs: Long? = null) { val manager = context.getSystemService(NotificationManager::class.java) manager.notify(REST_NOTIFICATION_ID, notification) - playRestToneThroughHeadphonesWhenNotificationsAreMuted() - } - - /** - * Android mutes notification audio in vibrate and silent modes, even when media is playing through - * headphones. In that specific situation, play a generated tone as media so the rest alert reaches - * the headphones without making a vibrate-only phone audible in the room. - */ - private fun playRestToneThroughHeadphonesWhenNotificationsAreMuted() { - if ( - audioManager.ringerMode == AudioManager.RINGER_MODE_NORMAL || - !audioManager.hasHeadphoneOutput() - ) { - return - } + playRestToneSequence(targetEpochMs, 0) + } - releaseRestToneGenerator(audioManager) - if (!requestRestToneAudioFocus(audioManager)) return + fun playRestCountdownTone(remainingSecs: Long, targetEpochMs: Long) { + playRestToneSequence(targetEpochMs, remainingSecs.toInt()) + } + private fun playRestToneSequence(targetEpochMs: Long?, remainingSecs: Int) { + val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + if (audioManager.ringerMode == AudioManager.RINGER_MODE_NORMAL || + outputs.none { it.type in HEADPHONE_AUDIO_DEVICE_TYPES }) { + releaseAllRestTones() + return + } + // Later countdown ticks and the final notification must not replay buffered tones. + if (targetEpochMs != null && restTonePlaybacks.containsKey(targetEpochMs)) { + return + } + val key = targetEpochMs ?: -(++toneSequence) + var track: AudioTrack? = null try { - val generator = ToneGenerator(AudioManager.STREAM_MUSIC, REST_TONE_VOLUME_PERCENT) - restToneGenerator = generator - if (!generator.startTone(REST_TONE_TYPE, REST_TONE_DURATION_MS)) { - releaseRestToneGenerator(audioManager) - Log.e("WorkoutNotificationManager", "Failed to start generated rest tone") + if (restTonePlaybacks.isEmpty() && !requestRestToneAudioFocus(audioManager)) { return } - - restToneHandler.postDelayed({ - if (restToneGenerator === generator) { - releaseRestToneGenerator(audioManager) + val samples = RestTonePcm.create(remainingSecs) + val audioTrack = AudioTrack.Builder() + .setAudioAttributes(REST_TONE_AUDIO_ATTRIBUTES) + .setAudioFormat(AudioFormat.Builder() + .setSampleRate(RestTonePcm.SAMPLE_RATE) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build()) + .setTransferMode(AudioTrack.MODE_STATIC) + .setBufferSizeInBytes(samples.size * 2) + .build() + track = audioTrack + // MODE_STATIC remains STATE_NO_STATIC_DATA until its first successful write. + val initialState = audioTrack.state + check(initialState != AudioTrack.STATE_UNINITIALIZED) { "AudioTrack initialization failed: state=$initialState" } + val written = audioTrack.write(samples, 0, samples.size) + check(written == samples.size) { "Incomplete PCM write: $written/${samples.size}" } + check(audioTrack.state == AudioTrack.STATE_INITIALIZED) { "AudioTrack not ready after write: state=${audioTrack.state}" } + val playback = audioTrack + restTonePlaybacks[key] = playback + audioTrack.setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener { + override fun onMarkerReached(track: AudioTrack) { + if (restTonePlaybacks[key] === playback) releaseRestTone(key) + } + override fun onPeriodicNotification(track: AudioTrack) { + if (restTonePlaybacks[key] !== playback) return + if (audioManager.ringerMode == AudioManager.RINGER_MODE_NORMAL) { + releaseAllRestTones() + } + } + }, restToneHandler) + audioTrack.addOnRoutingChangedListener(AudioRouting.OnRoutingChangedListener { routing -> + if (restTonePlaybacks[key] === playback) { + val deviceType = routing.routedDevice?.type + if (deviceType != null && deviceType !in HEADPHONE_AUDIO_DEVICE_TYPES) { + releaseRestTone(key) + } } - }, REST_TONE_DURATION_MS.toLong()) + }, restToneHandler) + check(audioTrack.setNotificationMarkerPosition(samples.size - 1) == AudioTrack.SUCCESS) + check(audioTrack.setPositionNotificationPeriod(RestTonePcm.SAMPLE_RATE) == AudioTrack.SUCCESS) + audioTrack.play() + val returnedAt = SystemClock.uptimeMillis() + val bufferMs = samples.size * 1_000L / RestTonePcm.SAMPLE_RATE + // Marker completion follows playback progress, not wall time. This only bounds leaks + // if a device fails to deliver its completion callback. + restToneHandler.postAtTime({ + if (restTonePlaybacks[key] === playback) releaseRestTone(key) + }, playback, returnedAt + bufferMs + 2_000) } catch (e: Exception) { - releaseRestToneGenerator(audioManager) - Log.e("WorkoutNotificationManager", "Failed to play rest tone through headphones", e) + if (restTonePlaybacks.containsKey(key)) releaseRestTone(key) + else { + track?.release() + if (restTonePlaybacks.isEmpty()) abandonRestToneAudioFocus(audioManager) + } + Log.e("WorkoutNotificationManager", "Failed to play rest tone sequence", e) } } @@ -213,11 +257,23 @@ class WorkoutNotificationManager(private val context: Context) { return granted } - private fun releaseRestToneGenerator(audioManager: AudioManager) { - restToneGenerator?.stopTone() - restToneGenerator?.release() - restToneGenerator = null - abandonRestToneAudioFocus(audioManager) + fun cancelRestToneSequence() { + releaseAllRestTones() + } + + private fun releaseAllRestTones() { + restTonePlaybacks.keys.toList().forEach { releaseRestTone(it) } + } + + private fun releaseRestTone(key: Long) { + val playback = restTonePlaybacks.remove(key) ?: return + restToneHandler.removeCallbacksAndMessages(playback) + try { + playback.stop() + } finally { + playback.release() + if (restTonePlaybacks.isEmpty()) abandonRestToneAudioFocus(audioManager) + } } private fun abandonRestToneAudioFocus(audioManager: AudioManager) { @@ -230,10 +286,6 @@ class WorkoutNotificationManager(private val context: Context) { } } - private fun AudioManager.hasHeadphoneOutput(): Boolean = - getDevices(AudioManager.GET_DEVICES_OUTPUTS).any { device -> - device.type in HEADPHONE_AUDIO_DEVICE_TYPES - } fun clearPersistentNotification() { val manager = context.getSystemService(NotificationManager::class.java) diff --git a/app/src/app/(tabs)/settings/notifications.tsx b/app/src/app/(tabs)/settings/notifications.tsx index 1104b6866..d862c7fc4 100644 --- a/app/src/app/(tabs)/settings/notifications.tsx +++ b/app/src/app/(tabs)/settings/notifications.tsx @@ -2,12 +2,14 @@ import { RootState, useAppSelector } from '@/store'; import { broadcastWorkoutEvent } from '@/store/workout-worker'; import { workoutUpdatedEvent } from '@/store/workout-worker/helpers'; import { selectActiveSession } from '@/store/stored-sessions'; -import { setRestNotifications, setRestTimersEnabled } from '@/store/settings'; +import { setRestCountdownTones, setRestNotifications, setRestTimersEnabled } from '@/store/settings'; import { useTranslate } from '@tolgee/react'; import { useDispatch } from 'react-redux'; import { SettingsPage } from '@/components/layout/settings-page'; import { SegmentedListSwitch } from '@/components/presentation/foundation/segmented-list-switch'; import { SegmentedGroup } from '@/components/presentation/foundation/segmented-list'; +import { spacing } from '@/hooks/useAppTheme'; +import { Platform } from 'react-native'; export default function NotificationsPage() { const { t } = useTranslate(); @@ -34,6 +36,26 @@ export default function NotificationsPage() { } }} /> + {Platform.OS === 'android' ? ( + { + dispatch(setRestCountdownTones(value)); + if (currentWorkout) { + dispatch(broadcastWorkoutEvent(workoutUpdatedEvent(currentWorkout, settings.restTimersEnabled))); + } + }} + /> + ) : undefined} void; testID?: string; disabled?: boolean; + style?: StyleProp; } export function SegmentedListSwitch(props: ListSwitchProps) { @@ -19,7 +21,8 @@ export function SegmentedListSwitch(props: ListSwitchProps) { label={props.label} supportingText={props.supportingText} icon={props.icon} - onPress={() => props.onValueChange(!props.value)} + onPress={props.disabled ? undefined : () => props.onValueChange(!props.value)} + style={props.style} right={ {time}", diff --git a/app/src/models/workout-worker-messages.spec.ts b/app/src/models/workout-worker-messages.spec.ts index 32ba2b968..d47aaf4d4 100644 --- a/app/src/models/workout-worker-messages.spec.ts +++ b/app/src/models/workout-worker-messages.spec.ts @@ -66,6 +66,7 @@ const TRANSLATIONS: Translations = { const APP_CONFIG: AppConfiguration = { notificationsEnabled: true, + restCountdownTonesEnabled: false, }; function makeWeightedExercise(): RecordedWeightedExercise { @@ -307,7 +308,7 @@ describe('WorkoutMessage JSON schema validation', () => { it('validates a WorkoutMessage with notifications disabled', () => { const message: WorkoutMessage = { translations: TRANSLATIONS, - appConfiguration: { notificationsEnabled: false }, + appConfiguration: { notificationsEnabled: false, restCountdownTonesEnabled: false }, payload: { type: 'WorkoutStartedEvent' }, }; expect(validate('WorkoutMessage', message)).toBe(true); @@ -352,11 +353,17 @@ describe('WorkoutMessage JSON schema validation', () => { describe('AppConfiguration', () => { it('validates AppConfiguration with notifications enabled', () => { - expect(validate('AppConfiguration', { notificationsEnabled: true })).toBe(true); + expect(validate('AppConfiguration', { notificationsEnabled: true, restCountdownTonesEnabled: false })).toBe(true); }); it('validates AppConfiguration with notifications disabled', () => { - expect(validate('AppConfiguration', { notificationsEnabled: false })).toBe(true); + expect(validate('AppConfiguration', { notificationsEnabled: false, restCountdownTonesEnabled: false })).toBe( + true, + ); + }); + + it('validates AppConfiguration with countdown tones enabled', () => { + expect(validate('AppConfiguration', { notificationsEnabled: true, restCountdownTonesEnabled: true })).toBe(true); }); }); // ------------------------------------------------------------------------- @@ -468,7 +475,13 @@ describe('WorkoutMessage JSON schema validation', () => { }); it('rejects an AppConfiguration where notificationsEnabled is not a boolean', () => { - expect(validate('AppConfiguration', { notificationsEnabled: 'yes' })).toBe(false); + expect(validate('AppConfiguration', { notificationsEnabled: 'yes', restCountdownTonesEnabled: false })).toBe( + false, + ); + }); + + it('rejects an AppConfiguration missing restCountdownTonesEnabled', () => { + expect(validate('AppConfiguration', { notificationsEnabled: true })).toBe(false); }); }); diff --git a/app/src/models/workout-worker-messages.ts b/app/src/models/workout-worker-messages.ts index 798dea437..d3074224f 100644 --- a/app/src/models/workout-worker-messages.ts +++ b/app/src/models/workout-worker-messages.ts @@ -93,4 +93,5 @@ export interface Translations { export interface AppConfiguration { notificationsEnabled: boolean; + restCountdownTonesEnabled: boolean; } diff --git a/app/src/services/preference-service.spec.ts b/app/src/services/preference-service.spec.ts index 07251b2b5..ae41e51a7 100644 --- a/app/src/services/preference-service.spec.ts +++ b/app/src/services/preference-service.spec.ts @@ -51,6 +51,7 @@ const booleanPrefs: BoolPref[] = [ { key: 'showBodyweight', default: true }, { key: 'showFeed', default: true }, { key: 'restNotifications', default: true }, + { key: 'restCountdownTones', default: false }, { key: 'restTimersEnabled', default: true }, { key: 'crashReportsEnabled', default: true }, { key: 'welcomeWizardCompleted', default: false }, diff --git a/app/src/services/workout-worker.ts b/app/src/services/workout-worker.ts index 073c8de97..5a90e418c 100644 --- a/app/src/services/workout-worker.ts +++ b/app/src/services/workout-worker.ts @@ -35,6 +35,7 @@ export class WorkoutWorker { private getAppConfigurationMessage(): AppConfiguration { return { notificationsEnabled: this.getState().settings.restNotifications, + restCountdownTonesEnabled: this.getState().settings.restCountdownTones, }; } diff --git a/app/src/store/settings/index.ts b/app/src/store/settings/index.ts index d27c94e70..870495fa2 100644 --- a/app/src/store/settings/index.ts +++ b/app/src/store/settings/index.ts @@ -88,6 +88,7 @@ export const { setLastSeenWhatsNewId, setShowFeed, setRestNotifications, + setRestCountdownTones, setRestTimersEnabled, setCrashReportsEnabled, setWelcomeWizardCompleted, diff --git a/app/src/store/settings/preferences.spec.ts b/app/src/store/settings/preferences.spec.ts index a8bfa9861..afa570b69 100644 --- a/app/src/store/settings/preferences.spec.ts +++ b/app/src/store/settings/preferences.spec.ts @@ -22,6 +22,7 @@ describe('settings slice - generated preference actions', () => { const state = settingsReducer(undefined, { type: '@@init' }); expect(state.notesExpandedByDefault).toBe(true); expect(state.keepScreenAwakeDuringWorkout).toBe(true); + expect(state.restCountdownTones).toBe(false); expect(state.isHydrated).toBe(false); }); }); diff --git a/app/src/store/settings/registry.ts b/app/src/store/settings/registry.ts index 7a969866d..6cef8e7f3 100644 --- a/app/src/store/settings/registry.ts +++ b/app/src/store/settings/registry.ts @@ -61,6 +61,7 @@ export const preferenceRegistry = { showBodyweight: pref({ default: true, codec: boolCodec }), showFeed: pref({ default: true, codec: boolCodec }), restNotifications: pref({ default: true, codec: boolCodec }), + restCountdownTones: pref({ default: false, codec: boolCodec }), restTimersEnabled: pref({ default: true, codec: boolCodec }), crashReportsEnabled: pref({ default: true, codec: boolCodec }), welcomeWizardCompleted: pref({ default: false, codec: boolCodec }), diff --git a/docs/schemas/workout-worker/AppConfiguration.json b/docs/schemas/workout-worker/AppConfiguration.json index c6f83becd..0e9e34aa5 100644 --- a/docs/schemas/workout-worker/AppConfiguration.json +++ b/docs/schemas/workout-worker/AppConfiguration.json @@ -4,9 +4,10 @@ "properties": { "notificationsEnabled": { "type": "boolean" + }, + "restCountdownTonesEnabled": { + "type": "boolean" } }, - "required": [ - "notificationsEnabled" - ] -} \ No newline at end of file + "required": ["notificationsEnabled", "restCountdownTonesEnabled"] +}