mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
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
This commit is contained in:
@@ -16,8 +16,8 @@ import SwiftUI
|
||||
/// Common settings between app and NSE
|
||||
protocol CommonSettingsProtocol: AnyObject {
|
||||
var lastNotificationBootTime: TimeInterval? { get set }
|
||||
var notificationSoundName: RemotePreference<UNNotificationSoundName> { get }
|
||||
|
||||
var selectedNotificationTone: NotificationTone? { get set }
|
||||
|
||||
var logLevel: LogLevel { get }
|
||||
var traceLogPacks: Set<TraceLogPack> { get }
|
||||
var bugReportRageshakeURL: RemotePreference<RageshakeConfiguration> { get }
|
||||
@@ -54,6 +54,7 @@ final class AppSettings {
|
||||
case enableInAppNotifications
|
||||
case pusherProfileTag
|
||||
case lastNotificationBootTime
|
||||
case selectedNotificationTone
|
||||
case logLevel
|
||||
case traceLogPacks
|
||||
case viewSourceEnabled
|
||||
@@ -299,10 +300,11 @@ final class AppSettings {
|
||||
/// The device's last boot time as recorded by the NSE.
|
||||
@UserPreference(key: UserDefaultsKeys.lastNotificationBootTime, storageType: .userDefaults(store))
|
||||
var lastNotificationBootTime: TimeInterval?
|
||||
|
||||
/// The name of sound played when delivering noisy notifications.
|
||||
var notificationSoundName: RemotePreference<UNNotificationSoundName> = .init(.init("message.caf"))
|
||||
|
||||
|
||||
/// The sound played when delivering noisy notifications. If nil, use the ElementX default
|
||||
@UserPreference(key: UserDefaultsKeys.selectedNotificationTone, defaultValue: nil, storageType: .userDefaults(store))
|
||||
var selectedNotificationTone: NotificationTone?
|
||||
|
||||
// MARK: - Logging
|
||||
|
||||
@UserPreference(key: UserDefaultsKeys.logLevel, defaultValue: LogLevel.info, storageType: .userDefaults(store))
|
||||
|
||||
@@ -1318,6 +1318,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
let parameters = NotificationSettingsScreenCoordinatorParameters(navigationStackCoordinator: stackCoordinator,
|
||||
userSession: userSession,
|
||||
userNotificationCenter: UNUserNotificationCenter.current(),
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
isModallyPresented: true,
|
||||
appSettings: flowParameters.appSettings)
|
||||
let coordinator = NotificationSettingsScreenCoordinator(parameters: parameters)
|
||||
|
||||
@@ -243,6 +243,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
|
||||
let notificationParameters = NotificationSettingsScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
|
||||
userSession: flowParameters.userSession,
|
||||
userNotificationCenter: UNUserNotificationCenter.current(),
|
||||
userIndicatorController: flowParameters.userIndicatorController,
|
||||
isModallyPresented: false,
|
||||
appSettings: flowParameters.appSettings)
|
||||
let coordinator = NotificationSettingsScreenCoordinator(parameters: notificationParameters)
|
||||
|
||||
@@ -18,6 +18,74 @@ internal enum UntranslatedL10n {
|
||||
internal static var cryptoHistorySharingConfirmStartChatDialogContent: String { return UntranslatedL10n.tr("Untranslated", "crypto_history_sharing_confirm_start_chat_dialog_content") }
|
||||
/// Start a chat with this new contact?
|
||||
internal static var cryptoHistorySharingConfirmStartChatDialogTitle: String { return UntranslatedL10n.tr("Untranslated", "crypto_history_sharing_confirm_start_chat_dialog_title") }
|
||||
/// Custom sound...
|
||||
internal static var screenNotificationSettingsSoundCustomSoundButtonTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_custom_sound_button_title") }
|
||||
/// Error deleting file
|
||||
internal static var screenNotificationSettingsSoundDeleteSoundErrorTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_delete_sound_error_title") }
|
||||
/// Element Default
|
||||
internal static var screenNotificationSettingsSoundElementDefault: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_element_default") }
|
||||
/// Element Fade
|
||||
internal static var screenNotificationSettingsSoundElementFade: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_element_fade") }
|
||||
/// Error importing file
|
||||
internal static var screenNotificationSettingsSoundImportSoundErrorTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_import_sound_error_title") }
|
||||
/// Problem previewing alert sound
|
||||
internal static var screenNotificationSettingsSoundPreviewSoundErrorTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_preview_sound_error_title") }
|
||||
/// Sound
|
||||
internal static var screenNotificationSettingsSoundSectionTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_section_title") }
|
||||
/// Problem setting alert sound
|
||||
internal static var screenNotificationSettingsSoundSetSoundErrorTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_set_sound_error_title") }
|
||||
/// Alert
|
||||
internal static var screenNotificationSettingsSoundSystemAlert: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_alert") }
|
||||
/// Anticipate
|
||||
internal static var screenNotificationSettingsSoundSystemAnticipate: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_anticipate") }
|
||||
/// Bell
|
||||
internal static var screenNotificationSettingsSoundSystemBell: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_bell") }
|
||||
/// Bloom
|
||||
internal static var screenNotificationSettingsSoundSystemBloom: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_bloom") }
|
||||
/// Calypso
|
||||
internal static var screenNotificationSettingsSoundSystemCalypso: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_calypso") }
|
||||
/// Chime
|
||||
internal static var screenNotificationSettingsSoundSystemChime: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_chime") }
|
||||
/// Choo Choo
|
||||
internal static var screenNotificationSettingsSoundSystemChooChoo: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_choo_choo") }
|
||||
/// Descent
|
||||
internal static var screenNotificationSettingsSoundSystemDescent: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_descent") }
|
||||
/// Electronic
|
||||
internal static var screenNotificationSettingsSoundSystemElectronic: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_electronic") }
|
||||
/// Fanfare
|
||||
internal static var screenNotificationSettingsSoundSystemFanfare: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_fanfare") }
|
||||
/// Glass
|
||||
internal static var screenNotificationSettingsSoundSystemGlass: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_glass") }
|
||||
/// Horn
|
||||
internal static var screenNotificationSettingsSoundSystemHorn: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_horn") }
|
||||
/// Ladder
|
||||
internal static var screenNotificationSettingsSoundSystemLadder: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_ladder") }
|
||||
/// Minuet
|
||||
internal static var screenNotificationSettingsSoundSystemMinuet: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_minuet") }
|
||||
/// News Flash
|
||||
internal static var screenNotificationSettingsSoundSystemNewsFlash: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_news_flash") }
|
||||
/// Noir
|
||||
internal static var screenNotificationSettingsSoundSystemNoir: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_noir") }
|
||||
/// Sherwood Forest
|
||||
internal static var screenNotificationSettingsSoundSystemSherwoodForest: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_sherwood_forest") }
|
||||
/// Spell
|
||||
internal static var screenNotificationSettingsSoundSystemSpell: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_spell") }
|
||||
/// Suspense
|
||||
internal static var screenNotificationSettingsSoundSystemSuspense: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_suspense") }
|
||||
/// Swish
|
||||
internal static var screenNotificationSettingsSoundSystemSwish: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_swish") }
|
||||
/// Telegraph
|
||||
internal static var screenNotificationSettingsSoundSystemTelegraph: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_telegraph") }
|
||||
/// Tiptoes
|
||||
internal static var screenNotificationSettingsSoundSystemTiptoes: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_tiptoes") }
|
||||
/// Tri-tone
|
||||
internal static var screenNotificationSettingsSoundSystemTriTone: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_tri_tone") }
|
||||
/// Tweet
|
||||
internal static var screenNotificationSettingsSoundSystemTweet: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_tweet") }
|
||||
/// Typewriters
|
||||
internal static var screenNotificationSettingsSoundSystemTypewriters: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_typewriters") }
|
||||
/// Update
|
||||
internal static var screenNotificationSettingsSoundSystemUpdate: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_update") }
|
||||
/// Clear all data currently stored on this device?
|
||||
/// Sign in again to access your account data and messages.
|
||||
internal static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") }
|
||||
|
||||
@@ -15382,6 +15382,269 @@ class NotificationSettingsProxyMock: NotificationSettingsProxyProtocol, @uncheck
|
||||
}
|
||||
}
|
||||
}
|
||||
class NotificationToneManagerMock: NotificationToneManagerProtocol, @unchecked Sendable {
|
||||
|
||||
//MARK: - setSelectedTone
|
||||
|
||||
var setSelectedToneThrowableError: Error?
|
||||
var setSelectedToneUnderlyingCallsCount = 0
|
||||
var setSelectedToneCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return setSelectedToneUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = setSelectedToneUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
setSelectedToneUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
setSelectedToneUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var setSelectedToneCalled: Bool {
|
||||
return setSelectedToneCallsCount > 0
|
||||
}
|
||||
var setSelectedToneReceivedAlertTone: NotificationTone?
|
||||
var setSelectedToneReceivedInvocations: [NotificationTone] = []
|
||||
|
||||
var setSelectedToneUnderlyingReturnValue: URL!
|
||||
var setSelectedToneReturnValue: URL! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return setSelectedToneUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: URL? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = setSelectedToneUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
setSelectedToneUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
setSelectedToneUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var setSelectedToneClosure: ((NotificationTone) throws -> URL)?
|
||||
|
||||
@discardableResult
|
||||
func setSelectedTone(_ alertTone: NotificationTone) throws -> URL {
|
||||
if let error = setSelectedToneThrowableError {
|
||||
throw error
|
||||
}
|
||||
setSelectedToneCallsCount += 1
|
||||
setSelectedToneReceivedAlertTone = alertTone
|
||||
DispatchQueue.main.async {
|
||||
self.setSelectedToneReceivedInvocations.append(alertTone)
|
||||
}
|
||||
if let setSelectedToneClosure = setSelectedToneClosure {
|
||||
return try setSelectedToneClosure(alertTone)
|
||||
} else {
|
||||
return setSelectedToneReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - customTones
|
||||
|
||||
var customTonesUnderlyingCallsCount = 0
|
||||
var customTonesCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return customTonesUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = customTonesUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
customTonesUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
customTonesUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var customTonesCalled: Bool {
|
||||
return customTonesCallsCount > 0
|
||||
}
|
||||
|
||||
var customTonesUnderlyingReturnValue: [NotificationTone]!
|
||||
var customTonesReturnValue: [NotificationTone]! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return customTonesUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: [NotificationTone]? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = customTonesUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
customTonesUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
customTonesUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var customTonesClosure: (() -> [NotificationTone])?
|
||||
|
||||
func customTones() -> [NotificationTone] {
|
||||
customTonesCallsCount += 1
|
||||
if let customTonesClosure = customTonesClosure {
|
||||
return customTonesClosure()
|
||||
} else {
|
||||
return customTonesReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - deleteCustomTone
|
||||
|
||||
var deleteCustomToneThrowableError: Error?
|
||||
var deleteCustomToneUnderlyingCallsCount = 0
|
||||
var deleteCustomToneCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return deleteCustomToneUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = deleteCustomToneUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
deleteCustomToneUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
deleteCustomToneUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var deleteCustomToneCalled: Bool {
|
||||
return deleteCustomToneCallsCount > 0
|
||||
}
|
||||
var deleteCustomToneReceivedAlertTone: NotificationTone?
|
||||
var deleteCustomToneReceivedInvocations: [NotificationTone] = []
|
||||
var deleteCustomToneClosure: ((NotificationTone) throws -> Void)?
|
||||
|
||||
func deleteCustomTone(_ alertTone: NotificationTone) throws {
|
||||
if let error = deleteCustomToneThrowableError {
|
||||
throw error
|
||||
}
|
||||
deleteCustomToneCallsCount += 1
|
||||
deleteCustomToneReceivedAlertTone = alertTone
|
||||
DispatchQueue.main.async {
|
||||
self.deleteCustomToneReceivedInvocations.append(alertTone)
|
||||
}
|
||||
try deleteCustomToneClosure?(alertTone)
|
||||
}
|
||||
//MARK: - addNewToneToLibrary
|
||||
|
||||
var addNewToneToLibraryFromThrowableError: Error?
|
||||
var addNewToneToLibraryFromUnderlyingCallsCount = 0
|
||||
var addNewToneToLibraryFromCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return addNewToneToLibraryFromUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = addNewToneToLibraryFromUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
addNewToneToLibraryFromUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
addNewToneToLibraryFromUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var addNewToneToLibraryFromCalled: Bool {
|
||||
return addNewToneToLibraryFromCallsCount > 0
|
||||
}
|
||||
var addNewToneToLibraryFromReceivedSourceURL: URL?
|
||||
var addNewToneToLibraryFromReceivedInvocations: [URL] = []
|
||||
|
||||
var addNewToneToLibraryFromUnderlyingReturnValue: URL!
|
||||
var addNewToneToLibraryFromReturnValue: URL! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return addNewToneToLibraryFromUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: URL? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = addNewToneToLibraryFromUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
addNewToneToLibraryFromUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
addNewToneToLibraryFromUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var addNewToneToLibraryFromClosure: ((URL) throws -> URL)?
|
||||
|
||||
@NotificationToneManager.ConversionActor
|
||||
@discardableResult
|
||||
func addNewToneToLibrary(from sourceURL: URL) throws -> URL {
|
||||
if let error = addNewToneToLibraryFromThrowableError {
|
||||
throw error
|
||||
}
|
||||
addNewToneToLibraryFromCallsCount += 1
|
||||
addNewToneToLibraryFromReceivedSourceURL = sourceURL
|
||||
DispatchQueue.main.async {
|
||||
self.addNewToneToLibraryFromReceivedInvocations.append(sourceURL)
|
||||
}
|
||||
if let addNewToneToLibraryFromClosure = addNewToneToLibraryFromClosure {
|
||||
return try addNewToneToLibraryFromClosure(sourceURL)
|
||||
} else {
|
||||
return addNewToneToLibraryFromReturnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
class OrientationManagerMock: OrientationManagerProtocol, @unchecked Sendable {
|
||||
|
||||
//MARK: - setOrientation
|
||||
|
||||
+4
-1
@@ -13,6 +13,7 @@ struct NotificationSettingsScreenCoordinatorParameters {
|
||||
weak var navigationStackCoordinator: NavigationStackCoordinator?
|
||||
let userSession: UserSessionProtocol
|
||||
let userNotificationCenter: UserNotificationCenterProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
let isModallyPresented: Bool
|
||||
let appSettings: AppSettings
|
||||
}
|
||||
@@ -37,10 +38,12 @@ final class NotificationSettingsScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
init(parameters: NotificationSettingsScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
|
||||
viewModel = NotificationSettingsScreenViewModel(appSettings: parameters.appSettings,
|
||||
userNotificationCenter: parameters.userNotificationCenter,
|
||||
notificationToneManager: NotificationToneManager(appSettings: parameters.appSettings),
|
||||
notificationSettingsProxy: parameters.userSession.clientProxy.notificationSettings,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
isModallyPresented: parameters.isModallyPresented)
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -29,6 +29,8 @@ struct NotificationSettingsScreenViewState: BindableState {
|
||||
|
||||
var settings: NotificationSettingsScreenSettings?
|
||||
var applyingChange = false
|
||||
var selectedAlertTone: NotificationTone
|
||||
var availableCustomTones: [NotificationTone]
|
||||
}
|
||||
|
||||
struct NotificationSettingsScreenViewStateBindings {
|
||||
@@ -37,6 +39,8 @@ struct NotificationSettingsScreenViewStateBindings {
|
||||
var callsEnabled = false
|
||||
var invitationsEnabled = false
|
||||
var alertInfo: AlertInfo<NotificationSettingsScreenErrorType>?
|
||||
var shouldShowAlertSounds = false
|
||||
var shouldShowCustomAlertTonePicker = false
|
||||
}
|
||||
|
||||
struct NotificationSettingsScreenSettings {
|
||||
@@ -89,6 +93,9 @@ enum NotificationSettingsScreenViewAction {
|
||||
case invitationsChanged
|
||||
case close
|
||||
case fixConfigurationMismatchTapped
|
||||
case selectAlertTone(NotificationTone)
|
||||
case addedCustomAlertTone(Result<URL, Error>)
|
||||
case deleteCustomAlertTones([NotificationTone])
|
||||
}
|
||||
|
||||
enum NotificationSettingsScreenErrorType: Hashable {
|
||||
|
||||
+93
-4
@@ -16,28 +16,60 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
|
||||
private let appSettings: AppSettings
|
||||
private let userNotificationCenter: UserNotificationCenterProtocol
|
||||
private let notificationSettingsProxy: NotificationSettingsProxyProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
// periphery:ignore - cancellable tasks get cancelled when reassigned
|
||||
@CancellableTask private var fetchSettingsTask: Task<Void, Error>?
|
||||
|
||||
private let notificationTonePreviewer: AudioPlayerProtocol
|
||||
private let notificationToneManager: NotificationToneManagerProtocol
|
||||
|
||||
var actions: AnyPublisher<NotificationSettingsScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(appSettings: AppSettings, userNotificationCenter: UserNotificationCenterProtocol, notificationSettingsProxy: NotificationSettingsProxyProtocol, isModallyPresented: Bool) {
|
||||
init(appSettings: AppSettings,
|
||||
userNotificationCenter: UserNotificationCenterProtocol,
|
||||
notificationToneManager: NotificationToneManagerProtocol,
|
||||
notificationSettingsProxy: NotificationSettingsProxyProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
isModallyPresented: Bool) {
|
||||
self.appSettings = appSettings
|
||||
self.userNotificationCenter = userNotificationCenter
|
||||
self.notificationSettingsProxy = notificationSettingsProxy
|
||||
self.userIndicatorController = userIndicatorController
|
||||
notificationTonePreviewer = AudioPlayer()
|
||||
self.notificationToneManager = notificationToneManager
|
||||
|
||||
let bindings = NotificationSettingsScreenViewStateBindings(enableNotifications: appSettings.enableNotifications)
|
||||
super.init(initialViewState: NotificationSettingsScreenViewState(bindings: bindings, isModallyPresented: isModallyPresented))
|
||||
|
||||
super.init(initialViewState: NotificationSettingsScreenViewState(bindings: bindings,
|
||||
isModallyPresented: isModallyPresented,
|
||||
selectedAlertTone: appSettings.selectedNotificationTone ?? NotificationToneManager.defaultElementXMessageTone,
|
||||
availableCustomTones: notificationToneManager.customTones()))
|
||||
|
||||
// Listen for changes to AppSettings.
|
||||
appSettings.$enableNotifications
|
||||
.weakAssign(to: \.state.bindings.enableNotifications, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
appSettings.$selectedNotificationTone
|
||||
.map { $0 ?? NotificationToneManager.defaultElementXMessageTone }
|
||||
.weakAssign(to: \.state.selectedAlertTone, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
setupDidBecomeActiveSubscription()
|
||||
setupNotificationSettingsSubscription()
|
||||
|
||||
notificationTonePreviewer.actions
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { action in
|
||||
guard case .didFailWithError(let error) = action else {
|
||||
return
|
||||
}
|
||||
let userIndicator = UserIndicator(type: .toast,
|
||||
title: UntranslatedL10n.screenNotificationSettingsSoundPreviewSoundErrorTitle)
|
||||
userIndicatorController.submitIndicator(userIndicator)
|
||||
MXLog.error("Error previewing alert tone: \(error)")
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func fetchInitialContent() {
|
||||
@@ -73,6 +105,14 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
|
||||
actionsSubject.send(.close)
|
||||
case .fixConfigurationMismatchTapped:
|
||||
Task { await fixConfigurationMismatch() }
|
||||
case .selectAlertTone(let alertTone):
|
||||
setSelectedTone(alertTone)
|
||||
case .addedCustomAlertTone(let result):
|
||||
Task {
|
||||
await self.addCustomAlertTone(from: result)
|
||||
}
|
||||
case .deleteCustomAlertTones(let tones):
|
||||
deleteAlertTones(tones)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +260,55 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
|
||||
}
|
||||
state.applyingChange = false
|
||||
}
|
||||
|
||||
/// Imports the audio file at the given sandboxed URL into the tone library, refreshing the available custom tones on success.
|
||||
private func addCustomAlertTone(from urlResult: Result<URL, Error>) async {
|
||||
do {
|
||||
let url = try urlResult.get()
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
throw NotificationToneManager.ManagerError.couldNotAccessSandboxedResource
|
||||
}
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
try await notificationToneManager.addNewToneToLibrary(from: url)
|
||||
state.availableCustomTones = notificationToneManager.customTones()
|
||||
} catch {
|
||||
MXLog.error("Error importing custom tone url: \(error)")
|
||||
userIndicatorController.submitIndicator(.init(type: .toast,
|
||||
title: UntranslatedL10n.screenNotificationSettingsSoundImportSoundErrorTitle))
|
||||
}
|
||||
}
|
||||
|
||||
private func setSelectedTone(_ alertTone: NotificationTone) {
|
||||
do {
|
||||
let toneLocation = try notificationToneManager.setSelectedTone(alertTone)
|
||||
notificationTonePreviewer.load(sourceURL: toneLocation, playbackURL: toneLocation, autoplay: true)
|
||||
MXLog.info("Successfully set selected tone: \(alertTone.label)")
|
||||
} catch {
|
||||
let userIndicator = UserIndicator(type: .toast,
|
||||
title: UntranslatedL10n.screenNotificationSettingsSoundSetSoundErrorTitle)
|
||||
userIndicatorController.submitIndicator(userIndicator)
|
||||
MXLog.error("Error setting selected alert tone to designated location in filesystem: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the given tones from the library. If the active tone is deleted, the selection resets to the default.
|
||||
private func deleteAlertTones(_ tones: [NotificationTone]) {
|
||||
for tone in tones {
|
||||
do {
|
||||
try notificationToneManager.deleteCustomTone(tone)
|
||||
|
||||
if tone == state.selectedAlertTone {
|
||||
appSettings.selectedNotificationTone = nil
|
||||
}
|
||||
} catch {
|
||||
MXLog.error("Error deleting alert tone \(tone.label): \(error)")
|
||||
userIndicatorController.submitIndicator(.init(type: .toast,
|
||||
title: UntranslatedL10n.screenNotificationSettingsSoundDeleteSoundErrorTitle))
|
||||
}
|
||||
}
|
||||
state.availableCustomTones = notificationToneManager.customTones()
|
||||
MXLog.info("Successfully deleted custom tone(s): \(tones.map(\.label))")
|
||||
}
|
||||
}
|
||||
|
||||
extension UNUserNotificationCenter {
|
||||
|
||||
+68
-1
@@ -8,6 +8,7 @@
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct NotificationSettingsScreen: View {
|
||||
@Bindable var context: NotificationSettingsScreenViewModel.Context
|
||||
@@ -37,6 +38,8 @@ struct NotificationSettingsScreen: View {
|
||||
if context.viewState.settings?.invitationsEnabled != nil {
|
||||
additionalSettingsSection
|
||||
}
|
||||
|
||||
soundSelectionSection
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,7 +171,67 @@ struct NotificationSettingsScreen: View {
|
||||
.compoundListSectionHeader()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var soundSelectionSection: some View {
|
||||
Section {
|
||||
DisclosureGroup(context.viewState.selectedAlertTone.label, isExpanded: $context.shouldShowAlertSounds) {
|
||||
customSoundSelectionSection
|
||||
}
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.listRowBackground(Color.compound.bgCanvasDefaultLevel1)
|
||||
.listRowSeparatorTint(ListRowColor.separatorTint)
|
||||
} header: {
|
||||
Text(UntranslatedL10n.screenNotificationSettingsSoundSectionTitle)
|
||||
.compoundListSectionHeader()
|
||||
}
|
||||
|
||||
Section(isExpanded: $context.shouldShowAlertSounds) {
|
||||
presetSoundSelectionSection
|
||||
} header: { }
|
||||
}
|
||||
|
||||
private var presetSoundSelectionSection: some View {
|
||||
ForEach(NotificationToneManager.allDefaultAlerts, id: \.filename) { alertTone in
|
||||
ListRow(label: .plain(title: alertTone.label),
|
||||
kind: .selection(isSelected: context.viewState.selectedAlertTone == alertTone) {
|
||||
context.send(viewAction: .selectAlertTone(alertTone))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var customSoundSelectionSection: some View {
|
||||
ForEach(context.viewState.availableCustomTones, id: \.filename) { alertTone in
|
||||
ListRow(label: .plain(title: alertTone.label),
|
||||
kind: .selection(isSelected: context.viewState.selectedAlertTone == alertTone) {
|
||||
context.send(viewAction: .selectAlertTone(alertTone))
|
||||
})
|
||||
}
|
||||
.onDelete { indices in
|
||||
let tones = indices.map {
|
||||
context.viewState.availableCustomTones[$0]
|
||||
}
|
||||
|
||||
context.send(viewAction: .deleteCustomAlertTones(tones))
|
||||
}
|
||||
|
||||
ListRow(label: .plain(title: UntranslatedL10n.screenNotificationSettingsSoundCustomSoundButtonTitle),
|
||||
kind: .button {
|
||||
context.shouldShowCustomAlertTonePicker = true
|
||||
})
|
||||
.fileImporter(isPresented: $context.shouldShowCustomAlertTonePicker,
|
||||
allowedContentTypes: [
|
||||
.mp3,
|
||||
.aiff,
|
||||
.wav,
|
||||
UTType("com.apple.m4a-audio"),
|
||||
UTType("com.apple.coreaudio-format")
|
||||
].compactMap(\.self)) {
|
||||
context.send(viewAction: .addedCustomAlertTone($0))
|
||||
}
|
||||
}
|
||||
|
||||
private var configurationMismatchSection: some View {
|
||||
Section {
|
||||
ListRow(kind: .custom {
|
||||
@@ -220,7 +283,9 @@ struct NotificationSettingsScreen_Previews: PreviewProvider, TestablePreview {
|
||||
|
||||
var viewModel = NotificationSettingsScreenViewModel(appSettings: appSettings,
|
||||
userNotificationCenter: notificationCenter,
|
||||
notificationToneManager: NotificationToneManagerMock(),
|
||||
notificationSettingsProxy: notificationSettingsProxy,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
isModallyPresented: true)
|
||||
viewModel.fetchInitialContent()
|
||||
return viewModel
|
||||
@@ -248,7 +313,9 @@ struct NotificationSettingsScreen_Previews: PreviewProvider, TestablePreview {
|
||||
|
||||
var viewModel = NotificationSettingsScreenViewModel(appSettings: appSettings,
|
||||
userNotificationCenter: notificationCenter,
|
||||
notificationToneManager: NotificationToneManagerMock(),
|
||||
notificationSettingsProxy: notificationSettingsProxy,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
isModallyPresented: true)
|
||||
viewModel.fetchInitialContent()
|
||||
return viewModel
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a notification alert tone and its backing audio file location.
|
||||
struct NotificationTone: Hashable, Comparable, Codable {
|
||||
/// Display name for the tone, falling back to the filename stem.
|
||||
var label: String {
|
||||
labelOverride ?? URL(filePath: "/\(relativePath.last, default: "")").deletingPathExtension().lastPathComponent
|
||||
}
|
||||
|
||||
private let labelOverride: String?
|
||||
let storageLocationRoot: StorageLocation
|
||||
let relativePath: [String]
|
||||
|
||||
/// The audio filename including its extension.
|
||||
var filename: String {
|
||||
relativePath.last ?? ""
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - labelOverride: Optional custom display name. If `nil`, the filename stem is used.
|
||||
/// - storageLocationRoot: Where the file lives (system, bundle, or library).
|
||||
/// - relativePath: Path components relative to the storage root, e.g. `["New", "Bloom.caf"]`.
|
||||
init(labelOverride: String?, storageLocationRoot: StorageLocation, relativePath: [String]) {
|
||||
self.labelOverride = labelOverride
|
||||
self.storageLocationRoot = storageLocationRoot
|
||||
self.relativePath = relativePath
|
||||
}
|
||||
|
||||
/// Indicates which storage root backs the tone's audio file.
|
||||
enum StorageLocation: Codable, Hashable {
|
||||
/// The device's system sounds directory.
|
||||
case system
|
||||
/// The app's main bundle resources.
|
||||
case appBundle
|
||||
/// The app's Library directory (user-imported tones).
|
||||
case appLibrary
|
||||
}
|
||||
|
||||
/// Creates a tone backed by a file in the system sounds directory.
|
||||
static func createSystemSound(label: String?, filename: String, systemSoundsSubdirectory: [String] = []) -> NotificationTone {
|
||||
NotificationTone(labelOverride: label, storageLocationRoot: .system, relativePath: systemSoundsSubdirectory + [filename])
|
||||
}
|
||||
|
||||
/// Creates a tone backed by a file in the app bundle.
|
||||
static func createBundledSound(label: String?, filename: String) -> NotificationTone {
|
||||
NotificationTone(labelOverride: label, storageLocationRoot: .appBundle, relativePath: [filename])
|
||||
}
|
||||
|
||||
/// Creates a tone backed by a user-imported file in the app library.
|
||||
static func createCustomUserSound(filename: String) -> NotificationTone {
|
||||
NotificationTone(labelOverride: nil, storageLocationRoot: .appLibrary, relativePath: [filename])
|
||||
}
|
||||
|
||||
static func < (lhs: NotificationTone, rhs: NotificationTone) -> Bool {
|
||||
lhs.label < rhs.label
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol NotificationToneManagerProtocol {
|
||||
@discardableResult
|
||||
func setSelectedTone(_ alertTone: NotificationTone) throws -> URL
|
||||
func customTones() -> [NotificationTone]
|
||||
func deleteCustomTone(_ alertTone: NotificationTone) throws
|
||||
|
||||
@NotificationToneManager.ConversionActor
|
||||
@discardableResult
|
||||
func addNewToneToLibrary(from sourceURL: URL) throws -> URL
|
||||
}
|
||||
Reference in New Issue
Block a user