diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index b31985d9a..216f3451d 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 9adbe91dc..8c673d062 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 e9f18cf6c..ea675695e 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 9a4a5cae1..3c9d61cb3 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 3e210fb08..bb04c49e9 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 }) ) } }