CustomSoundStore.swift 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. // LoopFollow
  2. // CustomSoundStore.swift
  3. import AVFoundation
  4. import Foundation
  5. /// A user-imported alarm sound stored inside the app's Documents directory.
  6. struct CustomSound: Identifiable, Hashable {
  7. let id: UUID
  8. let displayName: String
  9. let url: URL
  10. }
  11. /// Manages the pool of user-imported alarm sounds.
  12. ///
  13. /// Files live in `Documents/CustomSounds/<uuid>.<ext>`. A tiny sidecar `index.json`
  14. /// maps each UUID to its original filename so the picker can show something meaningful.
  15. /// Files dropped into `Documents/` via the Files app are picked up on next `list()` and
  16. /// moved into `CustomSounds/` with a fresh UUID.
  17. final class CustomSoundStore {
  18. static let shared = CustomSoundStore()
  19. /// Hard cap on imported audio file size. Alarm sounds should be short; this prevents
  20. /// users from accidentally importing a full podcast episode.
  21. static let maxFileBytes: Int = 2 * 1024 * 1024 // 2 MB
  22. /// Hard cap on audio duration. Alarms loop or repeat via their own delay, so long clips
  23. /// provide no benefit and bloat storage.
  24. static let maxDurationSeconds: TimeInterval = 30
  25. enum ImportError: LocalizedError {
  26. case unreadable
  27. case tooLarge(Int)
  28. case tooLong(TimeInterval)
  29. case notAudio
  30. var errorDescription: String? {
  31. switch self {
  32. case .unreadable: return "Couldn't read the selected file."
  33. case let .tooLarge(bytes):
  34. let mb = Double(bytes) / (1024 * 1024)
  35. return String(format: "File is too large (%.1f MB). Max %d MB.",
  36. mb, CustomSoundStore.maxFileBytes / (1024 * 1024))
  37. case let .tooLong(seconds):
  38. return String(format: "Audio is too long (%.1fs). Max %.0fs.",
  39. seconds, CustomSoundStore.maxDurationSeconds)
  40. case .notAudio: return "That file isn't a supported audio format."
  41. }
  42. }
  43. }
  44. private let fileManager = FileManager.default
  45. private let directory: URL
  46. private let indexURL: URL
  47. private let queue = DispatchQueue(label: "CustomSoundStore", qos: .userInitiated)
  48. private var index: [UUID: String] = [:]
  49. private init() {
  50. let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
  51. directory = documents.appendingPathComponent("CustomSounds", isDirectory: true)
  52. indexURL = directory.appendingPathComponent("index.json")
  53. try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
  54. loadIndex()
  55. }
  56. // MARK: - Public API
  57. /// All custom sounds available, sorted by display name.
  58. func list() -> [CustomSound] {
  59. return queue.sync {
  60. absorbDroppedFiles()
  61. pruneMissing()
  62. return index.compactMap { id, name in
  63. guard let url = fileURL(for: id) else { return nil }
  64. return CustomSound(id: id, displayName: name, url: url)
  65. }
  66. .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
  67. }
  68. }
  69. /// Display name for a given custom sound, or nil if it's been deleted.
  70. func displayName(for id: UUID) -> String? {
  71. queue.sync { index[id] }
  72. }
  73. /// Resolve a custom sound to its on-disk URL, or nil if missing.
  74. func url(for id: UUID) -> URL? {
  75. queue.sync { fileURL(for: id) }
  76. }
  77. /// Import the audio at `sourceURL` into the store, validate it, and return a reference.
  78. func importFile(at sourceURL: URL) throws -> CustomSound {
  79. try queue.sync {
  80. let needsScope = sourceURL.startAccessingSecurityScopedResource()
  81. defer { if needsScope { sourceURL.stopAccessingSecurityScopedResource() } }
  82. // Validate in place before touching the file so a rejected import never
  83. // deletes the user's original.
  84. let size = (try? sourceURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
  85. if size > Self.maxFileBytes {
  86. throw ImportError.tooLarge(size)
  87. }
  88. if !validateAudio(at: sourceURL) {
  89. throw ImportError.notAudio
  90. }
  91. if let duration = audioDuration(at: sourceURL), duration > Self.maxDurationSeconds {
  92. throw ImportError.tooLong(duration)
  93. }
  94. let newID = UUID()
  95. let ext = sourceURL.pathExtension.isEmpty ? "audio" : sourceURL.pathExtension
  96. let destURL = directory.appendingPathComponent("\(newID.uuidString).\(ext)")
  97. // If the picked file already lives in our shared Documents folder, move it
  98. // so absorbDroppedFiles() won't re-ingest the leftover original as a
  99. // duplicate. Otherwise copy, leaving the source untouched.
  100. let documentsRoot = directory.deletingLastPathComponent().resolvingSymlinksInPath().path
  101. let sourceInDocuments = sourceURL.resolvingSymlinksInPath().path.hasPrefix(documentsRoot + "/")
  102. do {
  103. if sourceInDocuments {
  104. try fileManager.moveItem(at: sourceURL, to: destURL)
  105. } else {
  106. try fileManager.copyItem(at: sourceURL, to: destURL)
  107. }
  108. } catch {
  109. throw ImportError.unreadable
  110. }
  111. let displayName = sourceURL.deletingPathExtension().lastPathComponent
  112. index[newID] = displayName.isEmpty ? "Custom Sound" : displayName
  113. saveIndex()
  114. return CustomSound(id: newID, displayName: index[newID]!, url: destURL)
  115. }
  116. }
  117. /// Delete a custom sound. Alarms referencing it will fall back to the default built-in.
  118. func delete(_ id: UUID) {
  119. queue.sync {
  120. if let url = fileURL(for: id) {
  121. try? fileManager.removeItem(at: url)
  122. }
  123. index.removeValue(forKey: id)
  124. saveIndex()
  125. }
  126. }
  127. // MARK: - Internals
  128. private func fileURL(for id: UUID) -> URL? {
  129. guard let contents = try? fileManager.contentsOfDirectory(atPath: directory.path) else {
  130. return nil
  131. }
  132. let prefix = id.uuidString + "."
  133. guard let match = contents.first(where: { $0.hasPrefix(prefix) }) else { return nil }
  134. return directory.appendingPathComponent(match)
  135. }
  136. private func loadIndex() {
  137. guard
  138. let data = try? Data(contentsOf: indexURL),
  139. let decoded = try? JSONDecoder().decode([String: String].self, from: data)
  140. else {
  141. index = [:]
  142. return
  143. }
  144. var result: [UUID: String] = [:]
  145. for (key, value) in decoded {
  146. if let uuid = UUID(uuidString: key) {
  147. result[uuid] = value
  148. }
  149. }
  150. index = result
  151. }
  152. private func saveIndex() {
  153. let encodable = Dictionary(uniqueKeysWithValues: index.map { ($0.key.uuidString, $0.value) })
  154. if let data = try? JSONEncoder().encode(encodable) {
  155. try? data.write(to: indexURL, options: .atomic)
  156. }
  157. }
  158. /// Drop entries from the index that point to files that no longer exist on disk
  159. /// (e.g. the user deleted them via the Files app).
  160. private func pruneMissing() {
  161. var didChange = false
  162. for id in Array(index.keys) where fileURL(for: id) == nil {
  163. index.removeValue(forKey: id)
  164. didChange = true
  165. }
  166. if didChange { saveIndex() }
  167. }
  168. /// Pick up audio files that were dropped into the Documents directory via the
  169. /// Files app and move them into `CustomSounds/` with a fresh UUID.
  170. ///
  171. /// Each candidate is validated *in place* against the same size/duration/format
  172. /// rules as `importFile(at:)` before being moved. A file that fails any check is
  173. /// left untouched in Documents — we never move (and therefore never delete) a file
  174. /// the user put there.
  175. private func absorbDroppedFiles() {
  176. let documents = directory.deletingLastPathComponent()
  177. guard let entries = try? fileManager.contentsOfDirectory(at: documents, includingPropertiesForKeys: [.fileSizeKey]) else {
  178. return
  179. }
  180. let audioExtensions: Set<String> = ["mp3", "wav", "m4a", "aac", "aif", "aiff", "caf"]
  181. var didChange = false
  182. for entry in entries {
  183. var isDir: ObjCBool = false
  184. guard fileManager.fileExists(atPath: entry.path, isDirectory: &isDir), !isDir.boolValue else { continue }
  185. guard audioExtensions.contains(entry.pathExtension.lowercased()) else { continue }
  186. let size = (try? entry.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
  187. guard size <= Self.maxFileBytes, validateAudio(at: entry) else { continue }
  188. if let duration = audioDuration(at: entry), duration > Self.maxDurationSeconds { continue }
  189. let newID = UUID()
  190. let dest = directory.appendingPathComponent("\(newID.uuidString).\(entry.pathExtension)")
  191. do {
  192. try fileManager.moveItem(at: entry, to: dest)
  193. } catch {
  194. continue
  195. }
  196. let baseName = entry.deletingPathExtension().lastPathComponent
  197. index[newID] = baseName.isEmpty ? "Custom Sound" : baseName
  198. didChange = true
  199. }
  200. if didChange { saveIndex() }
  201. }
  202. private func validateAudio(at url: URL) -> Bool {
  203. return (try? AVAudioPlayer(contentsOf: url)) != nil
  204. }
  205. private func audioDuration(at url: URL) -> TimeInterval? {
  206. guard let player = try? AVAudioPlayer(contentsOf: url) else { return nil }
  207. return player.duration
  208. }
  209. }