From dd0de4d7b0396dbd1c9ec0b715817a88468f8e6c Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 20 May 2026 07:40:34 -0500 Subject: [PATCH] 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 --- ElementX.xcodeproj/project.pbxproj | 42 +++ .../en.lproj/Untranslated.strings | 34 ++ ElementX/Resources/Sounds/sound_01.caf | Bin 0 -> 109156 bytes .../Application/Settings/AppSettings.swift | 14 +- .../RoomFlowCoordinator.swift | 1 + .../SettingsFlowCoordinator.swift | 1 + .../Generated/Strings+Untranslated.swift | 68 ++++ .../Mocks/Generated/GeneratedMocks.swift | 263 +++++++++++++++ ...otificationSettingsScreenCoordinator.swift | 5 +- .../NotificationSettingsScreenModels.swift | 7 + .../NotificationSettingsScreenViewModel.swift | 97 +++++- .../View/NotificationSettingsScreen.swift | 69 +++- .../AlertTones/NotificationTone.swift | 64 ++++ .../AlertTones/NotificationToneManager.swift | 301 ++++++++++++++++++ .../NotificationToneManagerProtocol.swift | 20 ++ .../CommonSettings+NotificationName.swift | 25 ++ NSE/Sources/NotificationHandler.swift | 4 +- .../NotificationServiceExtension.swift | 4 +- NSE/SupportingFiles/target.yml | 5 +- ShareExtension/SupportingFiles/target.yml | 1 + ...ficationSettingsScreenViewModelTests.swift | 106 ++++++ .../NotificationToneManagerTests.swift | 99 ++++++ 22 files changed, 1213 insertions(+), 17 deletions(-) create mode 100644 ElementX/Resources/Sounds/sound_01.caf create mode 100644 ElementX/Sources/Services/AlertTones/NotificationTone.swift create mode 100644 ElementX/Sources/Services/AlertTones/NotificationToneManager.swift create mode 100644 ElementX/Sources/Services/AlertTones/NotificationToneManagerProtocol.swift create mode 100644 NSE/Sources/CommonSettings+NotificationName.swift create mode 100644 UnitTests/Sources/NotificationToneManagerTests.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 1ecd6d234..b464a77ad 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; 5A70B03471F6027C90EE868C /* RoomMembersFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersFlowCoordinator.swift; sourceTree = ""; }; 5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenCoordinator.swift; sourceTree = ""; }; + 5B2A2B168C8DC14A1A9D1C64 /* NotificationToneManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationToneManagerProtocol.swift; sourceTree = ""; }; 5B8F0ED874DF8C9A51B0AB6F /* SettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenCoordinator.swift; sourceTree = ""; }; 5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModelTests.swift; sourceTree = ""; }; 5C60559596897AC65D2CB799 /* LiveLocationSharingBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationSharingBannerView.swift; sourceTree = ""; }; @@ -2254,6 +2266,7 @@ 72C4A2D279386A811BDC6DAE /* LinkNewDeviceScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceScreen.swift; sourceTree = ""; }; 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderProtocol.swift; sourceTree = ""; }; 7310D8DFE01AF45F0689C3AA /* Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.swift; sourceTree = ""; }; + 7349B71A67B765AA86002723 /* sound_01.caf */ = {isa = PBXFileReference; path = sound_01.caf; sourceTree = ""; }; 7367B3B9A8CAF902220F31D1 /* BugReportFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportFlowCoordinator.swift; sourceTree = ""; }; 739077686814E4EA339B1C83 /* RoomPreviewProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPreviewProxyProtocol.swift; sourceTree = ""; }; 7396A6491DEC64FCB66F60A1 /* RoomThreadListServiceProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomThreadListServiceProxyMock.swift; sourceTree = ""; }; @@ -2575,6 +2588,7 @@ AB07F03461023BC39C730922 /* PhishingDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhishingDetector.swift; sourceTree = ""; }; AB26D5444A4A7E095222DE8B /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.stringsdict"; sourceTree = ""; }; AB389C38BD41EB3E47092CFB /* AccessibilityTests.xctestplan */ = {isa = PBXFileReference; path = AccessibilityTests.xctestplan; sourceTree = ""; }; + AB7A66212DC7D097886BD01F /* CommonSettings+NotificationName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommonSettings+NotificationName.swift"; sourceTree = ""; }; ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelProtocol.swift; sourceTree = ""; }; ABF84AA68B2B7584D9275769 /* VoiceMessageTrashButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageTrashButton.swift; sourceTree = ""; }; AC0275CEE9CA078B34028BDF /* AppLockScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModelTests.swift; sourceTree = ""; }; @@ -2672,6 +2686,7 @@ B902EA6CD3296B0E10EE432B /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; B91AD590B0B40718A0AA0C61 /* DeferredFulfillment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredFulfillment.swift; sourceTree = ""; }; B9406BD5F99685C5ABDEDD93 /* LocationShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationShareSheet.swift; sourceTree = ""; }; + B94A6A57FE7E71F7605070AA /* NotificationToneManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationToneManager.swift; sourceTree = ""; }; B99E13633862847D8B7E2815 /* StartChatScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenModels.swift; sourceTree = ""; }; BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferenceTests.swift; sourceTree = ""; }; BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceServiceMock.swift; sourceTree = ""; }; @@ -2698,6 +2713,7 @@ BEBA759D1347CFFB3D84ED1F /* UserSessionStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStoreProtocol.swift; sourceTree = ""; }; BEE365C5A4E90ACBE398EFFE /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/SAS.strings; sourceTree = ""; }; BEF5FE93A06F563B477F024A /* RoomAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomAvatarImage.swift; sourceTree = ""; }; + BF08D6FC1919CB804A9191E8 /* NotificationTone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTone.swift; sourceTree = ""; }; BF34A2FD6797535C95AC918D /* PlaceholderScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderScreenCoordinator.swift; sourceTree = ""; }; BF8349149254469E24483FB0 /* SpaceScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceScreenViewModel.swift; sourceTree = ""; }; BFA9EA59D5C0DA1BFC7B3621 /* QRCodeLoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginScreen.swift; sourceTree = ""; }; @@ -2793,6 +2809,7 @@ D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = ""; }; D09227E671DB30795C43FFFD /* KnockRequestsListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestsListScreenViewModel.swift; sourceTree = ""; }; D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemViewState.swift; sourceTree = ""; }; + D148332A613AD27E9A2896C9 /* NotificationToneManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationToneManagerTests.swift; sourceTree = ""; }; D17F49E39CC38DAB7B305701 /* LiveLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationManager.swift; sourceTree = ""; }; D1896F6288D80E1F3EFB3DF8 /* ka */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ka; path = ka.lproj/Localizable.stringsdict; sourceTree = ""; }; D196116D2DD3F2757D45FCB7 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/SAS.strings; sourceTree = ""; }; @@ -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 = ""; }; + 80D2614A3BEBB68232559A52 /* AlertTones */ = { + isa = PBXGroup; + children = ( + BF08D6FC1919CB804A9191E8 /* NotificationTone.swift */, + B94A6A57FE7E71F7605070AA /* NotificationToneManager.swift */, + 5B2A2B168C8DC14A1A9D1C64 /* NotificationToneManagerProtocol.swift */, + ); + path = AlertTones; + sourceTree = ""; + }; 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 = ""; @@ -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 */, diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index b14794500..49ef1d2d3 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -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"; diff --git a/ElementX/Resources/Sounds/sound_01.caf b/ElementX/Resources/Sounds/sound_01.caf new file mode 100644 index 0000000000000000000000000000000000000000..0ceb055d33dcd031e880345a11575b859d99f8cf GIT binary patch literal 109156 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 { get } - + var selectedNotificationTone: NotificationTone? { get set } + var logLevel: LogLevel { get } var traceLogPacks: Set { get } var bugReportRageshakeURL: RemotePreference { 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 = .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)) diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index dfbfb5a0a..d6736f97b 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -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) diff --git a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift index eabcf0e7e..5fea12afb 100644 --- a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 8dd86a2ec..1a40d4028 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -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") } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 65e35f1c0..452cf5e20 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -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 diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenCoordinator.swift index 913066ecc..345836a24 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenCoordinator.swift @@ -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) } diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenModels.swift index bd5f90789..823e843f7 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenModels.swift @@ -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? + 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) + case deleteCustomAlertTones([NotificationTone]) } enum NotificationSettingsScreenErrorType: Hashable { diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift index 2121d8675..6f2b8590b 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/NotificationSettingsScreenViewModel.swift @@ -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? - + private let notificationTonePreviewer: AudioPlayerProtocol + private let notificationToneManager: NotificationToneManagerProtocol + var actions: AnyPublisher { 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) 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 { diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift index 3d9ddcda3..4bf468e29 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift @@ -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 diff --git a/ElementX/Sources/Services/AlertTones/NotificationTone.swift b/ElementX/Sources/Services/AlertTones/NotificationTone.swift new file mode 100644 index 000000000..fa064fb5d --- /dev/null +++ b/ElementX/Sources/Services/AlertTones/NotificationTone.swift @@ -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 + } +} diff --git a/ElementX/Sources/Services/AlertTones/NotificationToneManager.swift b/ElementX/Sources/Services/AlertTones/NotificationToneManager.swift new file mode 100644 index 000000000..7913dc168 --- /dev/null +++ b/ElementX/Sources/Services/AlertTones/NotificationToneManager.swift @@ -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) + } + } +} diff --git a/ElementX/Sources/Services/AlertTones/NotificationToneManagerProtocol.swift b/ElementX/Sources/Services/AlertTones/NotificationToneManagerProtocol.swift new file mode 100644 index 000000000..f5adea372 --- /dev/null +++ b/ElementX/Sources/Services/AlertTones/NotificationToneManagerProtocol.swift @@ -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 +} diff --git a/NSE/Sources/CommonSettings+NotificationName.swift b/NSE/Sources/CommonSettings+NotificationName.swift new file mode 100644 index 000000000..4a90e58ae --- /dev/null +++ b/NSE/Sources/CommonSettings+NotificationName.swift @@ -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) + } +} diff --git a/NSE/Sources/NotificationHandler.swift b/NSE/Sources/NotificationHandler.swift index 347c85327..729dfdf29 100644 --- a/NSE/Sources/NotificationHandler.swift +++ b/NSE/Sources/NotificationHandler.swift @@ -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) } diff --git a/NSE/Sources/NotificationServiceExtension.swift b/NSE/Sources/NotificationServiceExtension.swift index 1d8f001b8..bc65eaffa 100644 --- a/NSE/Sources/NotificationServiceExtension.swift +++ b/NSE/Sources/NotificationServiceExtension.swift @@ -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) } diff --git a/NSE/SupportingFiles/target.yml b/NSE/SupportingFiles/target.yml index 307613c4c..82bd0574a 100644 --- a/NSE/SupportingFiles/target.yml +++ b/NSE/SupportingFiles/target.yml @@ -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 diff --git a/ShareExtension/SupportingFiles/target.yml b/ShareExtension/SupportingFiles/target.yml index f23aa6046..6eba6721f 100644 --- a/ShareExtension/SupportingFiles/target.yml +++ b/ShareExtension/SupportingFiles/target.yml @@ -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 diff --git a/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift b/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift index 97ef5d465..5761455c1 100644 --- a/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift +++ b/UnitTests/Sources/NotificationSettingsScreenViewModelTests.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") diff --git a/UnitTests/Sources/NotificationToneManagerTests.swift b/UnitTests/Sources/NotificationToneManagerTests.swift new file mode 100644 index 000000000..cb9eab0f7 --- /dev/null +++ b/UnitTests/Sources/NotificationToneManagerTests.swift @@ -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) + } + } +}