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
+42
View File
@@ -199,6 +199,7 @@
1D623953F970D11F6F38499C /* AppLockService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851B95BB98649B8E773D6790 /* AppLockService.swift */; };
1D69E31913DF66426985909B /* EmojiPickerScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11151E78D6BB2B04A8FBD389 /* EmojiPickerScreenViewModelProtocol.swift */; };
1DC227816777A2F3A19657E5 /* RoomDirectorySearchScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF71646898A2F720C5BFDF5 /* RoomDirectorySearchScreenViewModel.swift */; };
1E414736CCF094E2CBD8E482 /* NotificationToneManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B2A2B168C8DC14A1A9D1C64 /* NotificationToneManagerProtocol.swift */; };
1ECE584D2CFA0FEF0ADE458C /* TestablePreviewsDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA723686F23EF45E2B398FBC /* TestablePreviewsDictionary.swift */; };
1F1BCEE81056FD9F344F3B0E /* ClassicAppAccountManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80935ADC7ED867226225F965 /* ClassicAppAccountManagerTests.swift */; };
1F3232BD368DF430AB433907 /* Compound in Frameworks */ = {isa = PBXBuildFile; productRef = 07FEEEDB11543A7DED420F04 /* Compound */; };
@@ -323,6 +324,7 @@
358290C2E4574456FE0C2DF6 /* LocationSharingScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B73675A21580971E9ADF66A /* LocationSharingScreenViewModelProtocol.swift */; };
35E975CFDA60E05362A7CF79 /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 1222DB76B917EB8A55365BA5 /* target.yml */; };
36206F74DDEBF9BEAF6A6A1F /* ExtensionLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A8571A8A071FB41778C016 /* ExtensionLogger.swift */; };
3683C4C0DBFA397BE0105B0D /* NotificationTone.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF08D6FC1919CB804A9191E8 /* NotificationTone.swift */; };
3684AD01C5FCB7616B28F629 /* TimelineMediaPreviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDE60FEE95039CCCEEEE3B0 /* TimelineMediaPreviewController.swift */; };
36926D795D6D19177C7812F8 /* EncryptionResetPasswordScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6935A55AB3B0C94BC566DD6 /* EncryptionResetPasswordScreenCoordinator.swift */; };
369BF960E52BBEE61F8A5BD1 /* BlockedUsersScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED60E4D2CD678E1EBF16F77A /* BlockedUsersScreen.swift */; };
@@ -693,6 +695,7 @@
754602A7B2AAD443C4228ED4 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 4278261E147DB2DE5CFB7FC5 /* PostHog */; };
755395927DDD6EBDDA5E217A /* SettingsFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F7A6CEEA4A2815B0F0F55 /* SettingsFlowCoordinator.swift */; };
755727E0B756430DFFEC4732 /* SessionVerificationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF05DA24F71B455E8EFEBC3B /* SessionVerificationViewModelTests.swift */; };
756BD0B80C1AF2A6DF0786CB /* CommonSettings+NotificationName.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7A66212DC7D097886BD01F /* CommonSettings+NotificationName.swift */; };
756EA0D663261889EF64E6D4 /* VoiceMessageRecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E9CBF577B9711CFBB4FA40D /* VoiceMessageRecordingView.swift */; };
7573D682F089205F7F1D96CF /* SessionDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2067FF58B4996323EB40C /* SessionDirectories.swift */; };
757862045774A0F458357E19 /* RoomThreadListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544B9262E1BC6F489C03FFFA /* RoomThreadListScreen.swift */; };
@@ -819,6 +822,7 @@
88C8B87A26D6B4E731F2B5B7 /* SpacesTabFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4930A2674306A2DC9C92009D /* SpacesTabFlowCoordinator.swift */; };
88CBF1595E39CE697928DE48 /* SFNumberedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEB5FF7A09B79B0C6B528F7C /* SFNumberedListView.swift */; };
88F348E2CB14FF71CBBB665D /* AudioRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7475C5AE20BA896930907EA8 /* AudioRoomTimelineItemContent.swift */; };
88FF7B41DF76E22E6835F3C3 /* CommonSettings+NotificationName.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7A66212DC7D097886BD01F /* CommonSettings+NotificationName.swift */; };
890F0D453FE388756479AC97 /* AnalyticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C687844F60BFF532D49A994C /* AnalyticsTests.swift */; };
89198AE2649DD77673D5793B /* ExtensionLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A8571A8A071FB41778C016 /* ExtensionLogger.swift */; };
8944548A684F1C837CEC47F4 /* RoomMembersListScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0946F77B696176E062D037 /* RoomMembersListScreenModels.swift */; };
@@ -902,6 +906,7 @@
94F0B78928E952689ACDB271 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D67E616BCA82D8A1258D488 /* NetworkMonitor.swift */; };
95690DDD9D547D3D842ACBE3 /* AnalyticsSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD371B60E07A5324B9507EF /* AnalyticsSettingsScreenCoordinator.swift */; };
9586E90A447C4896C0CA3A8E /* TimelineItemReplyDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */; };
95B690EA99A3385D4B8A593D /* NotificationToneManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B94A6A57FE7E71F7605070AA /* NotificationToneManager.swift */; };
95E7B236F7116CACE05A6BC9 /* BlockedUsersScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16D0F226B1819D017531647 /* BlockedUsersScreenCoordinator.swift */; };
9603EEF6DE980BB1D15D4707 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A3E8741D199CD1A37F4CBF /* UIView.swift */; };
962A4F8AD6312804E2C6BB6E /* PhotoLibraryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A232D9156D225BD9FD1D0C43 /* PhotoLibraryPicker.swift */; };
@@ -1008,6 +1013,7 @@
A6B83EB78F025D21B6EBA90C /* CompoundIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 044E501B8331B339874D1B96 /* CompoundIcon.swift */; };
A6D4C5EEA85A6A0ABA1559D6 /* RoomDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */; };
A6DEC1ADEC8FEEC206A0FA37 /* AttributedStringBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */; };
A6EDAA9C103BCBCA2FBFE175 /* NotificationToneManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B2A2B168C8DC14A1A9D1C64 /* NotificationToneManagerProtocol.swift */; };
A6F345328CCC5C9B0DAE2257 /* LogViewerScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BB05221D7D941CC82DC8480 /* LogViewerScreenViewModel.swift */; };
A6FFC4C5154C446BAD6B40D8 /* TimelineItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8520AFD6680CBAD388F6D927 /* TimelineItemProvider.swift */; };
A722F426FD81FC67706BB1E0 /* CustomLayoutLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42236480CF0431535EBE8387 /* CustomLayoutLabelStyle.swift */; };
@@ -1090,6 +1096,7 @@
B6DF6B6FA8734B70F9BF261E /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */; };
B6EC2148FA5443C9289BEEBA /* MediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17EFA1D3D09FC2F9C5E1CB2 /* MediaProvider.swift */; };
B73E50AF1AB2EB5477E20710 /* RoomDetailsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 166D45E1861A73B232109843 /* RoomDetailsScreenViewModelTests.swift */; };
B7513A1D8145E7F03B60696B /* sound_01.caf in Resources */ = {isa = PBXBuildFile; fileRef = 7349B71A67B765AA86002723 /* sound_01.caf */; };
B773ACD8881DB18E876D950C /* WaveformSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94028A227645FA880B966211 /* WaveformSource.swift */; };
B7888FC1E1DEF816D175C8D6 /* SecureBackupKeyBackupScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD72A9B720D75DBE60AC299F /* SecureBackupKeyBackupScreenModels.swift */; };
B796A25F282C0A340D1B9C12 /* ImageRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B5EDCD05D50BA9B815C66C /* ImageRoomTimelineItemContent.swift */; };
@@ -1352,6 +1359,7 @@
E587A513B3E1EE9B56E9AB9C /* DSWaveformImageViews in Frameworks */ = {isa = PBXBuildFile; productRef = 2A4106A0A96DC4C273128AA5 /* DSWaveformImageViews */; };
E58F1F3276E98A93F7D39219 /* RoomPollsHistoryScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D8479BB704B7EF696F8ABE /* RoomPollsHistoryScreenCoordinator.swift */; };
E591742E509A2A009BF25F9D /* RoomEventStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AE5800184E93CD5E02C6543 /* RoomEventStringBuilderTests.swift */; };
E593841CEBFDDC5387A75EBA /* NotificationToneManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D148332A613AD27E9A2896C9 /* NotificationToneManagerTests.swift */; };
E5AB28123E2488F97E953AC0 /* CallNotificationRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED17433ADC77287F8904F9 /* CallNotificationRoomTimelineItem.swift */; };
E5E43A0CA99AF5BA11B194A2 /* EncryptionSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57AD14D3ADADE8F6A10F9E88 /* EncryptionSettingsTests.swift */; };
E5F4C992845388B50BABACAA /* ServerSelectionScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB8BC4C791D0E88CFCF4E5DF /* ServerSelectionScreenCoordinator.swift */; };
@@ -1360,6 +1368,7 @@
E6A2F6E4795C1054025AACFF /* NotificationItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3AB32690C6CE5C62A86D6FA /* NotificationItemProxy.swift */; };
E6FA87F773424B27614B23E9 /* TimelineItemAccessibilityModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA7E93C2E148B96EF6A8500 /* TimelineItemAccessibilityModifier.swift */; };
E713F930C5AD0F4B11119E2B /* ChatsSpaceFiltersScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 154664B1B0D71846FBAC6E61 /* ChatsSpaceFiltersScreen.swift */; };
E7416C2EFDC2D475F69127FE /* NotificationTone.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF08D6FC1919CB804A9191E8 /* NotificationTone.swift */; };
E75CE800B3E64D0F7F8E228D /* TemplateScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C08E9043618AE5B0BF7B07E1 /* TemplateScreenViewModelTests.swift */; };
E77FE06B165A38BF1735509F /* SecureBackupScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF73F49E6B6683F7E2D26F0 /* SecureBackupScreenCoordinator.swift */; };
E78D429F18071545BF661A52 /* RoomDetailsEditScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3E77399BD262D301451BF2 /* RoomDetailsEditScreenCoordinator.swift */; };
@@ -1419,6 +1428,7 @@
EF79B9EFD094C17FBB4942C2 /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E6DE144D887A254F4CAF203 /* UserPreference.swift */; };
EF890DEF0479E66548F2BA23 /* AppLockTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490BEADEFB2D6B7C9F618AE8 /* AppLockTimer.swift */; };
EFBBD44C0A16F017C32D2099 /* TimelineItemStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72614BFF35B8394C6E13F55A /* TimelineItemStatusView.swift */; };
EFC4CCCF9D22FFA7D542693A /* NotificationTone.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF08D6FC1919CB804A9191E8 /* NotificationTone.swift */; };
EFF735EC040BEB669AFBAB50 /* LeaveSpaceHandleProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B3CE05643C7791D46AC54B /* LeaveSpaceHandleProxy.swift */; };
F0570F1ECD70C4C851FB2052 /* SecureBackupRecoveryKeyScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E7304F5ECB4CB11CB10E60 /* SecureBackupRecoveryKeyScreenViewModelProtocol.swift */; };
F06CE9132855E81EBB6DDC32 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 940C605265DD82DA0C655E23 /* Kingfisher */; };
@@ -1483,6 +1493,7 @@
F833D5B5BE6707F961FA88DB /* SecureBackupController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1119E9C63AE530252640D2 /* SecureBackupController.swift */; };
F86102DC2C68BBBB0521BAAE /* SoftLogoutScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB385E148DE55C85C0A02D6 /* SoftLogoutScreenModels.swift */; };
F8B2F5CBCF2A0E0798E8D646 /* TimelineViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB5B00A014307CE37B2812CD /* TimelineViewModelProtocol.swift */; };
F8B72DC211E95126444531DE /* NotificationToneManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B94A6A57FE7E71F7605070AA /* NotificationToneManager.swift */; };
F8C87130FD999F7F1076208C /* RoomChangePermissionsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89AAEA70CFF3284920811941 /* RoomChangePermissionsScreen.swift */; };
F8E725D42023ECA091349245 /* AudioRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57EAAF82432B0B53881CF826 /* AudioRoomTimelineItem.swift */; };
F8F47CE757EE656905F01F2C /* RoomRolesAndPermissionsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90DFF217B3D9D0941283278C /* RoomRolesAndPermissionsScreenViewModelProtocol.swift */; };
@@ -2135,6 +2146,7 @@
5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModelTests.swift; sourceTree = "<group>"; };
5A70B03471F6027C90EE868C /* RoomMembersFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersFlowCoordinator.swift; sourceTree = "<group>"; };
5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenCoordinator.swift; sourceTree = "<group>"; };
5B2A2B168C8DC14A1A9D1C64 /* NotificationToneManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationToneManagerProtocol.swift; sourceTree = "<group>"; };
5B8F0ED874DF8C9A51B0AB6F /* SettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenCoordinator.swift; sourceTree = "<group>"; };
5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModelTests.swift; sourceTree = "<group>"; };
5C60559596897AC65D2CB799 /* LiveLocationSharingBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationSharingBannerView.swift; sourceTree = "<group>"; };
@@ -2254,6 +2266,7 @@
72C4A2D279386A811BDC6DAE /* LinkNewDeviceScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceScreen.swift; sourceTree = "<group>"; };
72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderProtocol.swift; sourceTree = "<group>"; };
7310D8DFE01AF45F0689C3AA /* Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.swift; sourceTree = "<group>"; };
7349B71A67B765AA86002723 /* sound_01.caf */ = {isa = PBXFileReference; path = sound_01.caf; sourceTree = "<group>"; };
7367B3B9A8CAF902220F31D1 /* BugReportFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportFlowCoordinator.swift; sourceTree = "<group>"; };
739077686814E4EA339B1C83 /* RoomPreviewProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPreviewProxyProtocol.swift; sourceTree = "<group>"; };
7396A6491DEC64FCB66F60A1 /* RoomThreadListServiceProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListServiceProxyMock.swift; sourceTree = "<group>"; };
@@ -2575,6 +2588,7 @@
AB07F03461023BC39C730922 /* PhishingDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhishingDetector.swift; sourceTree = "<group>"; };
AB26D5444A4A7E095222DE8B /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
AB389C38BD41EB3E47092CFB /* AccessibilityTests.xctestplan */ = {isa = PBXFileReference; path = AccessibilityTests.xctestplan; sourceTree = "<group>"; };
AB7A66212DC7D097886BD01F /* CommonSettings+NotificationName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommonSettings+NotificationName.swift"; sourceTree = "<group>"; };
ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelProtocol.swift; sourceTree = "<group>"; };
ABF84AA68B2B7584D9275769 /* VoiceMessageTrashButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageTrashButton.swift; sourceTree = "<group>"; };
AC0275CEE9CA078B34028BDF /* AppLockScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelTests.swift; sourceTree = "<group>"; };
@@ -2672,6 +2686,7 @@
B902EA6CD3296B0E10EE432B /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
B91AD590B0B40718A0AA0C61 /* DeferredFulfillment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredFulfillment.swift; sourceTree = "<group>"; };
B9406BD5F99685C5ABDEDD93 /* LocationShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationShareSheet.swift; sourceTree = "<group>"; };
B94A6A57FE7E71F7605070AA /* NotificationToneManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationToneManager.swift; sourceTree = "<group>"; };
B99E13633862847D8B7E2815 /* StartChatScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenModels.swift; sourceTree = "<group>"; };
BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferenceTests.swift; sourceTree = "<group>"; };
BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceServiceMock.swift; sourceTree = "<group>"; };
@@ -2698,6 +2713,7 @@
BEBA759D1347CFFB3D84ED1F /* UserSessionStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStoreProtocol.swift; sourceTree = "<group>"; };
BEE365C5A4E90ACBE398EFFE /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/SAS.strings; sourceTree = "<group>"; };
BEF5FE93A06F563B477F024A /* RoomAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomAvatarImage.swift; sourceTree = "<group>"; };
BF08D6FC1919CB804A9191E8 /* NotificationTone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTone.swift; sourceTree = "<group>"; };
BF34A2FD6797535C95AC918D /* PlaceholderScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderScreenCoordinator.swift; sourceTree = "<group>"; };
BF8349149254469E24483FB0 /* SpaceScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceScreenViewModel.swift; sourceTree = "<group>"; };
BFA9EA59D5C0DA1BFC7B3621 /* QRCodeLoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginScreen.swift; sourceTree = "<group>"; };
@@ -2793,6 +2809,7 @@
D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = "<group>"; };
D09227E671DB30795C43FFFD /* KnockRequestsListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestsListScreenViewModel.swift; sourceTree = "<group>"; };
D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemViewState.swift; sourceTree = "<group>"; };
D148332A613AD27E9A2896C9 /* NotificationToneManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationToneManagerTests.swift; sourceTree = "<group>"; };
D17F49E39CC38DAB7B305701 /* LiveLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationManager.swift; sourceTree = "<group>"; };
D1896F6288D80E1F3EFB3DF8 /* ka */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ka; path = ka.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
D196116D2DD3F2757D45FCB7 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/SAS.strings; sourceTree = "<group>"; };
@@ -3267,6 +3284,7 @@
0787F81684E503024BD0C051 /* Services */ = {
isa = PBXGroup;
children = (
80D2614A3BEBB68232559A52 /* AlertTones */,
4BF8D11D9ED15CFC373D0119 /* Analytics */,
7803E03F759061C948D66B7E /* AppLock */,
FCE7249621F507F34A8122FB /* Audio */,
@@ -4949,6 +4967,7 @@
0F0AF1A13D358CA0BDA6065F /* NotificationContentBuilderTests.swift */,
8544F7058D31DBEB8DBFF0F5 /* NotificationSettingsEditScreenViewModelTests.swift */,
514363244AE7D68080D44C6F /* NotificationSettingsScreenViewModelTests.swift */,
D148332A613AD27E9A2896C9 /* NotificationToneManagerTests.swift */,
FA3EB5B1848CF4F64E63C6B7 /* PermalinkTests.swift */,
31A6314FDC51DA25712D9A81 /* PillContextTests.swift */,
6FA38E813BE14149F173F461 /* PinnedEventsBannerStateTests.swift */,
@@ -5274,6 +5293,16 @@
path = View;
sourceTree = "<group>";
};
80D2614A3BEBB68232559A52 /* AlertTones */ = {
isa = PBXGroup;
children = (
BF08D6FC1919CB804A9191E8 /* NotificationTone.swift */,
B94A6A57FE7E71F7605070AA /* NotificationToneManager.swift */,
5B2A2B168C8DC14A1A9D1C64 /* NotificationToneManagerProtocol.swift */,
);
path = AlertTones;
sourceTree = "<group>";
};
815092CE45A84D67A6CC7EA1 /* NetworkMonitor */ = {
isa = PBXGroup;
children = (
@@ -5374,6 +5403,7 @@
isa = PBXGroup;
children = (
6F054DE7D47849687662C9D9 /* BootDetectionManager.swift */,
AB7A66212DC7D097886BD01F /* CommonSettings+NotificationName.swift */,
D7BB243B26D54EF1A0C422C0 /* NotificationContentBuilder.swift */,
BB6ED50FE104992419310EEB /* NotificationHandler.swift */,
27A1AD6389A4659AF0CEAE62 /* NotificationServiceExtension.swift */,
@@ -5447,6 +5477,7 @@
isa = PBXGroup;
children = (
ED482057AE39D5C6D9C5F3D8 /* message.caf */,
7349B71A67B765AA86002723 /* sound_01.caf */,
);
path = Sounds;
sourceTree = "<group>";
@@ -7517,6 +7548,7 @@
03631FC946FF0BBAD37E22BF /* preview_avatar_user.jpg in Resources */,
5DB4334CBBA142376FF5FFEC /* preview_image.jpg in Resources */,
C3AFDF6349E54290AA31EC88 /* preview_video.jpg in Resources */,
B7513A1D8145E7F03B60696B /* sound_01.caf in Resources */,
DFF7D6A6C26DDD40D00AE579 /* target.yml in Resources */,
18978C9438206828C1D5AF2A /* test_animated_image.gif in Resources */,
01373C1AC4839604C4FDA404 /* test_apple_image.heic in Resources */,
@@ -7769,6 +7801,7 @@
9295F1F5E04484E10780BCE8 /* CharacterSet.swift in Sources */,
238D561CA231339C6D4D06F3 /* ClientBuilder.swift in Sources */,
0BAF83521871E69D222EE8E4 /* ClientBuilderHook.swift in Sources */,
88FF7B41DF76E22E6835F3C3 /* CommonSettings+NotificationName.swift in Sources */,
211B5F524E851178EE549417 /* CurrentValuePublisher.swift in Sources */,
24A75F72EEB7561B82D726FD /* Date.swift in Sources */,
FE9A5D4715C7AB68682C030C /* Dictionary.swift in Sources */,
@@ -7804,6 +7837,9 @@
5415236FA271AF7885D4995E /* NotificationItemProxy.swift in Sources */,
CBBBE597BE74A2DF68DE2209 /* NotificationItemProxyProtocol.swift in Sources */,
B14BC354E56616B6B7D9A3D7 /* NotificationServiceExtension.swift in Sources */,
3683C4C0DBFA397BE0105B0D /* NotificationTone.swift in Sources */,
F8B72DC211E95126444531DE /* NotificationToneManager.swift in Sources */,
A6EDAA9C103BCBCA2FBFE175 /* NotificationToneManagerProtocol.swift in Sources */,
86320FDBD2F12A0225B1BCBE /* OAuthConfiguration.swift in Sources */,
761EA50B2619307AB30891B8 /* PhishingDetector.swift in Sources */,
5DFC2A889D3B39DD47AC63A8 /* PillUtilities.swift in Sources */,
@@ -7913,6 +7949,7 @@
1B2DADC008EE211AF1DA5292 /* NotificationManagerTests.swift in Sources */,
C11939FDC40716C4387275A4 /* NotificationSettingsEditScreenViewModelTests.swift in Sources */,
E3AC72E3E58F364EF15C1CC7 /* NotificationSettingsScreenViewModelTests.swift in Sources */,
E593841CEBFDDC5387A75EBA /* NotificationToneManagerTests.swift in Sources */,
50381244BA280451771BE3ED /* PINTextFieldTests.swift in Sources */,
1583E2D766E4485FF91662FC /* PermalinkTests.swift in Sources */,
3982E60F9C126437D5E488A3 /* PillContextTests.swift in Sources */,
@@ -8018,6 +8055,7 @@
0638CBDE3098B1C3F23AFCFA /* MXLog.swift in Sources */,
074F741578307EF0179EE47C /* MapTilerConfiguration.swift in Sources */,
1A3783005E6945F8583AF997 /* NSItemProvider.swift in Sources */,
E7416C2EFDC2D475F69127FE /* NotificationTone.swift in Sources */,
F43564003B017DB148DB1503 /* OAuthConfiguration.swift in Sources */,
BE8E5985771DF9137C6CE89A /* ProcessInfo.swift in Sources */,
58F357A9D130A654ABCB1638 /* RageshakeConfiguration.swift in Sources */,
@@ -8237,6 +8275,7 @@
78A3D84BA47DAC69B4D0A34C /* CollapsibleRoomTimelineView.swift in Sources */,
0DC815CA24E1BD7F408F37D3 /* CollapsibleTimelineItem.swift in Sources */,
663E198678778F7426A9B27D /* Collection.swift in Sources */,
756BD0B80C1AF2A6DF0786CB /* CommonSettings+NotificationName.swift in Sources */,
24B7CD41342C143117ADA768 /* Comparable.swift in Sources */,
19DF5600A7F547B22DD7872A /* CompletionSuggestionService.swift in Sources */,
EAB3C1F0BC7F671ED8BDF82D /* CompletionSuggestionServiceProtocol.swift in Sources */,
@@ -8601,6 +8640,9 @@
D12F440F7973F1489F61389D /* NotificationSettingsScreenModels.swift in Sources */,
7F7EA51A9A43125A8CB6AC90 /* NotificationSettingsScreenViewModel.swift in Sources */,
CBD2ABE4C1A47ECD99E1488E /* NotificationSettingsScreenViewModelProtocol.swift in Sources */,
EFC4CCCF9D22FFA7D542693A /* NotificationTone.swift in Sources */,
95B690EA99A3385D4B8A593D /* NotificationToneManager.swift in Sources */,
1E414736CCF094E2CBD8E482 /* NotificationToneManagerProtocol.swift in Sources */,
6AC798F52571BE495E6AA1CE /* OAuthAccountSettingsPresenter.swift in Sources */,
E32E71A1AF5E5E69E8363B26 /* OAuthAuthenticationPresenter.swift in Sources */,
C525F6C2892CB0640E776B3D /* OAuthConfiguration.swift in Sources */,
@@ -11,3 +11,37 @@
"soft_logout_signin_notice" = "Your homeserver (%1$s) admin has signed you out of your account %2$s (%3$s).";
"soft_logout_signin_title" = "Sign in";
"untranslated" = "Untranslated";
"screen_notification_settings_sound_system_tri_tone" = "Tri-tone";
"screen_notification_settings_sound_system_chime" = "Chime";
"screen_notification_settings_sound_system_glass" = "Glass";
"screen_notification_settings_sound_system_horn" = "Horn";
"screen_notification_settings_sound_system_bell" = "Bell";
"screen_notification_settings_sound_system_electronic" = "Electronic";
"screen_notification_settings_sound_system_alert" = "Alert";
"screen_notification_settings_sound_system_bloom" = "Bloom";
"screen_notification_settings_sound_system_calypso" = "Calypso";
"screen_notification_settings_sound_system_anticipate" = "Anticipate";
"screen_notification_settings_sound_system_choo_choo" = "Choo Choo";
"screen_notification_settings_sound_system_descent" = "Descent";
"screen_notification_settings_sound_system_fanfare" = "Fanfare";
"screen_notification_settings_sound_system_ladder" = "Ladder";
"screen_notification_settings_sound_system_minuet" = "Minuet";
"screen_notification_settings_sound_system_news_flash" = "News Flash";
"screen_notification_settings_sound_system_noir" = "Noir";
"screen_notification_settings_sound_system_sherwood_forest" = "Sherwood Forest";
"screen_notification_settings_sound_system_spell" = "Spell";
"screen_notification_settings_sound_system_suspense" = "Suspense";
"screen_notification_settings_sound_system_telegraph" = "Telegraph";
"screen_notification_settings_sound_system_tiptoes" = "Tiptoes";
"screen_notification_settings_sound_system_typewriters" = "Typewriters";
"screen_notification_settings_sound_system_update" = "Update";
"screen_notification_settings_sound_system_swish" = "Swish";
"screen_notification_settings_sound_system_tweet" = "Tweet";
"screen_notification_settings_sound_element_default" = "Element Default";
"screen_notification_settings_sound_element_fade" = "Element Fade";
"screen_notification_settings_sound_custom_sound_button_title" = "Custom sound...";
"screen_notification_settings_sound_section_title" = "Sound";
"screen_notification_settings_sound_set_sound_error_title" = "Problem setting alert sound";
"screen_notification_settings_sound_preview_sound_error_title" = "Problem previewing alert sound";
"screen_notification_settings_sound_import_sound_error_title" = "Error importing file";
"screen_notification_settings_sound_delete_sound_error_title" = "Error deleting file";
Binary file not shown.
@@ -16,8 +16,8 @@ import SwiftUI
/// Common settings between app and NSE
protocol CommonSettingsProtocol: AnyObject {
var lastNotificationBootTime: TimeInterval? { get set }
var notificationSoundName: RemotePreference<UNNotificationSoundName> { get }
var selectedNotificationTone: NotificationTone? { get set }
var logLevel: LogLevel { get }
var traceLogPacks: Set<TraceLogPack> { get }
var bugReportRageshakeURL: RemotePreference<RageshakeConfiguration> { get }
@@ -54,6 +54,7 @@ final class AppSettings {
case enableInAppNotifications
case pusherProfileTag
case lastNotificationBootTime
case selectedNotificationTone
case logLevel
case traceLogPacks
case viewSourceEnabled
@@ -299,10 +300,11 @@ final class AppSettings {
/// The device's last boot time as recorded by the NSE.
@UserPreference(key: UserDefaultsKeys.lastNotificationBootTime, storageType: .userDefaults(store))
var lastNotificationBootTime: TimeInterval?
/// The name of sound played when delivering noisy notifications.
var notificationSoundName: RemotePreference<UNNotificationSoundName> = .init(.init("message.caf"))
/// The sound played when delivering noisy notifications. If nil, use the ElementX default
@UserPreference(key: UserDefaultsKeys.selectedNotificationTone, defaultValue: nil, storageType: .userDefaults(store))
var selectedNotificationTone: NotificationTone?
// MARK: - Logging
@UserPreference(key: UserDefaultsKeys.logLevel, defaultValue: LogLevel.info, storageType: .userDefaults(store))
@@ -1318,6 +1318,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let parameters = NotificationSettingsScreenCoordinatorParameters(navigationStackCoordinator: stackCoordinator,
userSession: userSession,
userNotificationCenter: UNUserNotificationCenter.current(),
userIndicatorController: flowParameters.userIndicatorController,
isModallyPresented: true,
appSettings: flowParameters.appSettings)
let coordinator = NotificationSettingsScreenCoordinator(parameters: parameters)
@@ -243,6 +243,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
let notificationParameters = NotificationSettingsScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
userSession: flowParameters.userSession,
userNotificationCenter: UNUserNotificationCenter.current(),
userIndicatorController: flowParameters.userIndicatorController,
isModallyPresented: false,
appSettings: flowParameters.appSettings)
let coordinator = NotificationSettingsScreenCoordinator(parameters: notificationParameters)
@@ -18,6 +18,74 @@ internal enum UntranslatedL10n {
internal static var cryptoHistorySharingConfirmStartChatDialogContent: String { return UntranslatedL10n.tr("Untranslated", "crypto_history_sharing_confirm_start_chat_dialog_content") }
/// Start a chat with this new contact?
internal static var cryptoHistorySharingConfirmStartChatDialogTitle: String { return UntranslatedL10n.tr("Untranslated", "crypto_history_sharing_confirm_start_chat_dialog_title") }
/// Custom sound...
internal static var screenNotificationSettingsSoundCustomSoundButtonTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_custom_sound_button_title") }
/// Error deleting file
internal static var screenNotificationSettingsSoundDeleteSoundErrorTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_delete_sound_error_title") }
/// Element Default
internal static var screenNotificationSettingsSoundElementDefault: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_element_default") }
/// Element Fade
internal static var screenNotificationSettingsSoundElementFade: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_element_fade") }
/// Error importing file
internal static var screenNotificationSettingsSoundImportSoundErrorTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_import_sound_error_title") }
/// Problem previewing alert sound
internal static var screenNotificationSettingsSoundPreviewSoundErrorTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_preview_sound_error_title") }
/// Sound
internal static var screenNotificationSettingsSoundSectionTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_section_title") }
/// Problem setting alert sound
internal static var screenNotificationSettingsSoundSetSoundErrorTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_set_sound_error_title") }
/// Alert
internal static var screenNotificationSettingsSoundSystemAlert: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_alert") }
/// Anticipate
internal static var screenNotificationSettingsSoundSystemAnticipate: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_anticipate") }
/// Bell
internal static var screenNotificationSettingsSoundSystemBell: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_bell") }
/// Bloom
internal static var screenNotificationSettingsSoundSystemBloom: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_bloom") }
/// Calypso
internal static var screenNotificationSettingsSoundSystemCalypso: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_calypso") }
/// Chime
internal static var screenNotificationSettingsSoundSystemChime: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_chime") }
/// Choo Choo
internal static var screenNotificationSettingsSoundSystemChooChoo: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_choo_choo") }
/// Descent
internal static var screenNotificationSettingsSoundSystemDescent: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_descent") }
/// Electronic
internal static var screenNotificationSettingsSoundSystemElectronic: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_electronic") }
/// Fanfare
internal static var screenNotificationSettingsSoundSystemFanfare: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_fanfare") }
/// Glass
internal static var screenNotificationSettingsSoundSystemGlass: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_glass") }
/// Horn
internal static var screenNotificationSettingsSoundSystemHorn: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_horn") }
/// Ladder
internal static var screenNotificationSettingsSoundSystemLadder: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_ladder") }
/// Minuet
internal static var screenNotificationSettingsSoundSystemMinuet: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_minuet") }
/// News Flash
internal static var screenNotificationSettingsSoundSystemNewsFlash: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_news_flash") }
/// Noir
internal static var screenNotificationSettingsSoundSystemNoir: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_noir") }
/// Sherwood Forest
internal static var screenNotificationSettingsSoundSystemSherwoodForest: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_sherwood_forest") }
/// Spell
internal static var screenNotificationSettingsSoundSystemSpell: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_spell") }
/// Suspense
internal static var screenNotificationSettingsSoundSystemSuspense: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_suspense") }
/// Swish
internal static var screenNotificationSettingsSoundSystemSwish: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_swish") }
/// Telegraph
internal static var screenNotificationSettingsSoundSystemTelegraph: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_telegraph") }
/// Tiptoes
internal static var screenNotificationSettingsSoundSystemTiptoes: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_tiptoes") }
/// Tri-tone
internal static var screenNotificationSettingsSoundSystemTriTone: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_tri_tone") }
/// Tweet
internal static var screenNotificationSettingsSoundSystemTweet: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_tweet") }
/// Typewriters
internal static var screenNotificationSettingsSoundSystemTypewriters: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_typewriters") }
/// Update
internal static var screenNotificationSettingsSoundSystemUpdate: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_sound_system_update") }
/// Clear all data currently stored on this device?
/// Sign in again to access your account data and messages.
internal static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") }
@@ -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
@@ -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
@@ -0,0 +1,64 @@
//
// Copyright 2026 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.
//
import Foundation
/// Represents a notification alert tone and its backing audio file location.
struct NotificationTone: Hashable, Comparable, Codable {
/// Display name for the tone, falling back to the filename stem.
var label: String {
labelOverride ?? URL(filePath: "/\(relativePath.last, default: "")").deletingPathExtension().lastPathComponent
}
private let labelOverride: String?
let storageLocationRoot: StorageLocation
let relativePath: [String]
/// The audio filename including its extension.
var filename: String {
relativePath.last ?? ""
}
/// - Parameters:
/// - labelOverride: Optional custom display name. If `nil`, the filename stem is used.
/// - storageLocationRoot: Where the file lives (system, bundle, or library).
/// - relativePath: Path components relative to the storage root, e.g. `["New", "Bloom.caf"]`.
init(labelOverride: String?, storageLocationRoot: StorageLocation, relativePath: [String]) {
self.labelOverride = labelOverride
self.storageLocationRoot = storageLocationRoot
self.relativePath = relativePath
}
/// Indicates which storage root backs the tone's audio file.
enum StorageLocation: Codable, Hashable {
/// The device's system sounds directory.
case system
/// The app's main bundle resources.
case appBundle
/// The app's Library directory (user-imported tones).
case appLibrary
}
/// Creates a tone backed by a file in the system sounds directory.
static func createSystemSound(label: String?, filename: String, systemSoundsSubdirectory: [String] = []) -> NotificationTone {
NotificationTone(labelOverride: label, storageLocationRoot: .system, relativePath: systemSoundsSubdirectory + [filename])
}
/// Creates a tone backed by a file in the app bundle.
static func createBundledSound(label: String?, filename: String) -> NotificationTone {
NotificationTone(labelOverride: label, storageLocationRoot: .appBundle, relativePath: [filename])
}
/// Creates a tone backed by a user-imported file in the app library.
static func createCustomUserSound(filename: String) -> NotificationTone {
NotificationTone(labelOverride: nil, storageLocationRoot: .appLibrary, relativePath: [filename])
}
static func < (lhs: NotificationTone, rhs: NotificationTone) -> Bool {
lhs.label < rhs.label
}
}
@@ -0,0 +1,301 @@
//
// Copyright 2026 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.
//
@preconcurrency import AVFoundation
import Foundation
import UniformTypeIdentifiers
/// Manages notification tone selection, import, conversion, and deletion.
struct NotificationToneManager: NotificationToneManagerProtocol {
@globalActor
actor ConversionActor {
static let shared = ConversionActor()
}
enum ManagerError: Error, Equatable {
/// The tone's file is not inside the user library directory and cannot be deleted.
case notACustomTone
/// The source file could not be accessed due to sandbox restrictions.
case couldNotAccessSandboxedResource
/// `AVAudioConverter` could not be initialised for the given format pair.
case converterSetupFailed
/// A tone with the same filename already exists in the library.
case fileAlreadyExists
/// An `AVAudioPCMBuffer` could not be allocated.
case bufferCreationFailed
}
private let appSettings: AppSettings
#if IS_MAIN_APP
/// The default Element X bundled message tone.
static let defaultElementXMessageTone: NotificationTone = .createBundledSound(label: UntranslatedL10n.screenNotificationSettingsSoundElementDefault,
filename: "message.caf")
/// All default tones (system + Element X), sorted by name.
static let allDefaultAlerts: [NotificationTone] = (defaultSystemAlerts + defaultElementXAlerts).sorted()
#endif
/// Filename of the active tone file used by the notification service.
static let selectedToneFilename = "currentAlert.caf"
/// Directory where user-imported custom tones are stored.
static let libraryLocation = URL.libraryDirectory.appending(components: "Sounds", "AvailableSounds", directoryHint: .isDirectory)
/// Creates the manager and ensures required library directories exist.
init(appSettings: AppSettings) {
self.appSettings = appSettings
do {
try FileManager.default.createDirectory(at: NotificationToneManager.libraryLocation, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: Self.selectedToneLocation.deletingLastPathComponent(), withIntermediateDirectories: true)
} catch {
fatalError("Catastrophic error setting up tone manager: \(error)")
}
}
/// Sets the given tone as the active notification alert tone.
///
/// Copies the tone's audio file to `selectedToneLocation` and persists the selection in app settings.
func setSelectedTone(_ alertTone: NotificationTone) throws -> URL {
do {
try? FileManager.default.removeItem(at: Self.selectedToneLocation)
let toneLocation = Self.toneLocation(for: alertTone)
try FileManager.default.copyItem(at: toneLocation, to: Self.selectedToneLocation)
appSettings.selectedNotificationTone = alertTone
return Self.selectedToneLocation
} catch {
if (try? Self.selectedToneLocation.checkResourceIsReachable()) != true {
// make sure the selected tone is reset if there's no custom tone present
appSettings.selectedNotificationTone = nil
}
throw error
}
}
/// Returns all user-imported CAF tones from the library directory, sorted by name.
func customTones() -> [NotificationTone] {
let availableFiles = try? FileManager
.default
.contentsOfDirectory(at: NotificationToneManager.libraryLocation, includingPropertiesForKeys: nil)
return (availableFiles ?? [])
.compactMap {
let pathExtension = $0.pathExtension
guard UTType(filenameExtension: pathExtension) == UTType("com.apple.coreaudio-format") else { return nil }
return .createCustomUserSound(filename: $0.lastPathComponent)
}
.sorted()
}
/// Imports an audio file into the tone library, converting to CAF if the source is not already CAF.
/// - Returns: The URL of the imported file in the library.
@ConversionActor
@discardableResult
func addNewToneToLibrary(from sourceURL: URL) throws -> URL {
let baseName = sourceURL.deletingPathExtension().lastPathComponent
let outputURL = NotificationToneManager.libraryLocation.appending(component: baseName).appendingPathExtension("caf")
guard (try? outputURL.checkResourceIsReachable()) != true else {
throw ManagerError.fileAlreadyExists
}
if sourceURL.pathExtension.lowercased() == "caf" {
try FileManager.default.copyItem(at: sourceURL, to: outputURL)
} else {
try convertToCAF(from: sourceURL, to: outputURL)
}
return outputURL
}
/// Removes a user-imported tone from the library.
/// - Throws: `DeletionError.notACustomTone` if the tone is not stored in the library directory.
func deleteCustomTone(_ alertTone: NotificationTone) throws {
let toneLocation = Self.toneLocation(for: alertTone)
guard toneLocation.deletingLastPathComponent() == NotificationToneManager.libraryLocation else {
throw ManagerError.notACustomTone
}
try FileManager.default.removeItem(at: toneLocation)
}
// MARK: - Private
@ConversionActor
private func convertToCAF(from sourceURL: URL, to destURL: URL) throws {
MXLog.info("Converting \(sourceURL.path(percentEncoded: false)) to caf")
let sourceFile = try AVAudioFile(forReading: sourceURL)
let outputSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: sourceFile.fileFormat.sampleRate,
AVNumberOfChannelsKey: sourceFile.fileFormat.channelCount
]
let tempURL = URL.temporaryDirectory.appending(component: destURL.lastPathComponent)
let destTempFile = try AVAudioFile(forWriting: tempURL, settings: outputSettings)
let frameCount: AVAudioFrameCount = 4096
guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: sourceFile.processingFormat, frameCapacity: frameCount) else {
MXLog.error("Error creating pcm conversion buffer: \(sourceFile.processingFormat) \(frameCount)")
throw ManagerError.bufferCreationFailed
}
do {
repeat {
try sourceFile.read(into: pcmBuffer)
guard pcmBuffer.frameLength > 0 else { break }
try destTempFile.write(from: pcmBuffer)
} while pcmBuffer.frameLength > 0 && sourceFile.framePosition < sourceFile.length
} catch {
let nsError = error as NSError
guard
// the framePosition < sourceFile.length SHOULD stop this from throwing, but as
// a defensive fallback, this just means that it reached/read past EOF
nsError.code == 0,
nsError.domain == "Foundation._GenericObjCError"
else { throw error }
}
destTempFile.close()
try FileManager.default.moveItem(at: tempURL, to: destURL)
MXLog.info("Converted \(sourceURL.path(percentEncoded: false)) to caf")
}
/// File URL of the active tone copied/linked for use by the system.
private static let selectedToneLocation = NotificationToneManager.libraryLocation.deletingLastPathComponent().appending(component: selectedToneFilename)
#if IS_MAIN_APP
/// Pre-defined iOS system tones available for selection, sorted by name.
private static let defaultSystemAlerts: [NotificationTone] = [
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemTriTone,
filename: "sms-received1.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemChime,
filename: "sms-received2.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemGlass,
filename: "sms-received3.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemHorn,
filename: "sms-received4.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemBell,
filename: "sms-received5.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemElectronic,
filename: "sms-received6.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemAlert,
filename: "alarm.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemBloom,
filename: "Bloom.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemCalypso,
filename: "Calypso.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemAnticipate,
filename: "Anticipate.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemChooChoo,
filename: "Choo_Choo.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemDescent,
filename: "Descent.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemFanfare,
filename: "Fanfare.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemLadder,
filename: "Ladder.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemMinuet,
filename: "Minuet.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemNewsFlash,
filename: "News_Flash.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemNoir,
filename: "Noir.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemSherwoodForest,
filename: "Sherwood_Forest.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemSpell,
filename: "Spell.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemSuspense,
filename: "Suspense.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemTelegraph,
filename: "Telegraph.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemTiptoes,
filename: "Tiptoes.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemTypewriters,
filename: "Typewriters.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemUpdate,
filename: "Update.caf",
systemSoundsSubdirectory: ["New"]),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemSwish,
filename: "Swish.caf"),
.createSystemSound(label: UntranslatedL10n.screenNotificationSettingsSoundSystemTweet,
filename: "tweet_sent.caf")
]
.compactMap { (alertTone: NotificationTone) -> NotificationTone? in
let toneLocation = toneLocation(for: alertTone)
guard (try? toneLocation.checkResourceIsReachable()) == true else {
return nil
}
return alertTone
}
.sorted()
/// Element X bundled tones available for selection, sorted by name.
private static let defaultElementXAlerts: [NotificationTone] = [
defaultElementXMessageTone,
.createBundledSound(label: UntranslatedL10n.screenNotificationSettingsSoundElementFade,
filename: "sound_01.caf")
].sorted()
#endif
private static let systemLocation = {
let systemRoot: URL
if let simulatorRoot = ProcessInfo.processInfo.environment["SIMULATOR_ROOT"] {
systemRoot = URL(filePath: simulatorRoot)
} else {
systemRoot = URL(filePath: "/")
}
return systemRoot.appending(components: "System", "Library", "Audio", "UISounds", directoryHint: .isDirectory)
}()
private static let bundledLocation: URL = {
guard let url = Bundle.app.resourceURL else {
fatalError("The app is seriously corrupt if resourceURL is missing.")
}
return url
}()
private static func toneLocation(for tone: NotificationTone) -> URL {
let root: URL
switch tone.storageLocationRoot {
case .system:
root = Self.systemLocation
case .appBundle:
root = Self.bundledLocation
case .appLibrary:
root = Self.libraryLocation
}
return tone.relativePath.reduce(root) {
$0.appending(component: $1)
}
}
}
@@ -0,0 +1,20 @@
//
// Copyright 2026 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.
//
import Foundation
// sourcery: AutoMockable
protocol NotificationToneManagerProtocol {
@discardableResult
func setSelectedTone(_ alertTone: NotificationTone) throws -> URL
func customTones() -> [NotificationTone]
func deleteCustomTone(_ alertTone: NotificationTone) throws
@NotificationToneManager.ConversionActor
@discardableResult
func addNewToneToLibrary(from sourceURL: URL) throws -> URL
}
@@ -0,0 +1,25 @@
//
// Copyright 2026 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.
//
import UserNotifications
extension CommonSettingsProtocol {
/// The sound name to use in outgoing notifications.
/// Falls back to the default Element X tone if no custom tone has been selected.
var notificationSoundName: UNNotificationSoundName {
if selectedNotificationTone == nil {
return UNNotificationSoundName("message.caf")
} else {
return UNNotificationSoundName(NotificationToneManager.selectedToneFilename)
}
}
/// A `UNNotificationSound` built from `notificationSoundName`, ready to attach to a notification content object.
var notificationSound: UNNotificationSound {
UNNotificationSound(named: notificationSoundName)
}
}
+2 -2
View File
@@ -35,9 +35,9 @@ class NotificationHandler {
let eventStringBuilder = RoomMessageEventStringBuilder(attributedStringBuilder: AttributedStringBuilder(mentionBuilder: PlainMentionBuilder()),
style: .plain)
notificationContentBuilder = NotificationContentBuilder(messageEventStringBuilder: eventStringBuilder,
notificationSoundName: settings.notificationSoundName.publisher.value,
notificationSoundName: settings.notificationSoundName,
userSession: userSession)
}
@@ -231,8 +231,8 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
let content = UNMutableNotificationContent()
content.body = L10n.notificationReceivedWhileOfflineIos
content.badge = originalRequest.content.unreadCount as NSNumber?
content.sound = .init(named: settings.notificationSoundName.publisher.value)
content.sound = settings.notificationSound
let request = UNNotificationRequest(identifier: Self.receivedWhileOfflineNotificationID, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
+4 -1
View File
@@ -76,7 +76,7 @@ targets:
SWIFT_OBJC_INTERFACE_HEADER_NAME: GeneratedInterface-Swift.h
OTHER_SWIFT_FLAGS:
- "-DIS_NSE"
sources:
- path: ../Sources
- path: ../SupportingFiles
@@ -118,6 +118,9 @@ targets:
- path: ../../ElementX/Sources/Other/SwiftUI/Views/PlaceholderAvatarImage.swift
- path: ../../ElementX/Sources/Other/TestablePreview/TestablePreview.swift
- path: ../../ElementX/Sources/Other/UserAgentBuilder.swift
- path: ../../ElementX/Sources/Services/AlertTones/NotificationTone.swift
- path: ../../ElementX/Sources/Services/AlertTones/NotificationToneManager.swift
- path: ../../ElementX/Sources/Services/AlertTones/NotificationToneManagerProtocol.swift
- path: ../../ElementX/Sources/Services/ElementCall/ElementCallServiceConstants.swift
- path: ../../ElementX/Sources/Services/Keychain/KeychainController.swift
- path: ../../ElementX/Sources/Services/Keychain/KeychainControllerProtocol.swift
@@ -87,6 +87,7 @@ targets:
- path: ../../Secrets/Secrets.swift
- path: ../../ElementX/Sources/Application/Settings
- path: ../../ElementX/Sources/Application/TargetConfiguration.swift
- path: ../../ElementX/Sources/Services/AlertTones/NotificationTone.swift
- path: ../../ElementX/Sources/Services/Keychain/KeychainController.swift
- path: ../../ElementX/Sources/Services/Keychain/KeychainControllerProtocol.swift
- path: ../../ElementX/Sources/Services/UserSession/RestorationToken.swift
@@ -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)
}
}
}