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:
Michael
2026-05-20 07:40:34 -05:00
committed by GitHub
parent cb1c89be9d
commit dd0de4d7b0
22 changed files with 1213 additions and 17 deletions
@@ -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)
}
@@ -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 {
@@ -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 {
@@ -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