mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +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:
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
import Testing
|
||||
|
||||
@@ -17,6 +18,7 @@ struct NotificationSettingsScreenViewModelTests {
|
||||
private var appSettings: AppSettings
|
||||
private var userNotificationCenter: UserNotificationCenterMock
|
||||
private var notificationSettingsProxy: NotificationSettingsProxyMock
|
||||
private var toneManager: NotificationToneManagerMock
|
||||
|
||||
init() throws {
|
||||
AppSettings.resetAllSettings()
|
||||
@@ -28,10 +30,14 @@ struct NotificationSettingsScreenViewModelTests {
|
||||
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .allMessages
|
||||
notificationSettingsProxy.isRoomMentionEnabledReturnValue = true
|
||||
notificationSettingsProxy.isCallEnabledReturnValue = true
|
||||
toneManager = NotificationToneManagerMock()
|
||||
toneManager.customTonesReturnValue = []
|
||||
|
||||
viewModel = NotificationSettingsScreenViewModel(appSettings: appSettings,
|
||||
userNotificationCenter: userNotificationCenter,
|
||||
notificationToneManager: toneManager,
|
||||
notificationSettingsProxy: notificationSettingsProxy,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
isModallyPresented: false)
|
||||
context = viewModel.context
|
||||
}
|
||||
@@ -400,6 +406,106 @@ struct NotificationSettingsScreenViewModelTests {
|
||||
#expect(notificationSettingsProxy.setInviteForMeEnabledEnabledReceivedEnabled == true)
|
||||
}
|
||||
|
||||
// MARK: - Alert Tones
|
||||
|
||||
@Test
|
||||
func selectingAlertTonePersistsSelection() {
|
||||
// Given a tone and a manager that persists the selection to app settings
|
||||
let tone = NotificationTone.createBundledSound(label: "Test", filename: "test.caf")
|
||||
toneManager.setSelectedToneClosure = { [appSettings] selectedTone in
|
||||
appSettings.selectedNotificationTone = selectedTone
|
||||
return NotificationToneManager.libraryLocation
|
||||
}
|
||||
|
||||
// When the user selects that tone
|
||||
context.send(viewAction: .selectAlertTone(tone))
|
||||
|
||||
// Then it is reflected in app settings
|
||||
#expect(appSettings.selectedNotificationTone == tone)
|
||||
}
|
||||
|
||||
@Test
|
||||
func deletingActiveToneResetsSelection() {
|
||||
// Given the active tone is a custom tone
|
||||
let customTone = NotificationTone.createCustomUserSound(filename: "custom.caf")
|
||||
appSettings.selectedNotificationTone = customTone
|
||||
toneManager.customTonesReturnValue = [customTone]
|
||||
|
||||
// When that tone is deleted
|
||||
context.send(viewAction: .deleteCustomAlertTones([customTone]))
|
||||
|
||||
// Then the selection is cleared, falling back to the default
|
||||
#expect(appSettings.selectedNotificationTone == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func deletingNonActiveTonePreservesSelection() {
|
||||
// Given a custom tone is active and a different custom tone also exists
|
||||
let activeTone = NotificationTone.createCustomUserSound(filename: "active.caf")
|
||||
let otherTone = NotificationTone.createCustomUserSound(filename: "other.caf")
|
||||
appSettings.selectedNotificationTone = activeTone
|
||||
toneManager.customTonesReturnValue = [activeTone, otherTone]
|
||||
|
||||
// When the non-active tone is deleted
|
||||
context.send(viewAction: .deleteCustomAlertTones([otherTone]))
|
||||
|
||||
// Then the active selection is unchanged
|
||||
#expect(appSettings.selectedNotificationTone == activeTone)
|
||||
}
|
||||
|
||||
@Test
|
||||
func deletingToneUpdatesCustomList() {
|
||||
// Given a manager that initially returns one custom tone
|
||||
let customTone = NotificationTone.createCustomUserSound(filename: "custom.caf")
|
||||
var remainingTones = [customTone]
|
||||
toneManager.customTonesClosure = { remainingTones }
|
||||
let localVM = NotificationSettingsScreenViewModel(appSettings: appSettings,
|
||||
userNotificationCenter: userNotificationCenter,
|
||||
notificationToneManager: toneManager,
|
||||
notificationSettingsProxy: notificationSettingsProxy,
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
isModallyPresented: false)
|
||||
|
||||
// When the tone is deleted and the manager reports no remaining tones
|
||||
remainingTones = []
|
||||
localVM.context.send(viewAction: .deleteCustomAlertTones([customTone]))
|
||||
|
||||
// Then the available list is empty
|
||||
#expect(localVM.context.viewState.availableCustomTones.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func deleteCustomToneFailurePreservesSelection() {
|
||||
// Given the active tone is a custom tone and deletion will fail
|
||||
let customTone = NotificationTone.createCustomUserSound(filename: "custom.caf")
|
||||
appSettings.selectedNotificationTone = customTone
|
||||
toneManager.deleteCustomToneThrowableError = NSError(domain: "test", code: 1)
|
||||
toneManager.customTonesReturnValue = [customTone]
|
||||
|
||||
// When deletion is attempted
|
||||
context.send(viewAction: .deleteCustomAlertTones([customTone]))
|
||||
|
||||
// Then the selection is unchanged
|
||||
#expect(appSettings.selectedNotificationTone == customTone)
|
||||
}
|
||||
|
||||
@Test
|
||||
func importingCustomToneRefreshesAvailableList() async throws {
|
||||
// Given a tone file ready to import
|
||||
let importedTone = NotificationTone.createCustomUserSound(filename: "imported.caf")
|
||||
let location = NotificationToneManager.libraryLocation.appending(component: importedTone.filename)
|
||||
toneManager.addNewToneToLibraryFromReturnValue = location
|
||||
toneManager.customTonesReturnValue = [importedTone]
|
||||
|
||||
// When the import result is received
|
||||
let deferred = deferFulfillment(context.observe(\.viewState.availableCustomTones)) { !$0.isEmpty }
|
||||
context.send(viewAction: .addedCustomAlertTone(.success(location)))
|
||||
|
||||
// Then the available list is updated to include the new tone
|
||||
try await deferred.fulfill()
|
||||
#expect(context.viewState.availableCustomTones == [importedTone])
|
||||
}
|
||||
|
||||
@Test
|
||||
func toggleInvitesFailure() async throws {
|
||||
notificationSettingsProxy.setInviteForMeEnabledEnabledThrowableError = NotificationSettingsError.Generic(msg: "error")
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// Copyright 2025 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.
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
/// These tests exercise `NotificationToneManager` against the real app sandbox filesystem.
|
||||
/// Because all tests share a single `libraryLocation` directory on disk, they are inherently
|
||||
/// incapable of running in parallel — files written by one test would corrupt the state of
|
||||
/// another. This project runs test in serial regardless, but this is just to document this is a specific case
|
||||
/// relying on that behavior.
|
||||
struct NotificationToneManagerTests {
|
||||
private let manager: NotificationToneManager
|
||||
|
||||
init() {
|
||||
AppSettings.resetAllSettings()
|
||||
manager = NotificationToneManager(appSettings: AppSettings())
|
||||
}
|
||||
|
||||
// MARK: - Deletion
|
||||
|
||||
@Test
|
||||
func deletingSystemToneThrowsNotACustomTone() {
|
||||
// Given a system tone (lives outside the library directory)
|
||||
let tone = NotificationTone.createSystemSound(label: nil, filename: "alarm.caf")
|
||||
|
||||
// When deletion is attempted
|
||||
// Then it throws because only library tones may be deleted
|
||||
let error = #expect(throws: NotificationToneManager.ManagerError.self) {
|
||||
try manager.deleteCustomTone(tone)
|
||||
}
|
||||
|
||||
#expect(error == .notACustomTone)
|
||||
}
|
||||
|
||||
@Test
|
||||
func deletingBundledToneThrowsNotACustomTone() {
|
||||
// Given a bundled app tone (lives outside the library directory)
|
||||
let tone = NotificationTone.createBundledSound(label: nil, filename: "message.caf")
|
||||
|
||||
// When deletion is attempted
|
||||
// Then it throws because only library tones may be deleted
|
||||
let error = #expect(throws: NotificationToneManager.ManagerError.self) {
|
||||
try manager.deleteCustomTone(tone)
|
||||
}
|
||||
|
||||
#expect(error == .notACustomTone)
|
||||
}
|
||||
|
||||
// MARK: - Custom Tone Listing
|
||||
|
||||
@Test
|
||||
func customTonesFiltersToCAFOnly() throws {
|
||||
// Given both CAF and non-CAF files written to the library directory
|
||||
let cafURL = NotificationToneManager.libraryLocation.appending(component: "\(UUID().uuidString).caf")
|
||||
let mp3URL = NotificationToneManager.libraryLocation.appending(component: "\(UUID().uuidString).mp3")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: cafURL)
|
||||
try? FileManager.default.removeItem(at: mp3URL)
|
||||
}
|
||||
try Data().write(to: cafURL)
|
||||
try Data().write(to: mp3URL)
|
||||
|
||||
// When fetching the list of custom tones
|
||||
let tones = manager.customTones()
|
||||
|
||||
// Then only the CAF file is included
|
||||
#expect(tones.contains { $0.filename == cafURL.lastPathComponent })
|
||||
#expect(!tones.contains { $0.filename == mp3URL.lastPathComponent })
|
||||
}
|
||||
|
||||
// MARK: - Import
|
||||
|
||||
@Test
|
||||
func addingDuplicateToneThrowsFileAlreadyExists() async throws {
|
||||
// Given a CAF file that has already been imported into the library
|
||||
let sourceURL = URL.temporaryDirectory.appending(component: "\(UUID().uuidString).caf")
|
||||
let importedURL = NotificationToneManager.libraryLocation
|
||||
.appending(component: sourceURL.deletingPathExtension().lastPathComponent)
|
||||
.appendingPathExtension("caf")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: sourceURL)
|
||||
try? FileManager.default.removeItem(at: importedURL)
|
||||
}
|
||||
try Data().write(to: sourceURL)
|
||||
try await manager.addNewToneToLibrary(from: sourceURL)
|
||||
|
||||
// When importing the same file a second time
|
||||
// Then it throws ConversionError (specifically fileAlreadyExists)
|
||||
await #expect(throws: NotificationToneManager.ManagerError.self) {
|
||||
try await manager.addNewToneToLibrary(from: sourceURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user