diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index e808be9dd41..833c5dab766 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -296,6 +296,7 @@ 3402AAB1271D9E180084CBAE /* OWSNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95544271B510400B05242 /* OWSNavigationBar.swift */; }; 3402AAB2271D9E180084CBAE /* ContactTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95536271B510400B05242 /* ContactTableViewCell.swift */; }; 3402AAB3271D9E180084CBAE /* CVTextLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95522271B510400B05242 /* CVTextLabel.swift */; }; + 5ECA11E62D1A000000000001 /* CVTextTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ECA11E52D1A000000000001 /* CVTextTest.swift */; }; 3402AAB4271D9E180084CBAE /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95519271B510400B05242 /* VideoPlayerView.swift */; }; 3402AAB6271D9E180084CBAE /* ImageEditorCropViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95556271B510400B05242 /* ImageEditorCropViewController.swift */; }; 3405C06E25435048008B24EF /* ConversationViewController+Banners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3405C06D25435048008B24EF /* ConversationViewController+Banners.swift */; }; @@ -4838,6 +4839,7 @@ 34A95523271B510400B05242 /* CVCellMeasurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVCellMeasurement.swift; sourceTree = ""; }; 34A95524271B510400B05242 /* CVUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVUtils.swift; sourceTree = ""; }; 34A95525271B510400B05242 /* CVText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVText.swift; sourceTree = ""; }; + 5ECA11E52D1A000000000001 /* CVTextTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVTextTest.swift; sourceTree = ""; }; 34A95526271B510400B05242 /* DirectionalPanGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectionalPanGestureRecognizer.swift; sourceTree = ""; }; 34A95529271B510400B05242 /* LinkingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkingTextView.swift; sourceTree = ""; }; 34A9552C271B510400B05242 /* MentionPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionPicker.swift; sourceTree = ""; }; @@ -9665,6 +9667,7 @@ 764981442A23194F00EC1287 /* CVItemViewModel.swift */, 34A95525271B510400B05242 /* CVText.swift */, 34A95522271B510400B05242 /* CVTextLabel.swift */, + 5ECA11E52D1A000000000001 /* CVTextTest.swift */, 34A95524271B510400B05242 /* CVUtils.swift */, ); path = ConversationView; @@ -18780,6 +18783,7 @@ 63C24D53306C50FD00D4C167 /* ContactShareAvatarFieldTests.swift in Sources */, A20EC336FE7D969EBAA7B99E /* ContactSharingPickerViewModelTests.swift in Sources */, 848961B553640CEDA15C38D9 /* ContactSharingSystemContactWrapperTests.swift in Sources */, + 5ECA11E62D1A000000000001 /* CVTextTest.swift in Sources */, 50BF51082BB2030C00C2C309 /* FormattedNumberFieldTest.swift in Sources */, 509085BA2C498C4400409B85 /* HTMLMetadataTests.swift in Sources */, 5073EACB2C4F3A16001FBB3E /* LinkPreviewFetcherTest.swift in Sources */, diff --git a/Signal/src/ViewControllers/AppSettings/Notifications/NotificationSettingsSoundViewController.swift b/Signal/src/ViewControllers/AppSettings/Notifications/NotificationSettingsSoundViewController.swift index 6eaf20c388a..604d254f13e 100644 --- a/Signal/src/ViewControllers/AppSettings/Notifications/NotificationSettingsSoundViewController.swift +++ b/Signal/src/ViewControllers/AppSettings/Notifications/NotificationSettingsSoundViewController.swift @@ -83,12 +83,34 @@ class NotificationSettingsSoundViewController: OWSTableViewController2 { soundName = sound.displayName } + let contextMenuActionProvider: UIContextMenuActionProvider? + if case .custom = sound { + contextMenuActionProvider = { [weak self] _ in + guard let self else { return nil } + return UIMenu(children: [ + UIAction( + title: OWSLocalizedString( + "NOTIFICATIONS_DELETE_CUSTOM_SOUND_ACTION", + comment: "Context menu action that deletes a custom notification sound.", + ), + image: UIImage(systemName: "trash"), + attributes: .destructive, + ) { _ in + self.didTapDeleteCustomSound(sound) + }, + ]) + } + } else { + contextMenuActionProvider = nil + } + section.add(.init( text: soundName, actionBlock: { [weak self] in self?.soundWasSelected(sound) }, accessoryType: sound == notificationSound ? .checkmark : .none, + contextMenuActionProvider: contextMenuActionProvider, )) } @@ -151,6 +173,40 @@ class NotificationSettingsSoundViewController: OWSTableViewController2 { }) } + private func didTapDeleteCustomSound(_ sound: Sound) { + OWSActionSheets.showConfirmationAlert( + title: String.nonPluralLocalizedStringWithFormat( + OWSLocalizedString( + "NOTIFICATIONS_DELETE_CUSTOM_SOUND_CONFIRMATION_FORMAT", + comment: "Title of the confirmation alert shown before deleting a custom notification sound. Embeds the {{sound name}}.", + ), + sound.displayName, + ), + proceedTitle: OWSLocalizedString( + "NOTIFICATIONS_DELETE_CUSTOM_SOUND_ACTION", + comment: "Context menu action that deletes a custom notification sound.", + ), + proceedStyle: .destructive, + proceedAction: { [weak self] _ in + self?.deleteCustomSound(sound) + }, + fromViewController: self, + ) + } + + private func deleteCustomSound(_ sound: Sound) { + player?.stop() + player = nil + + Sounds.deleteCustomSound(sound) + + if notificationSound == sound { + notificationSound = NotificationPreferencesManager.Defaults.globalNotificationSound + } + updateTableContents() + updateNavigation() + } + private func didTapDone() { if let thread { Sounds.setNotificationSound(notificationSound, forThread: thread) diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index e6d56e359ff..a7acc92f6d2 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -6520,6 +6520,12 @@ /* Label for settings UI that allows user to add a new notification sound. */ "NOTIFICATIONS_SECTION_SOUNDS_ADD_CUSTOM_SOUND" = "Add custom sound…"; +/* Context menu action that deletes a custom notification sound. */ +"NOTIFICATIONS_DELETE_CUSTOM_SOUND_ACTION" = "Delete Sound"; + +/* Title of the confirmation alert shown before deleting a custom notification sound. Embeds the {{sound name}}. */ +"NOTIFICATIONS_DELETE_CUSTOM_SOUND_CONFIRMATION_FORMAT" = "Delete %@?"; + /* No comment provided by engineer. */ "NOTIFICATIONS_SENDER_AND_MESSAGE" = "Name, Content, and Actions"; diff --git a/SignalServiceKit/Util/Sounds.swift b/SignalServiceKit/Util/Sounds.swift index 0b2d29d3da1..e7849bebc65 100644 --- a/SignalServiceKit/Util/Sounds.swift +++ b/SignalServiceKit/Util/Sounds.swift @@ -445,6 +445,36 @@ public class Sounds { keyValueStore.removeAll(transaction: tx) } + /// Deletes the given custom sound, removing its file and clearing any + /// references to it in the per-thread and global notification sound + /// settings, so that they fall back to the default sound. + @discardableResult + public static func deleteCustomSound(_ sound: Sound) -> Bool { + guard case .custom(let customSound) = sound else { + owsFailDebug("Invalid sound.") + return false + } + + SSKEnvironment.shared.databaseStorageRef.write { tx in + keyValueStore.removeValues( + forKeys: keyValueStore.allKeys(transaction: tx).filter { + keyValueStore.getUInt64($0, transaction: tx) == customSound.id + }, + transaction: tx, + ) + + let notificationPreferencesManager = DependenciesBridge.shared.notificationPreferencesManager + if notificationPreferencesManager.globalNotificationSound(tx: tx).id == customSound.id { + notificationPreferencesManager.setGlobalNotificationSound( + NotificationPreferencesManager.Defaults.globalNotificationSound, + tx: tx, + ) + } + } + + return deleteCustomSound(customSound) + } + // MARK: - Util private static func deleteCustomSound(_ sound: CustomSound) -> Bool { diff --git a/SignalServiceKit/tests/Util/SoundsTest.swift b/SignalServiceKit/tests/Util/SoundsTest.swift index b727094b05d..9add196fe77 100644 --- a/SignalServiceKit/tests/Util/SoundsTest.swift +++ b/SignalServiceKit/tests/Util/SoundsTest.swift @@ -5,6 +5,7 @@ import Foundation import Testing +import XCTest @testable import SignalServiceKit struct SoundsTest { @@ -16,3 +17,57 @@ struct SoundsTest { #expect(CustomSound.idFromFilename(testCase.filename) == testCase.id) } } + +final class DeleteCustomSoundTest: SSKBaseTest { + + private func writeTestSoundFile(_ filename: String) throws -> URL { + let soundUrl = URL(fileURLWithPath: Sounds.soundsDirectory, isDirectory: true) + .appendingPathComponent(filename) + try Data([0x00]).write(to: soundUrl) + addTeardownBlock { + try? FileManager.default.removeItem(at: soundUrl) + } + return soundUrl + } + + func testDeleteCustomSound() throws { + let filename = "TestDeleteCustomSound.aifc" + let soundUrl = try writeTestSoundFile(filename) + + let sound = Sound.custom(CustomSound(filename: filename)) + XCTAssertTrue(Sounds.allNotificationSounds.contains(sound)) + + Sounds.deleteCustomSound(sound) + + XCTAssertFalse(FileManager.default.fileExists(atPath: soundUrl.path)) + XCTAssertFalse(Sounds.allNotificationSounds.contains(sound)) + } + + func testDeleteCustomSoundClearsReferences() throws { + let filename = "TestDeleteReferencedSound.aifc" + _ = try writeTestSoundFile(filename) + + let sound = Sound.custom(CustomSound(filename: filename)) + let notificationPreferencesManager = DependenciesBridge.shared.notificationPreferencesManager + + let originalSound = SSKEnvironment.shared.databaseStorageRef.read { tx in + notificationPreferencesManager.globalNotificationSound(tx: tx) + } + addTeardownBlock { + SSKEnvironment.shared.databaseStorageRef.write { tx in + notificationPreferencesManager.setGlobalNotificationSound(originalSound, tx: tx) + } + } + + write { tx in + notificationPreferencesManager.setGlobalNotificationSound(sound, tx: tx) + } + + Sounds.deleteCustomSound(sound) + + let globalSound = SSKEnvironment.shared.databaseStorageRef.read { tx in + notificationPreferencesManager.globalNotificationSound(tx: tx) + } + XCTAssertEqual(globalSound, NotificationPreferencesManager.Defaults.globalNotificationSound) + } +} diff --git a/SignalUI/ConversationView/CVText.swift b/SignalUI/ConversationView/CVText.swift index 8a18590e871..c477a3cddf9 100644 --- a/SignalUI/ConversationView/CVText.swift +++ b/SignalUI/ConversationView/CVText.swift @@ -325,8 +325,7 @@ public class CVText { return result.ceil } -#if TESTABLE_BUILD - public static func measureLabelUsingView(config: CVLabelConfig, maxWidth: CGFloat) -> CGSize { + private static func measureLabelUsingView(config: CVLabelConfig, maxWidth: CGFloat) -> CGSize { guard !config.text.isEmpty else { return .zero } @@ -337,7 +336,6 @@ public class CVText { size.width = min(size.width, maxWidth) return size } -#endif static func measureLabelUsingLayoutManager(config: CVLabelConfig, maxWidth: CGFloat) -> CGSize { guard !config.text.isEmpty else { @@ -347,7 +345,17 @@ public class CVText { textContainer.maximumNumberOfLines = config.numberOfLines textContainer.lineBreakMode = config.lineBreakMode textContainer.lineFragmentPadding = 0 - return textContainer.size(for: config.text, font: config.font) + let result = textContainer.size(for: config.text, font: config.font) + + // On some iOS versions, `usedRect(for:)` can fail to wrap long text + // and return a width larger than the container's, which would let the + // content stretch beyond its maximum width. Fall back to measuring + // with a UILabel, which wraps correctly. + guard result.width <= maxWidth else { + owsAssertDebug(false, "Layout manager measurement exceeded maxWidth.") + return measureLabelUsingView(config: config, maxWidth: maxWidth) + } + return result } // MARK: - CVTextLabel diff --git a/SignalUI/ConversationView/CVTextTest.swift b/SignalUI/ConversationView/CVTextTest.swift new file mode 100644 index 00000000000..88903b797e2 --- /dev/null +++ b/SignalUI/ConversationView/CVTextTest.swift @@ -0,0 +1,31 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import XCTest +@testable import SignalUI + +final class CVTextTest: XCTestCase { + + /// Long text (e.g. some translations of the poll subtitle) must wrap to + /// fit `maxWidth` instead of being measured at its unwrapped width, which + /// would let message bubbles stretch beyond their maximum width. + func testMeasureLabelWrapsLongText() { + let config = CVLabelConfig.unstyledText( + "Umfrage · Wähle eine oder mehrere Optionen aus", + font: .dynamicTypeFootnote, + textColor: .label, + numberOfLines: 0, + lineBreakMode: .byWordWrapping, + ) + let maxWidth: CGFloat = 200 + + let size = CVText.measureLabel(config: config, maxWidth: maxWidth) + + XCTAssertLessThanOrEqual(size.width, maxWidth) + // The string is much wider than maxWidth on a single line, so it must + // have wrapped onto multiple lines. + XCTAssertGreaterThan(size.height, config.font.lineHeight) + } +} diff --git a/SignalUI/ViewControllers/OWSTableItem.swift b/SignalUI/ViewControllers/OWSTableItem.swift index 822e875de66..bf8b9c4697b 100644 --- a/SignalUI/ViewControllers/OWSTableItem.swift +++ b/SignalUI/ViewControllers/OWSTableItem.swift @@ -64,6 +64,7 @@ public class OWSTableItem { accessoryText: String? = nil, accessoryType: UITableViewCell.AccessoryType, accessibilityIdentifier: String? = nil, + contextMenuActionProvider: UIContextMenuActionProvider? = nil, ) { self.init( customCellBlock: { @@ -76,6 +77,7 @@ public class OWSTableItem { ) }, actionBlock: actionBlock, + contextMenuActionProvider: contextMenuActionProvider, ) }