mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +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:
+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
|
||||
|
||||
Reference in New Issue
Block a user