525 lines
17 KiB
Swift
525 lines
17 KiB
Swift
//
|
|
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
public import AudioToolbox
|
|
import CryptoKit
|
|
|
|
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
|
|
|
|
init(filename: String) {
|
|
self.id = CustomSound.idFromFilename(filename)
|
|
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 {
|
|
let filenameData = Data(filename.utf8)
|
|
let hashValue = SHA256.hash(data: filenameData).withUnsafeBytes {
|
|
$0.loadUnaligned(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 {
|
|
|
|
// 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 = KeyValueStore(collection: "kOWSSoundsStorageNotificationCollection")
|
|
|
|
private init() { }
|
|
|
|
public static func performStartupTasks(appReadiness: AppReadiness) {
|
|
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 = SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
return keyValueStore.getUInt(soundsStorageGlobalNotificationKey, transaction: transaction.asV2Read)
|
|
}
|
|
guard let soundId else { return defaultNotificationSound }
|
|
return soundForId(soundId)
|
|
}
|
|
|
|
public static func setGlobalNotificationSound(_ sound: Sound) {
|
|
SSKEnvironment.shared.databaseStorageRef.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.asV2Write)
|
|
}
|
|
|
|
public static func notificationSoundWithSneakyTransaction(forThreadUniqueId threadUniqueId: String) -> Sound {
|
|
let soundId = SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
return keyValueStore.getUInt(threadUniqueId, transaction: transaction.asV2Read)
|
|
}
|
|
guard let soundId else { return globalNotificationSound }
|
|
return soundForId(soundId)
|
|
}
|
|
|
|
public static func setNotificationSound(_ sound: Sound, forThread thread: TSThread) {
|
|
SSKEnvironment.shared.databaseStorageRef.write { transaction in
|
|
keyValueStore.setUInt(sound.id, key: thread.uniqueId, transaction: transaction.asV2Write)
|
|
}
|
|
}
|
|
|
|
// 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 = SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
return Set(keyValueStore.allKeys(transaction: transaction.asV2Read).compactMap {
|
|
return keyValueStore.getUInt($0, transaction: transaction.asV2Read)
|
|
})
|
|
}
|
|
|
|
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.")
|
|
}
|
|
}
|