Files
element-x-ios/ElementX/Sources/Services/AlertTones/NotificationToneManager.swift
T
Michael dd0de4d7b0 Custom Notification Tones (#5527)
* added foundation for custom notifications

* feat: add localizations for tone names

also, if available, include the pro sound in the element x sound list

* use a static file location to store the notification tone

and update the file at that location when selected

* if there's a problem previewing or setting the alert tone, toast the user with the error

* rename tone directory

* create NotificationToneManager to handle tone business logic

* conform to project formatting

* assign custom sound to actual notifications

* dry up notification generation in the nse

* add compilation condition for including notification alert tone and remove from share extension

* swift format had some opinions

* flatten the notification tone ui, removing system/elementx designations

* fix stored file url breaking due to sandbox path changes

* tone manager can import custom tones and provide a list of imported tones

* ui support for custom tones

* can delete custom imported tones

* sort the custom tones

* add additional preset sounds

* can choose caf files for custom sounds and fix location persistence issues

* store sound locations with relative links

this is a better solution than storing bookmarks since bookmark resolution can be variable and differ from freshly derived urls

* add docs for new notification features

* run tone conversion in background

* resolved several ai found issues

* added protocol and mocks for notification tone manager

* added tests for notification settings screen vm interactions with the tone manager

* add tests for notification tone manager

* rename element tone names per feedback

* move custom tone option to the top of the notification tone list

* rename Tone to Sound for consistency with Android

* remove additional bundled tones

* restore accidentally deleted Element Default sound file

* system alert tones are filtered by what's available on the system prior to finalizing the collection

* Add a couple more caf file conversion log statements

* Simplify the notification sound conversion logic

* Update notification sound strings in sync with Android

* update xcodeproj after rebase

* remove conditional compilation and include NotificationAlertSound in share extension

* Use AudioPlayer instead of custom tone previewer and remove the previewer

* rename NotificationTone

* Consolidate and move errors to top of NotificationToneManager

* tone manager no longer depends on userinterfacecontroller

* Move static Tone properties to NotificationToneManager

* resolve view model initializer feedback

* update xcodeproj

* fatalerror when tone manager cannot init

* Remove banner icon on alert tone error

* fixup review feedback

* Moved URL handling to NotificationToneManager
2026-05-20 15:40:34 +03:00

302 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.
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
/// `AVAudioConverter` could not be initialised for the given format pair.
case converterSetupFailed
/// A tone with the same filename already exists in the library.
case fileAlreadyExists
/// An `AVAudioPCMBuffer` could not be allocated.
case bufferCreationFailed
}
private let appSettings: AppSettings
#if IS_MAIN_APP
/// The default Element X bundled message tone.
static let defaultElementXMessageTone: NotificationTone = .createBundledSound(label: UntranslatedL10n.screenNotificationSettingsSoundElementDefault,
filename: "message.caf")
/// All default tones (system + Element X), sorted by name.
static let allDefaultAlerts: [NotificationTone] = (defaultSystemAlerts + defaultElementXAlerts).sorted()
#endif
/// Filename of the active tone file used by the notification service.
static let selectedToneFilename = "currentAlert.caf"
/// Directory where user-imported custom tones are stored.
static let libraryLocation = URL.libraryDirectory.appending(components: "Sounds", "AvailableSounds", directoryHint: .isDirectory)
/// Creates the manager and ensures required library directories exist.
init(appSettings: AppSettings) {
self.appSettings = appSettings
do {
try FileManager.default.createDirectory(at: NotificationToneManager.libraryLocation, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: Self.selectedToneLocation.deletingLastPathComponent(), withIntermediateDirectories: true)
} catch {
fatalError("Catastrophic error setting up tone manager: \(error)")
}
}
/// Sets the given tone as the active notification alert tone.
///
/// Copies the tone's audio file to `selectedToneLocation` and persists the selection in app settings.
func setSelectedTone(_ alertTone: NotificationTone) throws -> URL {
do {
try? FileManager.default.removeItem(at: Self.selectedToneLocation)
let toneLocation = Self.toneLocation(for: alertTone)
try FileManager.default.copyItem(at: toneLocation, to: Self.selectedToneLocation)
appSettings.selectedNotificationTone = alertTone
return Self.selectedToneLocation
} catch {
if (try? Self.selectedToneLocation.checkResourceIsReachable()) != true {
// make sure the selected tone is reset if there's no custom tone present
appSettings.selectedNotificationTone = nil
}
throw error
}
}
/// Returns all user-imported CAF tones from the library directory, sorted by name.
func customTones() -> [NotificationTone] {
let availableFiles = try? FileManager
.default
.contentsOfDirectory(at: NotificationToneManager.libraryLocation, 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 = NotificationToneManager.libraryLocation.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() == NotificationToneManager.libraryLocation 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")
}
/// File URL of the active tone copied/linked for use by the system.
private static let selectedToneLocation = NotificationToneManager.libraryLocation.deletingLastPathComponent().appending(component: selectedToneFilename)
#if IS_MAIN_APP
/// Pre-defined iOS system tones available for selection, sorted by name.
private static let defaultSystemAlerts: [NotificationTone] = [
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemTriTone,
filename: "sms-received1.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemChime,
filename: "sms-received2.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemGlass,
filename: "sms-received3.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemHorn,
filename: "sms-received4.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemBell,
filename: "sms-received5.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemElectronic,
filename: "sms-received6.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemAlert,
filename: "alarm.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemBloom,
filename: "Bloom.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemCalypso,
filename: "Calypso.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemAnticipate,
filename: "Anticipate.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemChooChoo,
filename: "Choo_Choo.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemDescent,
filename: "Descent.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemFanfare,
filename: "Fanfare.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemLadder,
filename: "Ladder.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemMinuet,
filename: "Minuet.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemNewsFlash,
filename: "News_Flash.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemNoir,
filename: "Noir.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemSherwoodForest,
filename: "Sherwood_Forest.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemSpell,
filename: "Spell.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemSuspense,
filename: "Suspense.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemTelegraph,
filename: "Telegraph.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemTiptoes,
filename: "Tiptoes.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemTypewriters,
filename: "Typewriters.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemUpdate,
filename: "Update.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemSwish,
filename: "Swish.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemTweet,
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()
/// Element X bundled tones available for selection, sorted by name.
private static let defaultElementXAlerts: [NotificationTone] = [
defaultElementXMessageTone,
.createBundledSound(label: UntranslatedL10n.screenNotificationSettingsSoundElementFade,
filename: "sound_01.caf")
].sorted()
#endif
private static let systemLocation = {
let systemRoot: URL
if let simulatorRoot = ProcessInfo.processInfo.environment["SIMULATOR_ROOT"] {
systemRoot = URL(filePath: simulatorRoot)
} else {
systemRoot = URL(filePath: "/")
}
return systemRoot.appending(components: "System", "Library", "Audio", "UISounds", directoryHint: .isDirectory)
}()
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 = Self.libraryLocation
}
return tone.relativePath.reduce(root) {
$0.appending(component: $1)
}
}
}