Files
element-x-ios/ElementX/Sources/Services/AlertTones/NotificationToneManager.swift
T
Doug ac29354b08 Move any visible account settings into AccountSettings. (#6308)
* 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.
2026-10-13 15:21:13 +01:00

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