Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion Loop/Managers/Alerts/AlertManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 13 additions & 3 deletions Loop/Managers/Alerts/CriticalAlertAudioPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions Loop/Managers/Alerts/GlucoseAlertManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Loop/Managers/DeviceDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions Loop/Views/GlucoseAlertSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ struct GlucoseAlertDetailView: View {
var delay: DelayBinding? = nil
var showsTestButton: Bool = false
var soundBinding: Binding<String>? = nil
var playsThroughSilent: Binding<Bool>? = nil

struct SnoozeBinding {
let enabled: Binding<Bool>
Expand Down Expand Up @@ -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.")
}
}
}
}
Expand Down Expand Up @@ -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 })
)
}

Expand All @@ -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 })
)
}

Expand All @@ -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 })
)
}
}
Expand Down