diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index b162633e0..528a480dc 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -247,6 +247,7 @@ DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A592DDC988F006F1C10 /* CarbSample.swift */; }; DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */; }; + DDCF9AA02E99000000000001 /* CustomSoundStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9AA02E99000000000002 /* CustomSoundStore.swift */; }; DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A812D85FD14004DF4DD /* AlarmType.swift */; }; DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A872D85FD33004DF4DD /* AlarmData.swift */; }; DDCF9A8C2D86005E004DF4DD /* AlarmManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A8B2D86005E004DF4DD /* AlarmManager.swift */; }; @@ -716,6 +717,7 @@ DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Testing.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/Testing.framework; sourceTree = DEVELOPER_DIR; }; DDCC3AD62DDE1790006F1C10 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alarm.swift; sourceTree = ""; }; + DDCF9AA02E99000000000002 /* CustomSoundStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomSoundStore.swift; sourceTree = ""; }; DDCF9A812D85FD14004DF4DD /* AlarmType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmType.swift; sourceTree = ""; }; DDCF9A872D85FD33004DF4DD /* AlarmData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmData.swift; sourceTree = ""; }; DDCF9A8B2D86005E004DF4DD /* AlarmManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmManager.swift; sourceTree = ""; }; @@ -1374,6 +1376,7 @@ DDCF9A872D85FD33004DF4DD /* AlarmData.swift */, DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */, DDBD19952DFB44B0005C2D69 /* Alarm+byPriorityThenSpec.swift */, + DDCF9AA02E99000000000002 /* CustomSoundStore.swift */, ); path = Alarm; sourceTree = ""; @@ -2422,6 +2425,7 @@ DD0650F52DCF303F004D3B41 /* AlarmStepperSection.swift in Sources */, 379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */, DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */, + DDCF9AA02E99000000000001 /* CustomSoundStore.swift in Sources */, DD7F4C132DD51FD500D449E9 /* TempTargetEndCondition.swift in Sources */, DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */, DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */, diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index 2de36fb0f..f483bbb6d 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -262,7 +262,7 @@ struct Alarm: Identifiable, Codable, Equatable { AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze") if playSound { - AlarmSound.setSoundFile(str: soundFile.rawValue) + AlarmSound.setSoundFile(soundFile) // Only use delay if repeating is enabled, otherwise delay doesn't make sense let delay = shouldRepeat ? soundDelay : 0 AlarmSound.play(repeating: shouldRepeat, delay: delay) diff --git a/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift b/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift index 5d5beb1bd..e78de27a2 100644 --- a/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift +++ b/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift @@ -2,6 +2,7 @@ // AlarmAudioSection.swift import SwiftUI +import UniformTypeIdentifiers struct AlarmAudioSection: View { @Binding var alarm: Alarm @@ -111,27 +112,37 @@ private struct TonePickerSheet: View { @Binding var selected: SoundFile @Environment(\.dismiss) private var dismiss + @State private var customSounds: [CustomSound] = [] + @State private var showingImporter = false + @State private var importError: String? + var body: some View { NavigationView { ScrollViewReader { proxy in List { - ForEach(SoundFile.allCases) { tone in + Section { + ForEach(customSounds) { sound in + toneRow(tone: .custom(sound.id), label: sound.displayName) + .id(SoundFile.custom(sound.id)) + } + .onDelete(perform: deleteCustomSounds) + Button { - selected = tone - AlarmSound.setSoundFile(str: tone.rawValue) - AlarmSound.stop() - AlarmSound.playTest() + showingImporter = true } label: { - HStack { - Text(tone.displayName) - if tone == selected { - Spacer() - Image(systemName: "checkmark") - .foregroundColor(.accentColor) - } - } + Label("Import Sound…", systemImage: "plus.circle") + } + } header: { + Text("Custom") + } footer: { + Text("Custom sounds stay on this device and aren't included in settings export.") + } + + Section(header: Text("Built-in")) { + ForEach(SoundFile.allBuiltins) { tone in + toneRow(tone: tone, label: tone.displayName) + .id(tone) } - .id(tone) } } .navigationTitle("Choose Tone") @@ -144,9 +155,81 @@ private struct TonePickerSheet: View { } } .onAppear { + reloadCustomSounds() proxy.scrollTo(selected, anchor: .center) } + .fileImporter( + isPresented: $showingImporter, + allowedContentTypes: [.audio], + allowsMultipleSelection: false + ) { result in + handleImport(result) + } + .alert( + "Import Failed", + isPresented: Binding( + get: { importError != nil }, + set: { if !$0 { importError = nil } } + ), + actions: { Button("OK", role: .cancel) { importError = nil } }, + message: { Text(importError ?? "") } + ) + } + } + } + + @ViewBuilder + private func toneRow(tone: SoundFile, label: String) -> some View { + Button { + selected = tone + AlarmSound.setSoundFile(tone) + AlarmSound.stop() + AlarmSound.playTest() + } label: { + HStack { + Text(label) + if tone == selected { + Spacer() + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + } + } + + private func reloadCustomSounds() { + customSounds = CustomSoundStore.shared.list() + } + + private func deleteCustomSounds(at offsets: IndexSet) { + let deleted = offsets.map { customSounds[$0] } + for sound in deleted { + CustomSoundStore.shared.delete(sound.id) + } + // If the alarm was pointing at a sound we just deleted, fall back to a valid + // built-in so the picker and the alarm don't keep a dangling reference. + if deleted.contains(where: { SoundFile.custom($0.id) == selected }) { + selected = .fallback + } + reloadCustomSounds() + } + + private func handleImport(_ result: Result<[URL], Error>) { + switch result { + case let .success(urls): + guard let url = urls.first else { return } + do { + let imported = try CustomSoundStore.shared.importFile(at: url) + reloadCustomSounds() + selected = .custom(imported.id) + AlarmSound.setSoundFile(.custom(imported.id)) + AlarmSound.stop() + AlarmSound.playTest() + } catch { + importError = error.localizedDescription } + case let .failure(error): + importError = error.localizedDescription } } } diff --git a/LoopFollow/Alarm/AlarmEditing/Components/SoundFile.swift b/LoopFollow/Alarm/AlarmEditing/Components/SoundFile.swift index d67bbb711..4d0f8e968 100644 --- a/LoopFollow/Alarm/AlarmEditing/Components/SoundFile.swift +++ b/LoopFollow/Alarm/AlarmEditing/Components/SoundFile.swift @@ -3,122 +3,239 @@ import Foundation -enum SoundFile: String, CaseIterable, Identifiable, Codable { - case alarmBuzzer = "Alarm_Buzzer" - case alarmClock = "Alarm_Clock" - case alertToneBusy = "Alert_Tone_Busy" - case alertToneRingtone1 = "Alert_Tone_Ringtone_1" - case alertToneRingtone2 = "Alert_Tone_Ringtone_2" - case alienSiren = "Alien_Siren" - case ambulance = "Ambulance" - case analogWatchAlarm = "Analog_Watch_Alarm" - case bigClockTicking = "Big_Clock_Ticking" - case burglarAlarmSiren1 = "Burglar_Alarm_Siren_1" - case burglarAlarmSiren2 = "Burglar_Alarm_Siren_2" - case cartoonAscendClimbSneaky = "Cartoon_Ascend_Climb_Sneaky" - case cartoonAscendThenDescend = "Cartoon_Ascend_Then_Descend" - case cartoonBounceToCeiling = "Cartoon_Bounce_To_Ceiling" - case cartoonDreamyGlissandoHarp = "Cartoon_Dreamy_Glissando_Harp" - case cartoonFailStringsTrumpet = "Cartoon_Fail_Strings_Trumpet" - case cartoonMachineClumsyLoop = "Cartoon_Machine_Clumsy_Loop" - case cartoonSiren = "Cartoon_Siren" - case cartoonTipToeSneakyWalk = "Cartoon_Tip_Toe_Sneaky_Walk" - case cartoonUhOh = "Cartoon_Uh_Oh" - case cartoonVillainHorns = "Cartoon_Villain_Horns" - case cellPhoneRingTone = "Cell_Phone_Ring_Tone" - case chimesGlassy = "Chimes_Glassy" - case computerMagic = "Computer_Magic" - case csfx2Alarm = "CSFX-2_Alarm" - case cuckooClock = "Cuckoo_Clock" - case dholShuffleloop = "Dhol_Shuffleloop" - case discreet = "Discreet" - case earlySunrise = "Early_Sunrise" - case emergencyAlarmCarbonMonoxide = "Emergency_Alarm_Carbon_Monoxide" - case emergencyAlarmSiren = "Emergency_Alarm_Siren" - case emergencyAlarm = "Emergency_Alarm" - case endingReached = "Ending_Reached" - case fly = "Fly" - case ghostHover = "Ghost_Hover" - case goodMorning = "Good_Morning" - case hellYeahSomewhatCalmer = "Hell_Yeah_Somewhat_Calmer" - case inAHurry = "In_A_Hurry" - case indeed = "Indeed" - case insistently = "Insistently" - case jingleAllTheWay = "Jingle_All_The_Way" - case laserShoot = "Laser_Shoot" - case machineCharge = "Machine_Charge" - case magicalTwinkle = "Magical_Twinkle" - case marchingHeavyFootedFatElephants = "Marching_Heavy_Footed_Fat_Elephants" - case marimbaDescend = "Marimba_Descend" - case marimbaFlutterOrShake = "Marimba_Flutter_or_Shake" - case martianGun = "Martian_Gun" - case martianScanner = "Martian_Scanner" - case metallic = "Metallic" - case nightguard = "Nightguard" - case notKiddin = "Not_Kiddin" - case openYourEyesAndSee = "Open_Your_Eyes_And_See" - case orchestralHorns = "Orchestral_Horns" - case oringz = "Oringz" - case pagerBeeps = "Pager_Beeps" - case remembersMeOfAsia = "Remembers_Me_Of_Asia" - case riseAndShine = "Rise_And_Shine" - case rush = "Rush" - case sciFiAirRaidAlarm = "Sci-Fi_Air_Raid_Alarm" - case sciFiAlarmLoop1 = "Sci-Fi_Alarm_Loop_1" - case sciFiAlarmLoop2 = "Sci-Fi_Alarm_Loop_2" - case sciFiAlarmLoop3 = "Sci-Fi_Alarm_Loop_3" - case sciFiAlarmLoop4 = "Sci-Fi_Alarm_Loop_4" - case sciFiAlarm = "Sci-Fi_Alarm" - case sciFiComputerConsoleAlarm = "Sci-Fi_Computer_Console_Alarm" - case sciFiConsoleAlarm = "Sci-Fi_Console_Alarm" - case sciFiEerieAlarm = "Sci-Fi_Eerie_Alarm" - case sciFiEngineShutDown = "Sci-Fi_Engine_Shut_Down" - case sciFiIncomingMessageAlert = "Sci-Fi_Incoming_Message_Alert" - case sciFiSpaceshipMessage = "Sci-Fi_Spaceship_Message" - case sciFiSpaceshipWarmUp = "Sci-Fi_Spaceship_Warm_Up" - case sciFiWarning = "Sci-Fi_Warning" - case signatureCorporate = "Signature_Corporate" - case siriAlertCalibrationNeeded = "Siri_Alert_Calibration_Needed" - case siriAlertDeviceMuted = "Siri_Alert_Device_Muted" - case siriAlertGlucoseDroppingFast = "Siri_Alert_Glucose_Dropping_Fast" - case siriAlertGlucoseRisingFast = "Siri_Alert_Glucose_Rising_Fast" - case siriAlertHighGlucose = "Siri_Alert_High_Glucose" - case siriAlertLowGlucose = "Siri_Alert_Low_Glucose" - case siriAlertMissedReadings = "Siri_Alert_Missed_Readings" - case siriAlertTransmitterBatteryLow = "Siri_Alert_Transmitter_Battery_Low" - case siriAlertUrgentHighGlucose = "Siri_Alert_Urgent_High_Glucose" - case siriAlertUrgentLowGlucose = "Siri_Alert_Urgent_Low_Glucose" - case siriCalibrationNeeded = "Siri_Calibration_Needed" - case siriDeviceMuted = "Siri_Device_Muted" - case siriGlucoseDroppingFast = "Siri_Glucose_Dropping_Fast" - case siriGlucoseRisingFast = "Siri_Glucose_Rising_Fast" - case siriHighGlucose = "Siri_High_Glucose" - case siriLowGlucose = "Siri_Low_Glucose" - case siriMissedReadings = "Siri_Missed_Readings" - case siriTransmitterBatteryLow = "Siri_Transmitter_Battery_Low" - case siriUrgentHighGlucose = "Siri_Urgent_High_Glucose" - case siriUrgentLowGlucose = "Siri_Urgent_Low_Glucose" - case softMarimbaPadPositive = "Soft_Marimba_Pad_Positive" - case softWarmAiryOptimistic = "Soft_Warm_Airy_Optimistic" - case softWarmAiryReassuring = "Soft_Warm_Airy_Reassuring" - case storeDoorChime = "Store_Door_Chime" - case sunny = "Sunny" - case thunderSoundFX = "Thunder_Sound_FX" - case timeHasCome = "Time_Has_Come" - case tornadoSiren = "Tornado_Siren" - case twoTurtleDoves = "Two_Turtle_Doves" - case unpaved = "Unpaved" - case wakeUpWillYou = "Wake_Up_Will_You" - case winGain = "Win_Gain" - case wrongAnswer = "Wrong_Answer" +/// A sound chosen for an alarm. +/// +/// `.builtin` wraps the filename (no extension) of a `.caf` resource bundled with the app. +/// `.custom` references an audio file imported by the user into `Documents/CustomSounds/`, +/// keyed by UUID (see `CustomSoundStore`). +/// +/// Codable note: built-in values encode/decode as a bare string (e.g. `"Indeed"`) so stored +/// alarm data written by older app versions continues to load. Custom values encode as a +/// keyed object and are new with this version. +enum SoundFile: Hashable, Identifiable { + case builtin(String) + case custom(UUID) - // Identifiable conformance - var id: SoundFile { self } + var id: String { + switch self { + case let .builtin(name): return "builtin:\(name)" + case let .custom(uuid): return "custom:\(uuid.uuidString)" + } + } - /// Human-friendly name (spaces instead of underscores) + /// Human-friendly name for display in pickers. var displayName: String { - rawValue - .replacingOccurrences(of: "_", with: " ") - .replacingOccurrences(of: " ", with: " ") + switch self { + case let .builtin(name): + return name + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: " ", with: " ") + case let .custom(uuid): + return CustomSoundStore.shared.displayName(for: uuid) ?? "Custom Sound" + } + } + + /// Convenience for call sites that still think in terms of a bundle filename. + var builtinName: String? { + if case let .builtin(name) = self { return name } + return nil + } + + /// Back-compat shim for migration code that reads legacy string values out of UserDefaults. + /// The string is treated as a built-in filename; playback will fall back gracefully if the + /// file isn't actually in the bundle. + init?(rawValue: String) { + guard !rawValue.isEmpty else { return nil } + self = .builtin(rawValue) } } + +extension SoundFile: Codable { + private enum CodingKeys: String, CodingKey { + case kind, name, id + } + + init(from decoder: Decoder) throws { + // Legacy form: bare string == built-in filename. + if let container = try? decoder.singleValueContainer(), + let name = try? container.decode(String.self) + { + self = .builtin(name) + return + } + let c = try decoder.container(keyedBy: CodingKeys.self) + let kind = try c.decode(String.self, forKey: .kind) + switch kind { + case "builtin": + let name = try c.decode(String.self, forKey: .name) + self = .builtin(name) + case "custom": + let uuid = try c.decode(UUID.self, forKey: .id) + self = .custom(uuid) + default: + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: c, + debugDescription: "Unknown SoundFile kind: \(kind)" + ) + } + } + + func encode(to encoder: Encoder) throws { + switch self { + case let .builtin(name): + // Preserve legacy wire format so older app versions can still read it. + var c = encoder.singleValueContainer() + try c.encode(name) + case let .custom(uuid): + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode("custom", forKey: .kind) + try c.encode(uuid, forKey: .id) + } + } +} + +// MARK: - Built-in catalog + +extension SoundFile { + /// Fallback used when a referenced sound file can't be located (e.g. a deleted custom sound). + static let fallback: SoundFile = .indeed + + /// Every built-in sound shipped with the app, in the order shown in the picker. + static let allBuiltins: [SoundFile] = [ + .alarmBuzzer, .alarmClock, .alertToneBusy, .alertToneRingtone1, .alertToneRingtone2, + .alienSiren, .ambulance, .analogWatchAlarm, .bigClockTicking, .burglarAlarmSiren1, + .burglarAlarmSiren2, .cartoonAscendClimbSneaky, .cartoonAscendThenDescend, + .cartoonBounceToCeiling, .cartoonDreamyGlissandoHarp, .cartoonFailStringsTrumpet, + .cartoonMachineClumsyLoop, .cartoonSiren, .cartoonTipToeSneakyWalk, .cartoonUhOh, + .cartoonVillainHorns, .cellPhoneRingTone, .chimesGlassy, .computerMagic, .csfx2Alarm, + .cuckooClock, .dholShuffleloop, .discreet, .earlySunrise, .emergencyAlarmCarbonMonoxide, + .emergencyAlarmSiren, .emergencyAlarm, .endingReached, .fly, .ghostHover, .goodMorning, + .hellYeahSomewhatCalmer, .inAHurry, .indeed, .insistently, .jingleAllTheWay, .laserShoot, + .machineCharge, .magicalTwinkle, .marchingHeavyFootedFatElephants, .marimbaDescend, + .marimbaFlutterOrShake, .martianGun, .martianScanner, .metallic, .nightguard, .notKiddin, + .openYourEyesAndSee, .orchestralHorns, .oringz, .pagerBeeps, .remembersMeOfAsia, + .riseAndShine, .rush, .sciFiAirRaidAlarm, .sciFiAlarmLoop1, .sciFiAlarmLoop2, + .sciFiAlarmLoop3, .sciFiAlarmLoop4, .sciFiAlarm, .sciFiComputerConsoleAlarm, + .sciFiConsoleAlarm, .sciFiEerieAlarm, .sciFiEngineShutDown, .sciFiIncomingMessageAlert, + .sciFiSpaceshipMessage, .sciFiSpaceshipWarmUp, .sciFiWarning, .signatureCorporate, + .siriAlertCalibrationNeeded, .siriAlertDeviceMuted, .siriAlertGlucoseDroppingFast, + .siriAlertGlucoseRisingFast, .siriAlertHighGlucose, .siriAlertLowGlucose, + .siriAlertMissedReadings, .siriAlertTransmitterBatteryLow, .siriAlertUrgentHighGlucose, + .siriAlertUrgentLowGlucose, .siriCalibrationNeeded, .siriDeviceMuted, + .siriGlucoseDroppingFast, .siriGlucoseRisingFast, .siriHighGlucose, .siriLowGlucose, + .siriMissedReadings, .siriTransmitterBatteryLow, .siriUrgentHighGlucose, + .siriUrgentLowGlucose, .softMarimbaPadPositive, .softWarmAiryOptimistic, + .softWarmAiryReassuring, .storeDoorChime, .sunny, .thunderSoundFX, .timeHasCome, + .tornadoSiren, .twoTurtleDoves, .unpaved, .wakeUpWillYou, .winGain, .wrongAnswer, + ] + + // Static aliases so existing call sites like `soundFile = .indeed` keep compiling. + static let alarmBuzzer: SoundFile = .builtin("Alarm_Buzzer") + static let alarmClock: SoundFile = .builtin("Alarm_Clock") + static let alertToneBusy: SoundFile = .builtin("Alert_Tone_Busy") + static let alertToneRingtone1: SoundFile = .builtin("Alert_Tone_Ringtone_1") + static let alertToneRingtone2: SoundFile = .builtin("Alert_Tone_Ringtone_2") + static let alienSiren: SoundFile = .builtin("Alien_Siren") + static let ambulance: SoundFile = .builtin("Ambulance") + static let analogWatchAlarm: SoundFile = .builtin("Analog_Watch_Alarm") + static let bigClockTicking: SoundFile = .builtin("Big_Clock_Ticking") + static let burglarAlarmSiren1: SoundFile = .builtin("Burglar_Alarm_Siren_1") + static let burglarAlarmSiren2: SoundFile = .builtin("Burglar_Alarm_Siren_2") + static let cartoonAscendClimbSneaky: SoundFile = .builtin("Cartoon_Ascend_Climb_Sneaky") + static let cartoonAscendThenDescend: SoundFile = .builtin("Cartoon_Ascend_Then_Descend") + static let cartoonBounceToCeiling: SoundFile = .builtin("Cartoon_Bounce_To_Ceiling") + static let cartoonDreamyGlissandoHarp: SoundFile = .builtin("Cartoon_Dreamy_Glissando_Harp") + static let cartoonFailStringsTrumpet: SoundFile = .builtin("Cartoon_Fail_Strings_Trumpet") + static let cartoonMachineClumsyLoop: SoundFile = .builtin("Cartoon_Machine_Clumsy_Loop") + static let cartoonSiren: SoundFile = .builtin("Cartoon_Siren") + static let cartoonTipToeSneakyWalk: SoundFile = .builtin("Cartoon_Tip_Toe_Sneaky_Walk") + static let cartoonUhOh: SoundFile = .builtin("Cartoon_Uh_Oh") + static let cartoonVillainHorns: SoundFile = .builtin("Cartoon_Villain_Horns") + static let cellPhoneRingTone: SoundFile = .builtin("Cell_Phone_Ring_Tone") + static let chimesGlassy: SoundFile = .builtin("Chimes_Glassy") + static let computerMagic: SoundFile = .builtin("Computer_Magic") + static let csfx2Alarm: SoundFile = .builtin("CSFX-2_Alarm") + static let cuckooClock: SoundFile = .builtin("Cuckoo_Clock") + static let dholShuffleloop: SoundFile = .builtin("Dhol_Shuffleloop") + static let discreet: SoundFile = .builtin("Discreet") + static let earlySunrise: SoundFile = .builtin("Early_Sunrise") + static let emergencyAlarmCarbonMonoxide: SoundFile = .builtin("Emergency_Alarm_Carbon_Monoxide") + static let emergencyAlarmSiren: SoundFile = .builtin("Emergency_Alarm_Siren") + static let emergencyAlarm: SoundFile = .builtin("Emergency_Alarm") + static let endingReached: SoundFile = .builtin("Ending_Reached") + static let fly: SoundFile = .builtin("Fly") + static let ghostHover: SoundFile = .builtin("Ghost_Hover") + static let goodMorning: SoundFile = .builtin("Good_Morning") + static let hellYeahSomewhatCalmer: SoundFile = .builtin("Hell_Yeah_Somewhat_Calmer") + static let inAHurry: SoundFile = .builtin("In_A_Hurry") + static let indeed: SoundFile = .builtin("Indeed") + static let insistently: SoundFile = .builtin("Insistently") + static let jingleAllTheWay: SoundFile = .builtin("Jingle_All_The_Way") + static let laserShoot: SoundFile = .builtin("Laser_Shoot") + static let machineCharge: SoundFile = .builtin("Machine_Charge") + static let magicalTwinkle: SoundFile = .builtin("Magical_Twinkle") + static let marchingHeavyFootedFatElephants: SoundFile = .builtin("Marching_Heavy_Footed_Fat_Elephants") + static let marimbaDescend: SoundFile = .builtin("Marimba_Descend") + static let marimbaFlutterOrShake: SoundFile = .builtin("Marimba_Flutter_or_Shake") + static let martianGun: SoundFile = .builtin("Martian_Gun") + static let martianScanner: SoundFile = .builtin("Martian_Scanner") + static let metallic: SoundFile = .builtin("Metallic") + static let nightguard: SoundFile = .builtin("Nightguard") + static let notKiddin: SoundFile = .builtin("Not_Kiddin") + static let openYourEyesAndSee: SoundFile = .builtin("Open_Your_Eyes_And_See") + static let orchestralHorns: SoundFile = .builtin("Orchestral_Horns") + static let oringz: SoundFile = .builtin("Oringz") + static let pagerBeeps: SoundFile = .builtin("Pager_Beeps") + static let remembersMeOfAsia: SoundFile = .builtin("Remembers_Me_Of_Asia") + static let riseAndShine: SoundFile = .builtin("Rise_And_Shine") + static let rush: SoundFile = .builtin("Rush") + static let sciFiAirRaidAlarm: SoundFile = .builtin("Sci-Fi_Air_Raid_Alarm") + static let sciFiAlarmLoop1: SoundFile = .builtin("Sci-Fi_Alarm_Loop_1") + static let sciFiAlarmLoop2: SoundFile = .builtin("Sci-Fi_Alarm_Loop_2") + static let sciFiAlarmLoop3: SoundFile = .builtin("Sci-Fi_Alarm_Loop_3") + static let sciFiAlarmLoop4: SoundFile = .builtin("Sci-Fi_Alarm_Loop_4") + static let sciFiAlarm: SoundFile = .builtin("Sci-Fi_Alarm") + static let sciFiComputerConsoleAlarm: SoundFile = .builtin("Sci-Fi_Computer_Console_Alarm") + static let sciFiConsoleAlarm: SoundFile = .builtin("Sci-Fi_Console_Alarm") + static let sciFiEerieAlarm: SoundFile = .builtin("Sci-Fi_Eerie_Alarm") + static let sciFiEngineShutDown: SoundFile = .builtin("Sci-Fi_Engine_Shut_Down") + static let sciFiIncomingMessageAlert: SoundFile = .builtin("Sci-Fi_Incoming_Message_Alert") + static let sciFiSpaceshipMessage: SoundFile = .builtin("Sci-Fi_Spaceship_Message") + static let sciFiSpaceshipWarmUp: SoundFile = .builtin("Sci-Fi_Spaceship_Warm_Up") + static let sciFiWarning: SoundFile = .builtin("Sci-Fi_Warning") + static let signatureCorporate: SoundFile = .builtin("Signature_Corporate") + static let siriAlertCalibrationNeeded: SoundFile = .builtin("Siri_Alert_Calibration_Needed") + static let siriAlertDeviceMuted: SoundFile = .builtin("Siri_Alert_Device_Muted") + static let siriAlertGlucoseDroppingFast: SoundFile = .builtin("Siri_Alert_Glucose_Dropping_Fast") + static let siriAlertGlucoseRisingFast: SoundFile = .builtin("Siri_Alert_Glucose_Rising_Fast") + static let siriAlertHighGlucose: SoundFile = .builtin("Siri_Alert_High_Glucose") + static let siriAlertLowGlucose: SoundFile = .builtin("Siri_Alert_Low_Glucose") + static let siriAlertMissedReadings: SoundFile = .builtin("Siri_Alert_Missed_Readings") + static let siriAlertTransmitterBatteryLow: SoundFile = .builtin("Siri_Alert_Transmitter_Battery_Low") + static let siriAlertUrgentHighGlucose: SoundFile = .builtin("Siri_Alert_Urgent_High_Glucose") + static let siriAlertUrgentLowGlucose: SoundFile = .builtin("Siri_Alert_Urgent_Low_Glucose") + static let siriCalibrationNeeded: SoundFile = .builtin("Siri_Calibration_Needed") + static let siriDeviceMuted: SoundFile = .builtin("Siri_Device_Muted") + static let siriGlucoseDroppingFast: SoundFile = .builtin("Siri_Glucose_Dropping_Fast") + static let siriGlucoseRisingFast: SoundFile = .builtin("Siri_Glucose_Rising_Fast") + static let siriHighGlucose: SoundFile = .builtin("Siri_High_Glucose") + static let siriLowGlucose: SoundFile = .builtin("Siri_Low_Glucose") + static let siriMissedReadings: SoundFile = .builtin("Siri_Missed_Readings") + static let siriTransmitterBatteryLow: SoundFile = .builtin("Siri_Transmitter_Battery_Low") + static let siriUrgentHighGlucose: SoundFile = .builtin("Siri_Urgent_High_Glucose") + static let siriUrgentLowGlucose: SoundFile = .builtin("Siri_Urgent_Low_Glucose") + static let softMarimbaPadPositive: SoundFile = .builtin("Soft_Marimba_Pad_Positive") + static let softWarmAiryOptimistic: SoundFile = .builtin("Soft_Warm_Airy_Optimistic") + static let softWarmAiryReassuring: SoundFile = .builtin("Soft_Warm_Airy_Reassuring") + static let storeDoorChime: SoundFile = .builtin("Store_Door_Chime") + static let sunny: SoundFile = .builtin("Sunny") + static let thunderSoundFX: SoundFile = .builtin("Thunder_Sound_FX") + static let timeHasCome: SoundFile = .builtin("Time_Has_Come") + static let tornadoSiren: SoundFile = .builtin("Tornado_Siren") + static let twoTurtleDoves: SoundFile = .builtin("Two_Turtle_Doves") + static let unpaved: SoundFile = .builtin("Unpaved") + static let wakeUpWillYou: SoundFile = .builtin("Wake_Up_Will_You") + static let winGain: SoundFile = .builtin("Win_Gain") + static let wrongAnswer: SoundFile = .builtin("Wrong_Answer") +} diff --git a/LoopFollow/Alarm/CustomSoundStore.swift b/LoopFollow/Alarm/CustomSoundStore.swift new file mode 100644 index 000000000..1d20e7aad --- /dev/null +++ b/LoopFollow/Alarm/CustomSoundStore.swift @@ -0,0 +1,237 @@ +// LoopFollow +// CustomSoundStore.swift + +import AVFoundation +import Foundation + +/// A user-imported alarm sound stored inside the app's Documents directory. +struct CustomSound: Identifiable, Hashable { + let id: UUID + let displayName: String + let url: URL +} + +/// Manages the pool of user-imported alarm sounds. +/// +/// Files live in `Documents/CustomSounds/.`. A tiny sidecar `index.json` +/// maps each UUID to its original filename so the picker can show something meaningful. +/// Files dropped into `Documents/` via the Files app are picked up on next `list()` and +/// moved into `CustomSounds/` with a fresh UUID. +final class CustomSoundStore { + static let shared = CustomSoundStore() + + /// Hard cap on imported audio file size. Alarm sounds should be short; this prevents + /// users from accidentally importing a full podcast episode. + static let maxFileBytes: Int = 2 * 1024 * 1024 // 2 MB + /// Hard cap on audio duration. Alarms loop or repeat via their own delay, so long clips + /// provide no benefit and bloat storage. + static let maxDurationSeconds: TimeInterval = 30 + + enum ImportError: LocalizedError { + case unreadable + case tooLarge(Int) + case tooLong(TimeInterval) + case notAudio + + var errorDescription: String? { + switch self { + case .unreadable: return "Couldn't read the selected file." + case let .tooLarge(bytes): + let mb = Double(bytes) / (1024 * 1024) + return String(format: "File is too large (%.1f MB). Max %d MB.", + mb, CustomSoundStore.maxFileBytes / (1024 * 1024)) + case let .tooLong(seconds): + return String(format: "Audio is too long (%.1fs). Max %.0fs.", + seconds, CustomSoundStore.maxDurationSeconds) + case .notAudio: return "That file isn't a supported audio format." + } + } + } + + private let fileManager = FileManager.default + private let directory: URL + private let indexURL: URL + private let queue = DispatchQueue(label: "CustomSoundStore", qos: .userInitiated) + private var index: [UUID: String] = [:] + + private init() { + let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + directory = documents.appendingPathComponent("CustomSounds", isDirectory: true) + indexURL = directory.appendingPathComponent("index.json") + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + loadIndex() + } + + // MARK: - Public API + + /// All custom sounds available, sorted by display name. + func list() -> [CustomSound] { + return queue.sync { + absorbDroppedFiles() + pruneMissing() + return index.compactMap { id, name in + guard let url = fileURL(for: id) else { return nil } + return CustomSound(id: id, displayName: name, url: url) + } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + } + } + + /// Display name for a given custom sound, or nil if it's been deleted. + func displayName(for id: UUID) -> String? { + queue.sync { index[id] } + } + + /// Resolve a custom sound to its on-disk URL, or nil if missing. + func url(for id: UUID) -> URL? { + queue.sync { fileURL(for: id) } + } + + /// Import the audio at `sourceURL` into the store, validate it, and return a reference. + func importFile(at sourceURL: URL) throws -> CustomSound { + try queue.sync { + let needsScope = sourceURL.startAccessingSecurityScopedResource() + defer { if needsScope { sourceURL.stopAccessingSecurityScopedResource() } } + + // Validate in place before touching the file so a rejected import never + // deletes the user's original. + let size = (try? sourceURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + if size > Self.maxFileBytes { + throw ImportError.tooLarge(size) + } + if !validateAudio(at: sourceURL) { + throw ImportError.notAudio + } + if let duration = audioDuration(at: sourceURL), duration > Self.maxDurationSeconds { + throw ImportError.tooLong(duration) + } + + let newID = UUID() + let ext = sourceURL.pathExtension.isEmpty ? "audio" : sourceURL.pathExtension + let destURL = directory.appendingPathComponent("\(newID.uuidString).\(ext)") + + // If the picked file already lives in our shared Documents folder, move it + // so absorbDroppedFiles() won't re-ingest the leftover original as a + // duplicate. Otherwise copy, leaving the source untouched. + let documentsRoot = directory.deletingLastPathComponent().resolvingSymlinksInPath().path + let sourceInDocuments = sourceURL.resolvingSymlinksInPath().path.hasPrefix(documentsRoot + "/") + do { + if sourceInDocuments { + try fileManager.moveItem(at: sourceURL, to: destURL) + } else { + try fileManager.copyItem(at: sourceURL, to: destURL) + } + } catch { + throw ImportError.unreadable + } + + let displayName = sourceURL.deletingPathExtension().lastPathComponent + index[newID] = displayName.isEmpty ? "Custom Sound" : displayName + saveIndex() + return CustomSound(id: newID, displayName: index[newID]!, url: destURL) + } + } + + /// Delete a custom sound. Alarms referencing it will fall back to the default built-in. + func delete(_ id: UUID) { + queue.sync { + if let url = fileURL(for: id) { + try? fileManager.removeItem(at: url) + } + index.removeValue(forKey: id) + saveIndex() + } + } + + // MARK: - Internals + + private func fileURL(for id: UUID) -> URL? { + guard let contents = try? fileManager.contentsOfDirectory(atPath: directory.path) else { + return nil + } + let prefix = id.uuidString + "." + guard let match = contents.first(where: { $0.hasPrefix(prefix) }) else { return nil } + return directory.appendingPathComponent(match) + } + + private func loadIndex() { + guard + let data = try? Data(contentsOf: indexURL), + let decoded = try? JSONDecoder().decode([String: String].self, from: data) + else { + index = [:] + return + } + var result: [UUID: String] = [:] + for (key, value) in decoded { + if let uuid = UUID(uuidString: key) { + result[uuid] = value + } + } + index = result + } + + private func saveIndex() { + let encodable = Dictionary(uniqueKeysWithValues: index.map { ($0.key.uuidString, $0.value) }) + if let data = try? JSONEncoder().encode(encodable) { + try? data.write(to: indexURL, options: .atomic) + } + } + + /// Drop entries from the index that point to files that no longer exist on disk + /// (e.g. the user deleted them via the Files app). + private func pruneMissing() { + var didChange = false + for id in Array(index.keys) where fileURL(for: id) == nil { + index.removeValue(forKey: id) + didChange = true + } + if didChange { saveIndex() } + } + + /// Pick up audio files that were dropped into the Documents directory via the + /// Files app and move them into `CustomSounds/` with a fresh UUID. + /// + /// Each candidate is validated *in place* against the same size/duration/format + /// rules as `importFile(at:)` before being moved. A file that fails any check is + /// left untouched in Documents — we never move (and therefore never delete) a file + /// the user put there. + private func absorbDroppedFiles() { + let documents = directory.deletingLastPathComponent() + guard let entries = try? fileManager.contentsOfDirectory(at: documents, includingPropertiesForKeys: [.fileSizeKey]) else { + return + } + let audioExtensions: Set = ["mp3", "wav", "m4a", "aac", "aif", "aiff", "caf"] + var didChange = false + for entry in entries { + var isDir: ObjCBool = false + guard fileManager.fileExists(atPath: entry.path, isDirectory: &isDir), !isDir.boolValue else { continue } + guard audioExtensions.contains(entry.pathExtension.lowercased()) else { continue } + + let size = (try? entry.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + guard size <= Self.maxFileBytes, validateAudio(at: entry) else { continue } + if let duration = audioDuration(at: entry), duration > Self.maxDurationSeconds { continue } + + let newID = UUID() + let dest = directory.appendingPathComponent("\(newID.uuidString).\(entry.pathExtension)") + do { + try fileManager.moveItem(at: entry, to: dest) + } catch { + continue + } + let baseName = entry.deletingPathExtension().lastPathComponent + index[newID] = baseName.isEmpty ? "Custom Sound" : baseName + didChange = true + } + if didChange { saveIndex() } + } + + private func validateAudio(at url: URL) -> Bool { + return (try? AVAudioPlayer(contentsOf: url)) != nil + } + + private func audioDuration(at url: URL) -> TimeInterval? { + guard let player = try? AVAudioPlayer(contentsOf: url) else { return nil } + return player.duration + } +} diff --git a/LoopFollow/Controllers/AlarmSound.swift b/LoopFollow/Controllers/AlarmSound.swift index 7f32fd181..fafd2bf6f 100644 --- a/LoopFollow/Controllers/AlarmSound.swift +++ b/LoopFollow/Controllers/AlarmSound.swift @@ -58,7 +58,30 @@ class AlarmSound { } static func setSoundFile(str: String) { - soundURL = Bundle.main.url(forResource: str, withExtension: "caf")! + setSoundFile(.builtin(str)) + } + + static func setSoundFile(_ file: SoundFile) { + if let resolved = resolveURL(for: file) { + soundURL = resolved + return + } + LogManager.shared.log( + category: .alarm, + message: "AlarmSound - missing sound for \(file.id); falling back to \(SoundFile.fallback.id)" + ) + if let fallback = resolveURL(for: .fallback) { + soundURL = fallback + } + } + + private static func resolveURL(for file: SoundFile) -> URL? { + switch file { + case let .builtin(name): + return Bundle.main.url(forResource: name, withExtension: "caf") + case let .custom(uuid): + return CustomSoundStore.shared.url(for: uuid) + } } /*