// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import AudioToolbox import SignalCoreKit public enum Sound: Equatable { case standard(StandardSound) case custom(CustomSound) public static func == (lhs: Sound, rhs: Sound) -> Bool { return lhs.id == rhs.id } } public extension Sound { var id: UInt { switch self { case .standard(let standardSound): return standardSound.rawValue case .custom(let customSound): return customSound.id } } var displayName: String { switch self { case .standard(let standardSound): return standardSound.displayName case .custom(let customSound): return customSound.displayName } } var filename: String? { filename(quiet: false) } func filename(quiet: Bool) -> String? { switch self { case .standard(let standardSound): return standardSound.filename(quiet: quiet) case .custom(let customSound): return customSound.filename } } func soundUrl(quiet: Bool) -> URL? { if case .custom(let customSound) = self { return customSound.url } guard case .standard(let standardSound) = self, let filename = standardSound.filename(quiet: quiet) else { return nil } let url = Bundle.main.url( forResource: (filename as NSString).deletingPathExtension, withExtension: (filename as NSString).pathExtension ) owsAssertDebug(url != nil) return url } } public enum StandardSound: UInt { case `default` = 0 // Notification Sounds case aurora = 1 case bamboo = 2 case chord = 3 case circles = 4 case complete = 5 case hello = 6 case input = 7 case keys = 8 case note = 9 case popcorn = 10 case pulse = 11 case synth = 12 case signalClassic = 13 // Calls case callConnecting = 15 case callOutboundRinging = 16 case callBusy = 17 case callEnded = 18 // Group Calls case groupCallJoin = 19 case groupCallLeave = 20 case raisedHand = 26 // Other case messageSent = 21 case none = 22 case silence = 23 // Audio Playback case beginNextTrack = 24 case endLastTrack = 25 } public extension StandardSound { var displayName: String { // TODO: Should we localize these sound names? switch self { case .`default`: owsFailDebug("invalid argument.") return "" // Notification Sounds case .aurora: return "Aurora" case .bamboo: return "Bamboo" case .chord: return "Chord" case .circles: return "Circles" case .complete: return "Complete" case .hello: return "Hello" case .input: return "Input" case .keys: return "Keys" case .note: return "Note" case .popcorn: return "Popcorn" case .pulse: return "Pulse" case .synth: return "Synth" case .signalClassic: return "Signal Classic" // Calls case .callConnecting: return "Call Connecting" case .callOutboundRinging: return "Call Outbound Ringing" case .callBusy: return "Call Busy" case .callEnded: return "Call Ended" // Group Calls case .groupCallJoin: return "Group Call Join" case .groupCallLeave: return "Group Call Leave" case .raisedHand: return "Raise Hand" // Other case .messageSent: return "Message Sent" case .none: return OWSLocalizedString( "SOUNDS_NONE", comment: "Label for the 'no sound' option that allows users to disable sounds for notifications, etc." ) case .silence: return "Silence" // Audio Playback case .beginNextTrack: return "Begin Next Track" case .endLastTrack: return "End Last Track" } } fileprivate func filename(quiet: Bool) -> String? { switch self { case .`default`: owsFailDebug("invalid argument.") return nil // Notification Sounds case .aurora: return quiet ? "aurora-quiet.aifc" : "aurora.aifc" case .bamboo: return quiet ? "bamboo-quiet.aifc" : "bamboo.aifc" case .chord: return quiet ? "chord-quiet.aifc" : "chord.aifc" case .circles: return quiet ? "circles-quiet.aifc" : "circles.aifc" case .complete: return quiet ? "complete-quiet.aifc" : "complete.aifc" case .hello: return quiet ? "hello-quiet.aifc" : "hello.aifc" case .input: return quiet ? "input-quiet.aifc" : "input.aifc" case .keys: return quiet ? "keys-quiet.aifc" : "keys.aifc" case .note: return quiet ? "note-quiet.aifc" : "note.aifc" case .popcorn: return quiet ? "popcorn-quiet.aifc" : "popcorn.aifc" case .pulse: return quiet ? "pulse-quiet.aifc" : "pulse.aifc" case .synth: return quiet ? "synth-quiet.aifc" : "synth.aifc" case .signalClassic: return quiet ? "classic-quiet.aifc" : "classic.aifc" // Calls case .callConnecting: return "ringback_tone_ansi.caf" case .callOutboundRinging: return "ringback_tone_ansi.caf" case .callBusy: return "busy_tone_ansi.caf" case .callEnded: return "end_call_tone_cept.caf" // Group Calls case .groupCallJoin: return "group_call_join.aiff" case .groupCallLeave: return "group_call_leave.aiff" case .raisedHand: return "notification_simple-01.caf" // Other case .messageSent: return "message_sent.aiff" case .silence: return "silence.aiff" case .none: return nil // Audio Playback case .beginNextTrack: return "state-change_confirm-down.caf" case .endLastTrack: return "state-change_confirm-up.caf" } } } public struct CustomSound { let id: UInt let filename: String private init(id: UInt, filename: String) { self.id = id self.filename = filename } init?(filename: String) { guard let id = CustomSound.idFromFilename(filename) else { return nil } self.id = id self.filename = filename } fileprivate var displayName: String { let filenameWithoutExtension = (filename as NSString).deletingPathExtension guard !filenameWithoutExtension.isEmpty else { owsFailDebug("Empty filename") return "Custom Sound" } return filenameWithoutExtension.capitalized } fileprivate var url: URL { return URL(fileURLWithPath: Sounds.soundsDirectory, isDirectory: true).appendingPathComponent(filename) } fileprivate static var all: [CustomSound] { let filenames: [String] do { filenames = try FileManager.default.contentsOfDirectory(atPath: Sounds.soundsDirectory) } catch { owsFailDebug("Failed retrieving custom sound files: \(error)") return [] } let sounds: [CustomSound] = filenames.compactMap { filename in guard filename != Sounds.defaultNotificationSoundFilename else { return nil } return CustomSound(filename: filename) } return sounds } // MARK: - private static let customSoundShift: UInt = 16 private static func idFromFilename(_ filename: String) -> UInt? { guard let filenameData = filename.data(using: .utf8) else { owsFailDebug("could not get data from filename.") return nil } guard let hashData = Cryptography.computeSHA256Digest(filenameData, truncatedToBytes: UInt(MemoryLayout.size)) else { owsFailDebug("could not get hash from filename.") return nil } var hashValue: UInt = 0 hashData.withUnsafeBytes { ptr in hashValue = ptr.load(as: UInt.self) } return hashValue << customSoundShift } } private class SystemSound: NSObject { let id: SystemSoundID let soundUrl: URL init?(url: URL) { var newSoundId: SystemSoundID = 0 guard kAudioServicesNoError == AudioServicesCreateSystemSoundID(url as CFURL, &newSoundId), newSoundId != 0 else { owsFailDebug("AudioServicesCreateSystemSoundID failed") return nil } id = newSoundId soundUrl = url super.init() } deinit { let status = AudioServicesDisposeSystemSoundID(id) owsAssertDebug(status == kAudioServicesNoError) } } public class Sounds: Dependencies { // This name is specified in the payload by the Signal Service when requesting fallback push notifications. fileprivate static let defaultNotificationSoundFilename = "NewMessage.aifc" fileprivate static let soundsStorageGlobalNotificationKey = "kOWSSoundsStorageGlobalNotificationKey" private static let cachedSystemSounds = AnyLRUCache(maxSize: 4, nseMaxSize: 0, shouldEvacuateInBackground: false) private static let keyValueStore = SDSKeyValueStore(collection: "kOWSSoundsStorageNotificationCollection") private init() { } public static func performStartupTasks() { AppReadiness.runNowOrWhenMainAppDidBecomeReadyAsync { Sounds.migrateLegacySounds() Sounds.cleanupOrphanedSounds() } } // MARK: - Public public static var allNotificationSounds: [Sound] { let standardSounds: [StandardSound] = [ // None and Note (default) should be first. .none, .note, .aurora, .bamboo, .chord, .circles, .complete, .hello, .input, .keys, .popcorn, .pulse, .signalClassic, .synth ] let customSounds = CustomSound.all return standardSounds.map({ Sound.standard($0) }) + customSounds.map({ Sound.custom($0) }) } public static var soundsDirectory: String { let directory = OWSFileSystem.appSharedDataDirectoryPath().appendingPathComponent("Library/Sounds") OWSFileSystem.ensureDirectoryExists(directory) return directory } public static func systemSoundIDForSound(_ sound: Sound, quiet: Bool) -> SystemSoundID? { let cacheKey = String(format: "%lu:%d", sound.id, quiet) if let cachedSound = cachedSystemSounds.get(key: cacheKey as NSString) as? SystemSound { return cachedSound.id } guard let soundUrl = sound.soundUrl(quiet: quiet), let systemSound = SystemSound(url: soundUrl) else { owsFailDebug("Failed to create system sound") return nil } cachedSystemSounds.set(key: cacheKey as NSString, value: systemSound) return systemSound.id } public static func importSoundsAtUrls(_ urls: [URL]) { guard !urls.isEmpty else { return } let fileManager = FileManager.default for url in urls { let filename = url.lastPathComponent guard !filename.isEmpty, let destinationUrl = NSURL(fileURLWithPath: soundsDirectory, isDirectory: true).appendingPathComponent(filename), !fileManager.fileExists(atPath: destinationUrl.path) else { continue } do { try fileManager.copyItem(at: url, to: destinationUrl) } catch { owsFailDebug("Failed to import custom sound with error: \(error)") } } } // MARK: - Notifications public static var defaultNotificationSound: Sound { .standard(.note) } private static func soundForId(_ soundId: UInt) -> Sound { if let standardSound = StandardSound(rawValue: soundId) { return .standard(standardSound) } if let customSound = CustomSound.all.first(where: { $0.id == soundId }) { return .custom(customSound) } return defaultNotificationSound } public static var globalNotificationSound: Sound { let soundId = databaseStorage.read { transaction in return keyValueStore.getUInt(soundsStorageGlobalNotificationKey, transaction: transaction) } guard let soundId else { return defaultNotificationSound } return soundForId(soundId) } public static func setGlobalNotificationSound(_ sound: Sound) { databaseStorage.write { transaction in setGlobalNotificationSound(sound, transaction: transaction) } } private static func setGlobalNotificationSound(_ sound: Sound, transaction: SDSAnyWriteTransaction) { Logger.info("Setting global notification sound to: \(sound.displayName)") // Fallback push notifications play a sound specified by the server, but we don't want to store this configuration // on the server. Instead, we create a file with the same name as the default to be played when receiving // a fallback notification. let defaultSoundUrl = URL(fileURLWithPath: soundsDirectory, isDirectory: true).appendingPathComponent(defaultNotificationSoundFilename) let soundUrl = sound.soundUrl(quiet: false) let soundData: Data = { if let soundUrl, let data = try? Data(contentsOf: soundUrl) { return data } guard sound == .standard(.none) else { owsFailDebug("Failed to load sound data.") return Data() } return Data() }() // Quick way to achieve an atomic "copy" operation that allows overwriting if the user has previously specified // a default notification sound. do { try soundData.write(to: defaultSoundUrl, options: .atomic) } catch { owsFailDebug("Unable to write new default sound data from: \(String(describing: soundUrl)) to \(defaultSoundUrl): \(error)") return } // The globally configured sound the user has configured is unprotected, so that we can still play the sound if the // user hasn't authenticated after power-cycling their device. OWSFileSystem.protectFileOrFolder(atPath: defaultSoundUrl.path, fileProtectionType: .none) keyValueStore.setUInt(sound.id, key: soundsStorageGlobalNotificationKey, transaction: transaction) } public static func notificationSoundForThread(_ thread: TSThread) -> Sound { let soundId = databaseStorage.read { transaction in return keyValueStore.getUInt(thread.uniqueId, transaction: transaction) } guard let soundId else { return globalNotificationSound } return soundForId(soundId) } public static func setNotificationSound(_ sound: Sound, forThread thread: TSThread) { databaseStorage.write { transaction in keyValueStore.setUInt(sound.id, key: thread.uniqueId, transaction: transaction) } } // MARK: - Util private static func deleteCustomSound(_ sound: CustomSound) -> Bool { do { try OWSFileSystem.deleteFileIfExists(url: sound.url) return true } catch { owsFailDebug("Failed to delete custom sound: \(error)") return false } } private static func migrateLegacySounds() { owsAssertDebug(CurrentAppContext().isMainApp) let legacySoundsDirectory = OWSFileSystem.appLibraryDirectoryPath().appendingPathComponent("Sounds") guard OWSFileSystem.fileOrFolderExists(atPath: legacySoundsDirectory) else { return } guard let legacySoundFiles = try? FileManager.default.contentsOfDirectory(atPath: legacySoundsDirectory) else { owsFailDebug("Failed looking up legacy sound files") return } for soundFile in legacySoundFiles { do { try FileManager.default.moveItem( atPath: legacySoundsDirectory.appendingPathComponent(soundFile), toPath: soundsDirectory.appendingPathComponent(soundFile) ) } catch { owsFailDebug("Failed to migrate legacy sound file: \(error)") } } if !OWSFileSystem.deleteFile(legacySoundsDirectory) { owsFailDebug("Failed to delete legacy sounds directory") } } private static func cleanupOrphanedSounds() { owsAssertDebug(CurrentAppContext().isMainApp) let allCustomSounds = CustomSound.all guard !allCustomSounds.isEmpty else { return } let allInUseSoundIds = databaseStorage.read { transaction in return Set(keyValueStore.allValues(transaction: transaction) as! [UInt]) } let orphanedSounds = allCustomSounds.filter { !allInUseSoundIds.contains($0.id) } guard !orphanedSounds.isEmpty else { return } var deletedCount: UInt = 0 for sound in orphanedSounds { if deleteCustomSound(sound) { deletedCount += 1 } } Logger.info("Cleaned up \(deletedCount) orphaned custom sounds.") } }