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 000000000..0ceb055d3 Binary files /dev/null and b/ElementX/Resources/Sounds/sound_01.caf differ diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 82a37cc7e..f8770b903 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -16,8 +16,8 @@ import SwiftUI /// Common settings between app and NSE protocol CommonSettingsProtocol: AnyObject { var lastNotificationBootTime: TimeInterval? { get set } - var notificationSoundName: RemotePreference { 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) + } + } +}