From a2bd9c15595ab9612fb80958cec69577ccc01549 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Fri, 16 Oct 2026 17:35:06 -0500 Subject: [PATCH] Let low, high and predicted low alarms play through silent mode Low, high and predicted low go out as time-sensitive notifications, whose sound the ring/silent switch mutes, so on a silenced phone only urgent low was audible (LoopKit/Loop#2481). iOS offers no way to read the switch, so detecting it isn't an option. Each of these alarms now has a Play Through Silent Mode toggle next to its sound. When it's on and the alert isn't muted, Loop plays the sound once in-process through the .playback audio session at the current volume, with vibration, and leaves the notification silent so the sound isn't heard twice. It is on by default for low and predicted low and off for high. Urgent low is unchanged. --- Loop/Managers/Alerts/AlertManager.swift | 21 +++++++++++- .../Alerts/CriticalAlertAudioPlayer.swift | 16 +++++++-- .../Managers/Alerts/GlucoseAlertManager.swift | 33 +++++++++++++++++++ Loop/Managers/DeviceDataManager.swift | 4 +++ Loop/Views/GlucoseAlertSettingsView.swift | 19 ++++++++--- 5 files changed, 85 insertions(+), 8 deletions(-) diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index b31985d9a1..216f3451dc 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -53,6 +53,9 @@ public final class AlertManager { private let criticalAlertAlarmScheduler = CriticalAlertAlarmScheduler() var alertMuter: AlertMuter + /// Non-critical alerts that should play their sound in-app, through the silent switch and Focus. + var playsThroughSilent: (Alert) -> Bool = { _ in false } + let alertStore: AlertStore private let bluetoothPoweredOffIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: "bluetoothPoweredOff") @@ -432,10 +435,26 @@ extension AlertManager: AlertIssuer { private func scheduleAlertWithSchedulers(_ alert: Alert, issuedDate: Date = Date()) { modalAlertScheduler.scheduleAlert(alert) let muted = alertMuter.shouldMuteAlert(alert, issuedDate: issuedDate) - userNotificationAlertScheduler.scheduleAlert(alert, muted: muted) + let inAppSound = muted ? nil : inAppSoundThroughSilent(for: alert) + // The notification stays silent when the app plays the sound, so it isn't heard twice + userNotificationAlertScheduler.scheduleAlert(alert, muted: muted || inAppSound != nil) + if let inAppSound { + log.default("Playing %{public}@ through silent mode for %{public}@", inAppSound, String(describing: alert.identifier)) + criticalAlertAudioPlayer.play(soundNamed: inAppSound, boostVolume: false) + } playCriticalAlertAudioFallbackIfNeeded(for: alert, muted: muted, issuedDate: issuedDate) } + private func inAppSoundThroughSilent(for alert: Alert) -> String? { + guard alert.interruptionLevel != .critical, case .immediate = alert.trigger, playsThroughSilent(alert) else { + return nil + } + guard case .sound(let name) = alert.sound else { + return nil + } + return name + } + private func unscheduleAlertWithSchedulers(identifier: Alert.Identifier) async { await modalAlertScheduler.unscheduleAlert(identifier: identifier) userNotificationAlertScheduler.unscheduleAlert(identifier: identifier) diff --git a/Loop/Managers/Alerts/CriticalAlertAudioPlayer.swift b/Loop/Managers/Alerts/CriticalAlertAudioPlayer.swift index 9adbe91dc7..8c673d0622 100644 --- a/Loop/Managers/Alerts/CriticalAlertAudioPlayer.swift +++ b/Loop/Managers/Alerts/CriticalAlertAudioPlayer.swift @@ -68,7 +68,9 @@ final class CriticalAlertAudioPlayer { /// always present. We resolve it directly from the bundle rather than /// AlertManager's AlertSoundVendor-keyed `Library/Sounds/` lookup. /// Falls back to `critical.caf` if the named file is missing. - func play(soundNamed soundName: String = "critical.caf") { + /// With `boostVolume` false the sound plays once at the current volume, for + /// non-critical alerts that only need to get past the silent switch. + func play(soundNamed soundName: String = "critical.caf", boostVolume: Bool = true) { stop() // Vibration is volume-independent, so it's our primary "you missed @@ -112,10 +114,12 @@ final class CriticalAlertAudioPlayer { // Without the Critical Alerts entitlement the system won't raise // an inaudible ringer for us, so force it up for the life of the // alarm (restored on stop). Best-effort — see SystemVolumeBooster. - volumeBooster.boost(to: boostedVolume) + if boostVolume { + volumeBooster.boost(to: boostedVolume) + } let p = try AVAudioPlayer(contentsOf: url) - p.numberOfLoops = -1 + p.numberOfLoops = boostVolume ? -1 : 0 p.volume = 1.0 p.prepareToPlay() guard p.play() else { @@ -124,6 +128,12 @@ final class CriticalAlertAudioPlayer { return } player = p + if !boostVolume { + stopTimer?.invalidate() + stopTimer = Timer.scheduledTimer(withTimeInterval: p.duration, repeats: false) { [weak self] _ in + Task { @MainActor in self?.stop() } + } + } os_log("Started critical-alert audio playback (duration=%{public}.2fs playing=%{public}@)", log: log, type: .info, p.duration, p.isPlaying ? "yes" : "no") } catch { diff --git a/Loop/Managers/Alerts/GlucoseAlertManager.swift b/Loop/Managers/Alerts/GlucoseAlertManager.swift index e9f18cf6cc..ea675695e8 100644 --- a/Loop/Managers/Alerts/GlucoseAlertManager.swift +++ b/Loop/Managers/Alerts/GlucoseAlertManager.swift @@ -227,6 +227,9 @@ final class GlucoseAlertManager: ObservableObject { private static let lowSoundKey = "GlucoseAlertLowSound" private static let highSoundKey = "GlucoseAlertHighSound" private static let predictedLowSoundKey = "GlucoseAlertPredictedLowSound" + private static let lowPlaysThroughSilentKey = "GlucoseAlertLowPlaysThroughSilent" + private static let highPlaysThroughSilentKey = "GlucoseAlertHighPlaysThroughSilent" + private static let predictedLowPlaysThroughSilentKey = "GlucoseAlertPredictedLowPlaysThroughSilent" private static let episodeStateKey = "GlucoseAlertEpisodeState" // Per-alarm sound defaults. Urgent low keeps the loud critical tone; @@ -235,6 +238,9 @@ final class GlucoseAlertManager: ObservableObject { static let defaultLowSound = "simple.caf" static let defaultHighSound = "chime.caf" static let defaultPredictedLowSound = "mood_synth.caf" + static let defaultLowPlaysThroughSilent = true + static let defaultHighPlaysThroughSilent = false + static let defaultPredictedLowPlaysThroughSilent = true private static let overrideKey = "GlucoseAlertLoopOverride" private static let legacyConfigKey = "GlucoseAlertConfiguration" private static let legacyPrimaryKey = "GlucoseAlertPrimaryConfig" @@ -283,6 +289,27 @@ final class GlucoseAlertManager: ObservableObject { didSet { userDefaults.set(predictedLowSound, forKey: Self.predictedLowSoundKey) } } + /// Whether a non-urgent alarm plays its sound in-app, through the silent switch and Focus. + /// Urgent low always does, as a critical alert. + @Published var lowPlaysThroughSilent: Bool { + didSet { userDefaults.set(lowPlaysThroughSilent, forKey: Self.lowPlaysThroughSilentKey) } + } + @Published var highPlaysThroughSilent: Bool { + didSet { userDefaults.set(highPlaysThroughSilent, forKey: Self.highPlaysThroughSilentKey) } + } + @Published var predictedLowPlaysThroughSilent: Bool { + didSet { userDefaults.set(predictedLowPlaysThroughSilent, forKey: Self.predictedLowPlaysThroughSilentKey) } + } + + func playsThroughSilent(for identifier: Alert.AlertIdentifier) -> Bool { + switch identifier { + case Self.lowAlertIdentifier: return lowPlaysThroughSilent + case Self.highAlertIdentifier: return highPlaysThroughSilent + case Self.predictedLowAlertIdentifier: return predictedLowPlaysThroughSilent + default: return false + } + } + /// The configured sound filename for a given glucose alarm identifier. func configuredSoundName(for identifier: Alert.AlertIdentifier) -> String { switch identifier { @@ -347,6 +374,9 @@ final class GlucoseAlertManager: ObservableObject { lowSound = Self.defaultLowSound highSound = Self.defaultHighSound predictedLowSound = Self.defaultPredictedLowSound + lowPlaysThroughSilent = Self.defaultLowPlaysThroughSilent + highPlaysThroughSilent = Self.defaultHighPlaysThroughSilent + predictedLowPlaysThroughSilent = Self.defaultPredictedLowPlaysThroughSilent loopAlertsOverrideForOwnAlertingCGM = false } @@ -523,6 +553,9 @@ final class GlucoseAlertManager: ObservableObject { self.lowSound = userDefaults.string(forKey: Self.lowSoundKey) ?? Self.defaultLowSound self.highSound = userDefaults.string(forKey: Self.highSoundKey) ?? Self.defaultHighSound self.predictedLowSound = userDefaults.string(forKey: Self.predictedLowSoundKey) ?? Self.defaultPredictedLowSound + self.lowPlaysThroughSilent = userDefaults.object(forKey: Self.lowPlaysThroughSilentKey) as? Bool ?? Self.defaultLowPlaysThroughSilent + self.highPlaysThroughSilent = userDefaults.object(forKey: Self.highPlaysThroughSilentKey) as? Bool ?? Self.defaultHighPlaysThroughSilent + self.predictedLowPlaysThroughSilent = userDefaults.object(forKey: Self.predictedLowPlaysThroughSilentKey) as? Bool ?? Self.defaultPredictedLowPlaysThroughSilent if didMigrate { // Write new format and erase all legacy + WIP keys atomically so diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 9a4a5cae1d..3c9d61cb38 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -271,6 +271,10 @@ final class DeviceDataManager { self.glucoseAlertManager = GlucoseAlertManager(alertIssuer: alertManager) alertManager.addAlertSoundVendor(managerIdentifier: GlucoseAlertManager.managerIdentifier, soundVendor: self.glucoseAlertManager) + alertManager.playsThroughSilent = { [weak glucoseAlertManager = self.glucoseAlertManager] alert in + alert.identifier.managerIdentifier == GlucoseAlertManager.managerIdentifier && + glucoseAlertManager?.playsThroughSilent(for: alert.identifier.alertIdentifier) == true + } self.settingsManager = settingsManager self.healthStore = healthStore self.carbStore = carbStore diff --git a/Loop/Views/GlucoseAlertSettingsView.swift b/Loop/Views/GlucoseAlertSettingsView.swift index 3e210fb082..bb04c49e97 100644 --- a/Loop/Views/GlucoseAlertSettingsView.swift +++ b/Loop/Views/GlucoseAlertSettingsView.swift @@ -404,6 +404,7 @@ struct GlucoseAlertDetailView: View { var delay: DelayBinding? = nil var showsTestButton: Bool = false var soundBinding: Binding? = nil + var playsThroughSilent: Binding? = nil struct SnoozeBinding { let enabled: Binding @@ -467,8 +468,15 @@ struct GlucoseAlertDetailView: View { .foregroundStyle(.secondary) } } + if let playsThroughSilent { + Toggle("Play Through Silent Mode", isOn: playsThroughSilent) + } } footer: { - Text("The sound played when this alarm fires.") + if playsThroughSilent != nil { + Text("The sound played when this alarm fires. Playing through silent mode makes Loop play it even when the phone is silenced or in a Focus, at the current volume.") + } else { + Text("The sound played when this alarm fires.") + } } } } @@ -504,7 +512,8 @@ extension GlucoseAlertDetailView { enabled: detail.cfgBind(\.lowEnabled), snooze: .init(enabled: detail.cfgBind(\.lowSnoozeEnabled), interval: detail.cfgBind(\.lowRepeatInterval)), horizon: nil, infoNotice: nil, - soundBinding: Binding(get: { manager.lowSound }, set: { manager.lowSound = $0 }) + soundBinding: Binding(get: { manager.lowSound }, set: { manager.lowSound = $0 }), + playsThroughSilent: Binding(get: { manager.lowPlaysThroughSilent }, set: { manager.lowPlaysThroughSilent = $0 }) ) } @@ -530,7 +539,8 @@ extension GlucoseAlertDetailView { snooze: .init(enabled: detail.cfgBind(\.highSnoozeEnabled), interval: detail.cfgBind(\.highRepeatInterval)), horizon: nil, infoNotice: nil, delay: .init(enabled: detail.cfgBind(\.highDelayEnabled), interval: detail.cfgBind(\.highDelay)), - soundBinding: Binding(get: { manager.highSound }, set: { manager.highSound = $0 }) + soundBinding: Binding(get: { manager.highSound }, set: { manager.highSound = $0 }), + playsThroughSilent: Binding(get: { manager.highPlaysThroughSilent }, set: { manager.highPlaysThroughSilent = $0 }) ) } @@ -541,7 +551,8 @@ extension GlucoseAlertDetailView { levelLabel: "Level", level: detail.cfgBind(\.predictedLowThresholdMgDL), levelRange: 55...80, enabled: detail.cfgBind(\.predictedLowEnabled), snooze: nil, horizon: .init(value: detail.cfgBind(\.predictedLowHorizon)), infoNotice: nil, - soundBinding: Binding(get: { manager.predictedLowSound }, set: { manager.predictedLowSound = $0 }) + soundBinding: Binding(get: { manager.predictedLowSound }, set: { manager.predictedLowSound = $0 }), + playsThroughSilent: Binding(get: { manager.predictedLowPlaysThroughSilent }, set: { manager.predictedLowPlaysThroughSilent = $0 }) ) } }