From 284d9ebdf347458c5168502fb563f51836fa10dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 20 Apr 2026 20:28:37 +0200 Subject: [PATCH 1/3] Add custom alarm sounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now import their own audio for alarm tones via the tone picker (document picker, `.audio` UTType) or by dropping files into the app's Documents folder from the Files app. Imported files are copied into `Documents/CustomSounds/`, validated with AVAudioPlayer, and capped at 2 MB / 30 s. SoundFile is now an enum with `.builtin(String)` and `.custom(UUID)` cases. Built-in values encode as a bare string so existing stored alarms keep loading; custom values encode as a keyed object. Static aliases (`.indeed`, `.wrongAnswer`, …) preserve all existing call sites. AlarmSound.setSoundFile now resolves built-in via the bundle and custom via CustomSoundStore, and falls back to the default built-in with a log when a file is missing (deleted custom sound, etc.) instead of crashing. --- LoopFollow.xcodeproj/project.pbxproj | 4 + LoopFollow/Alarm/Alarm.swift | 2 +- .../Components/AlarmAudioSection.swift | 106 +++++- .../AlarmEditing/Components/SoundFile.swift | 345 ++++++++++++------ LoopFollow/Alarm/CustomSoundStore.swift | 220 +++++++++++ LoopFollow/Controllers/AlarmSound.swift | 25 +- 6 files changed, 572 insertions(+), 130 deletions(-) create mode 100644 LoopFollow/Alarm/CustomSoundStore.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 9a11022d6..1b9b3f3c3 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -235,6 +235,7 @@ DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979324C0D380002C9752 /* UIViewExtension.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 */; }; @@ -686,6 +687,7 @@ DDCC3AD62DDE1790006F1C10 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DDCF979324C0D380002C9752 /* UIViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtension.swift; sourceTree = ""; }; 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 = ""; }; @@ -1320,6 +1322,7 @@ DDCF9A872D85FD33004DF4DD /* AlarmData.swift */, DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */, DDBD19952DFB44B0005C2D69 /* Alarm+byPriorityThenSpec.swift */, + DDCF9AA02E99000000000002 /* CustomSoundStore.swift */, ); path = Alarm; sourceTree = ""; @@ -2351,6 +2354,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..d992216a1 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,76 @@ 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) { + for index in offsets { + let sound = customSounds[index] + CustomSoundStore.shared.delete(sound.id) + } + 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..ab3a286b6 --- /dev/null +++ b/LoopFollow/Alarm/CustomSoundStore.swift @@ -0,0 +1,220 @@ +// 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) } + } + + /// Copy 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() } } + + let size = (try? sourceURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + if size > Self.maxFileBytes { + throw ImportError.tooLarge(size) + } + + let newID = UUID() + let ext = sourceURL.pathExtension.isEmpty ? "audio" : sourceURL.pathExtension + let destURL = directory.appendingPathComponent("\(newID.uuidString).\(ext)") + + do { + try fileManager.copyItem(at: sourceURL, to: destURL) + } catch { + throw ImportError.unreadable + } + + if !validateAudio(at: destURL) { + try? fileManager.removeItem(at: destURL) + throw ImportError.notAudio + } + if let duration = audioDuration(at: destURL), duration > Self.maxDurationSeconds { + try? fileManager.removeItem(at: destURL) + throw ImportError.tooLong(duration) + } + + 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() { + for id in Array(index.keys) where fileURL(for: id) == nil { + index.removeValue(forKey: id) + } + 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. + private func absorbDroppedFiles() { + let documents = directory.deletingLastPathComponent() + guard let entries = try? fileManager.contentsOfDirectory(at: documents, includingPropertiesForKeys: nil) else { + return + } + let audioExtensions: Set = ["mp3", "wav", "m4a", "aac", "aif", "aiff", "caf"] + 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 newID = UUID() + let dest = directory.appendingPathComponent("\(newID.uuidString).\(entry.pathExtension)") + do { + try fileManager.moveItem(at: entry, to: dest) + } catch { + continue + } + if !validateAudio(at: dest) { + try? fileManager.removeItem(at: dest) + continue + } + let baseName = entry.deletingPathExtension().lastPathComponent + index[newID] = baseName.isEmpty ? "Custom Sound" : baseName + } + 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 ba131bc4f..ae5964e89 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) + } } /* From 92a642dcf614b11ea03996fbb3d19a84657fc0ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 21 Jun 2026 14:03:47 +0200 Subject: [PATCH 2/3] Harden custom alarm sound imports Validate files dropped into Documents in place before moving them into the store, applying the same size, duration, and format checks as the in-app importer. This prevents a user's file from being deleted when it fails the audio check and stops oversized files from bypassing the caps. Reset the alarm's sound to the fallback built-in when the selected custom sound is deleted, and avoid rewriting the index when nothing changed. --- .../Components/AlarmAudioSection.swift | 9 ++++++-- LoopFollow/Alarm/CustomSoundStore.swift | 23 +++++++++++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift b/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift index d992216a1..e78de27a2 100644 --- a/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift +++ b/LoopFollow/Alarm/AlarmEditing/Components/AlarmAudioSection.swift @@ -202,10 +202,15 @@ private struct TonePickerSheet: View { } private func deleteCustomSounds(at offsets: IndexSet) { - for index in offsets { - let sound = customSounds[index] + 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() } diff --git a/LoopFollow/Alarm/CustomSoundStore.swift b/LoopFollow/Alarm/CustomSoundStore.swift index ab3a286b6..15e1a291d 100644 --- a/LoopFollow/Alarm/CustomSoundStore.swift +++ b/LoopFollow/Alarm/CustomSoundStore.swift @@ -173,25 +173,37 @@ final class CustomSoundStore { /// 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 } - saveIndex() + 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: nil) else { + 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 { @@ -199,14 +211,11 @@ final class CustomSoundStore { } catch { continue } - if !validateAudio(at: dest) { - try? fileManager.removeItem(at: dest) - continue - } let baseName = entry.deletingPathExtension().lastPathComponent index[newID] = baseName.isEmpty ? "Custom Sound" : baseName + didChange = true } - saveIndex() + if didChange { saveIndex() } } private func validateAudio(at url: URL) -> Bool { From 4ba87b3fecd3bb46fadde78b0bfbacab526f8bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 8 Jul 2026 23:19:25 +0200 Subject: [PATCH 3/3] Prevent duplicate custom sound when importing from shared folder Move the picked file into the store instead of copying when it already lives in the app's shared Documents folder, so absorbDroppedFiles() doesn't re-ingest the leftover original as a second entry. Validate before the file operation so a rejected import never deletes the user's original. --- LoopFollow/Alarm/CustomSoundStore.swift | 30 ++++++++++++++++--------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/LoopFollow/Alarm/CustomSoundStore.swift b/LoopFollow/Alarm/CustomSoundStore.swift index 15e1a291d..1d20e7aad 100644 --- a/LoopFollow/Alarm/CustomSoundStore.swift +++ b/LoopFollow/Alarm/CustomSoundStore.swift @@ -87,36 +87,44 @@ final class CustomSoundStore { queue.sync { fileURL(for: id) } } - /// Copy the audio at `sourceURL` into the store, validate it, and return a reference. + /// 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 { - try fileManager.copyItem(at: sourceURL, to: destURL) + if sourceInDocuments { + try fileManager.moveItem(at: sourceURL, to: destURL) + } else { + try fileManager.copyItem(at: sourceURL, to: destURL) + } } catch { throw ImportError.unreadable } - if !validateAudio(at: destURL) { - try? fileManager.removeItem(at: destURL) - throw ImportError.notAudio - } - if let duration = audioDuration(at: destURL), duration > Self.maxDurationSeconds { - try? fileManager.removeItem(at: destURL) - throw ImportError.tooLong(duration) - } - let displayName = sourceURL.deletingPathExtension().lastPathComponent index[newID] = displayName.isEmpty ? "Custom Sound" : displayName saveIndex()