TM-SGNL-iOS/SignalServiceKit/Environment/Sounds.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

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.")
}
}