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
@@ -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)
}
}
}