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