mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
fccad5fc30
Remove some redundant injections when passed alongside a user session too. # Conflicts: # ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift # ElementX/Sources/Screens/Timeline/TimelineViewModel.swift
291 lines
14 KiB
Swift
291 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
|
|
|
|
/// The default Element X bundled message tone.
|
|
static let defaultElementXMessageTone: NotificationTone = .createBundledSound(label: L10n.screenNotificationSettingsSoundElementDefault,
|
|
filename: "message.caf")
|
|
|
|
/// All default tones (system + Element X), sorted by name.
|
|
static let allDefaultAlerts: [NotificationTone] = (defaultSystemAlerts + defaultElementXAlerts).sorted()
|
|
|
|
/// Filename of the active tone file used by the notification service.
|
|
static let selectedToneFilename = "currentAlert.caf"
|
|
|
|
/// Directory where user-imported custom tones are stored.
|
|
nonisolated static let libraryLocation = URL.libraryDirectory.appending(components: "Sounds", "AvailableSounds", directoryHint: .isDirectory)
|
|
|
|
/// Creates the manager and ensures required library directories exist.
|
|
init(userSettings: UserSettings) {
|
|
self.userSettings = userSettings
|
|
|
|
do {
|
|
try FileManager.default.createDirectory(at: NotificationToneManager.libraryLocation, withIntermediateDirectories: true)
|
|
try FileManager.default.createDirectory(at: Self.selectedToneLocation.deletingLastPathComponent(), 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.
|
|
///
|
|
/// 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)
|
|
userSettings.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
|
|
userSettings.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)
|
|
|
|
/// 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()
|
|
|
|
/// Element X bundled tones available for selection, sorted by name.
|
|
private static let defaultElementXAlerts: [NotificationTone] = [
|
|
defaultElementXMessageTone,
|
|
.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 = Self.libraryLocation
|
|
}
|
|
|
|
return tone.relativePath.reduce(root) {
|
|
$0.appending(component: $1)
|
|
}
|
|
}
|
|
}
|