| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- // 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/<uuid>.<ext>`. 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<String> = ["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
- }
- }
|