mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
ac29354b08
* Remove the now unused setting to disable in-app notifications. * Move visible account settings into AccountSettings. Have ignored enableNotifications for now as that will require reworking the NotificationManager. * Allow different sounds to be selected for each account. * Move defaultNotificationTone into AccountSettings.
290 lines
14 KiB
Swift
290 lines
14 KiB
Swift
//
|
|
// Copyright 2026 Element Creations Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
@preconcurrency import AVFoundation
|
|
import Foundation
|
|
import UniformTypeIdentifiers
|
|
|
|
/// Manages notification tone selection, import, conversion, and deletion.
|
|
nonisolated struct NotificationToneManager: NotificationToneManagerProtocol {
|
|
@globalActor
|
|
actor ConversionActor {
|
|
static let shared = ConversionActor()
|
|
}
|
|
|
|
enum ManagerError: Error, Equatable {
|
|
/// The tone's file is not inside the user library directory and cannot be deleted.
|
|
case notACustomTone
|
|
|
|
/// The source file could not be accessed due to sandbox restrictions.
|
|
case couldNotAccessSandboxedResource
|
|
|
|
/// A tone with the same filename already exists in the library.
|
|
case fileAlreadyExists
|
|
/// An `AVAudioPCMBuffer` could not be allocated.
|
|
case bufferCreationFailed
|
|
}
|
|
|
|
private let userSettings: UserSettings
|
|
|
|
/// All default tones (system + Element X), sorted by name.
|
|
static let allDefaultAlerts: [NotificationTone] = (defaultSystemAlerts + defaultBundledAlerts).sorted()
|
|
|
|
/// The name the notification service uses to find the tone's file in the app bundle or Library/Sounds.
|
|
///
|
|
/// Note: Names with a subdirectory aren't documented for `UNNotificationSound(named:)` but do work.
|
|
static func soundName(for tone: NotificationTone) -> String {
|
|
switch tone.storageLocationRoot {
|
|
case .appBundle:
|
|
tone.filename
|
|
case .appLibrary:
|
|
"\(URL.customTonesDirectory.lastPathComponent)/\(tone.filename)"
|
|
case .system:
|
|
"\(URL.systemTonesDirectory.lastPathComponent)/\(tone.filename)"
|
|
}
|
|
}
|
|
|
|
/// Copies a system tone into Library/Sounds, as the system sounds directory isn't searched for notification sounds.
|
|
static func copySystemToneIfNeeded(_ tone: NotificationTone) throws {
|
|
guard tone.storageLocationRoot == .system else { return }
|
|
|
|
let copyLocation = URL.systemTonesDirectory.appending(component: tone.filename)
|
|
guard (try? copyLocation.checkResourceIsReachable()) != true else { return }
|
|
|
|
try FileManager.default.createDirectory(at: URL.systemTonesDirectory, withIntermediateDirectories: true)
|
|
try FileManager.default.copyItem(at: toneLocation(for: tone), to: copyLocation)
|
|
}
|
|
|
|
/// Creates the manager and ensures required library directories exist.
|
|
init(userSettings: UserSettings) {
|
|
self.userSettings = userSettings
|
|
|
|
do {
|
|
try FileManager.default.createDirectory(at: URL.customTonesDirectory, withIntermediateDirectories: true)
|
|
} catch {
|
|
// Don't crash on a recoverable file system error. The underlying problem (directory creation
|
|
// failing on launch, e.g. a background launch before the container is writable) is acknowledged
|
|
// and will be treated separately.
|
|
MXLog.error("Failed setting up tone manager directories: \(error)")
|
|
}
|
|
}
|
|
|
|
/// Sets the given tone as the active notification alert tone.
|
|
func setSelectedTone(_ alertTone: NotificationTone) throws -> URL {
|
|
try Self.copySystemToneIfNeeded(alertTone)
|
|
userSettings.account.selectedNotificationTone = alertTone
|
|
return Self.toneLocation(for: alertTone)
|
|
}
|
|
|
|
/// Returns all user-imported CAF tones from the library directory, sorted by name.
|
|
func customTones() -> [NotificationTone] {
|
|
let availableFiles = try? FileManager
|
|
.default
|
|
.contentsOfDirectory(at: URL.customTonesDirectory, includingPropertiesForKeys: nil)
|
|
|
|
return (availableFiles ?? [])
|
|
.compactMap {
|
|
let pathExtension = $0.pathExtension
|
|
guard UTType(filenameExtension: pathExtension) == UTType("com.apple.coreaudio-format") else { return nil }
|
|
|
|
return .createCustomUserSound(filename: $0.lastPathComponent)
|
|
}
|
|
.sorted()
|
|
}
|
|
|
|
/// Imports an audio file into the tone library, converting to CAF if the source is not already CAF.
|
|
/// - Returns: The URL of the imported file in the library.
|
|
@ConversionActor
|
|
@discardableResult
|
|
func addNewToneToLibrary(from sourceURL: URL) throws -> URL {
|
|
let baseName = sourceURL.deletingPathExtension().lastPathComponent
|
|
let outputURL = URL.customTonesDirectory.appending(component: baseName).appendingPathExtension("caf")
|
|
|
|
guard (try? outputURL.checkResourceIsReachable()) != true else {
|
|
throw ManagerError.fileAlreadyExists
|
|
}
|
|
|
|
if sourceURL.pathExtension.lowercased() == "caf" {
|
|
try FileManager.default.copyItem(at: sourceURL, to: outputURL)
|
|
} else {
|
|
try convertToCAF(from: sourceURL, to: outputURL)
|
|
}
|
|
|
|
return outputURL
|
|
}
|
|
|
|
/// Removes a user-imported tone from the library.
|
|
/// - Throws: `DeletionError.notACustomTone` if the tone is not stored in the library directory.
|
|
func deleteCustomTone(_ alertTone: NotificationTone) throws {
|
|
let toneLocation = Self.toneLocation(for: alertTone)
|
|
guard toneLocation.deletingLastPathComponent() == URL.customTonesDirectory else {
|
|
throw ManagerError.notACustomTone
|
|
}
|
|
|
|
try FileManager.default.removeItem(at: toneLocation)
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
@ConversionActor
|
|
private func convertToCAF(from sourceURL: URL, to destURL: URL) throws {
|
|
MXLog.info("Converting \(sourceURL.path(percentEncoded: false)) to caf")
|
|
let sourceFile = try AVAudioFile(forReading: sourceURL)
|
|
|
|
let outputSettings: [String: Any] = [
|
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
AVSampleRateKey: sourceFile.fileFormat.sampleRate,
|
|
AVNumberOfChannelsKey: sourceFile.fileFormat.channelCount
|
|
]
|
|
|
|
let tempURL = URL.temporaryDirectory.appending(component: destURL.lastPathComponent)
|
|
|
|
let destTempFile = try AVAudioFile(forWriting: tempURL, settings: outputSettings)
|
|
|
|
let frameCount: AVAudioFrameCount = 4096
|
|
guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: sourceFile.processingFormat, frameCapacity: frameCount) else {
|
|
MXLog.error("Error creating pcm conversion buffer: \(sourceFile.processingFormat) \(frameCount)")
|
|
throw ManagerError.bufferCreationFailed
|
|
}
|
|
|
|
do {
|
|
repeat {
|
|
try sourceFile.read(into: pcmBuffer)
|
|
|
|
guard pcmBuffer.frameLength > 0 else { break }
|
|
|
|
try destTempFile.write(from: pcmBuffer)
|
|
} while pcmBuffer.frameLength > 0 && sourceFile.framePosition < sourceFile.length
|
|
} catch {
|
|
let nsError = error as NSError
|
|
guard
|
|
// the framePosition < sourceFile.length SHOULD stop this from throwing, but as
|
|
// a defensive fallback, this just means that it reached/read past EOF
|
|
nsError.code == 0,
|
|
nsError.domain == "Foundation._GenericObjCError"
|
|
else { throw error }
|
|
}
|
|
destTempFile.close()
|
|
|
|
try FileManager.default.moveItem(at: tempURL, to: destURL)
|
|
MXLog.info("Converted \(sourceURL.path(percentEncoded: false)) to caf")
|
|
}
|
|
|
|
/// Pre-defined iOS system tones available for selection, sorted by name.
|
|
private static let defaultSystemAlerts: [NotificationTone] = [
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemTriToneIos,
|
|
filename: "sms-received1.caf"),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemChimeIos,
|
|
filename: "sms-received2.caf"),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemGlassIos,
|
|
filename: "sms-received3.caf"),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemHornIos,
|
|
filename: "sms-received4.caf"),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemBellIos,
|
|
filename: "sms-received5.caf"),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemElectronicIos,
|
|
filename: "sms-received6.caf"),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemAlertIos,
|
|
filename: "alarm.caf"),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemBloomIos,
|
|
filename: "Bloom.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemCalypsoIos,
|
|
filename: "Calypso.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemAnticipateIos,
|
|
filename: "Anticipate.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemChooChooIos,
|
|
filename: "Choo_Choo.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemDescentIos,
|
|
filename: "Descent.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemFanfareIos,
|
|
filename: "Fanfare.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemLadderIos,
|
|
filename: "Ladder.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemMinuetIos,
|
|
filename: "Minuet.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemNewsFlashIos,
|
|
filename: "News_Flash.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemNoirIos,
|
|
filename: "Noir.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemSherwoodForestIos,
|
|
filename: "Sherwood_Forest.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemSpellIos,
|
|
filename: "Spell.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemSuspenseIos,
|
|
filename: "Suspense.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemTelegraphIos,
|
|
filename: "Telegraph.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemTiptoesIos,
|
|
filename: "Tiptoes.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemTypewritersIos,
|
|
filename: "Typewriters.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemUpdateIos,
|
|
filename: "Update.caf",
|
|
systemSoundsSubdirectory: ["New"]),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemSwishIos,
|
|
filename: "Swish.caf"),
|
|
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemTweetIos,
|
|
filename: "tweet_sent.caf")
|
|
]
|
|
.compactMap { (alertTone: NotificationTone) -> NotificationTone? in
|
|
let toneLocation = toneLocation(for: alertTone)
|
|
guard (try? toneLocation.checkResourceIsReachable()) == true else {
|
|
return nil
|
|
}
|
|
return alertTone
|
|
}
|
|
.sorted()
|
|
|
|
/// Bundled tones available for selection, sorted by name.
|
|
private static let defaultBundledAlerts: [NotificationTone] = [
|
|
.default,
|
|
.createBundledSound(label: L10n.screenNotificationSettingsSoundElementFade,
|
|
filename: "sound_01.caf")
|
|
].sorted()
|
|
|
|
private static let systemLocation = URL.systemSoundsDirectory
|
|
|
|
private static let bundledLocation: URL = {
|
|
guard let url = Bundle.app.resourceURL else {
|
|
fatalError("The app is seriously corrupt if resourceURL is missing.")
|
|
}
|
|
return url
|
|
}()
|
|
|
|
private static func toneLocation(for tone: NotificationTone) -> URL {
|
|
let root: URL
|
|
switch tone.storageLocationRoot {
|
|
case .system:
|
|
root = Self.systemLocation
|
|
case .appBundle:
|
|
root = Self.bundledLocation
|
|
case .appLibrary:
|
|
root = URL.customTonesDirectory
|
|
}
|
|
|
|
return tone.relativePath.reduce(root) {
|
|
$0.appending(component: $1)
|
|
}
|
|
}
|
|
}
|