diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 7b49370246..8065baf192 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -2702,6 +2702,8 @@ D925F563298D8F9400158EE4 /* Usernames.swift in Sources */ = {isa = PBXBuildFile; fileRef = D99840BE297A04EB00F7ED6D /* Usernames.swift */; }; D927372D2CD2DD1800E15D95 /* StorageServiceRecordIkmMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D927372C2CD2DD0D00E15D95 /* StorageServiceRecordIkmMigrator.swift */; }; D92812DC2FA545E200667DCF /* CallingAssetsFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F588CA982FA088B700693838 /* CallingAssetsFetcher.swift */; }; + D92812DE2FA94B7200667DCF /* DisplayableAccountEntropyPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92812DD2FA94B6E00667DCF /* DisplayableAccountEntropyPool.swift */; }; + D92812E22FA95C1400667DCF /* DisplayableAccountEntropyPoolTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92812E12FA95C0D00667DCF /* DisplayableAccountEntropyPoolTest.swift */; }; D92A1CDA2E314BD400C91E21 /* DebugUIPrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92A1CD92E314BD000C91E21 /* DebugUIPrompts.swift */; }; D92AB7D829E3BEE30081CA7D /* OWSDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92AB7D729E3BEE30081CA7D /* OWSDeviceManager.swift */; }; D92C57552A2925AD00A03BB7 /* TSInfoMessage+DisplayableGroupUpdateItemTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92C57542A2925AD00A03BB7 /* TSInfoMessage+DisplayableGroupUpdateItemTest.swift */; }; @@ -6990,6 +6992,8 @@ D925F566298DC33E00158EE4 /* MockUsernameLookupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUsernameLookupManager.swift; sourceTree = ""; }; D925F56F298DF41B00158EE4 /* UsernameLookupRecordTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameLookupRecordTest.swift; sourceTree = ""; }; D927372C2CD2DD0D00E15D95 /* StorageServiceRecordIkmMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageServiceRecordIkmMigrator.swift; sourceTree = ""; }; + D92812DD2FA94B6E00667DCF /* DisplayableAccountEntropyPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayableAccountEntropyPool.swift; sourceTree = ""; }; + D92812E12FA95C0D00667DCF /* DisplayableAccountEntropyPoolTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayableAccountEntropyPoolTest.swift; sourceTree = ""; }; D92A1CD92E314BD000C91E21 /* DebugUIPrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugUIPrompts.swift; sourceTree = ""; }; D92AB7D729E3BEE30081CA7D /* OWSDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSDeviceManager.swift; sourceTree = ""; }; D92C57542A2925AD00A03BB7 /* TSInfoMessage+DisplayableGroupUpdateItemTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+DisplayableGroupUpdateItemTest.swift"; sourceTree = ""; }; @@ -13572,6 +13576,7 @@ D951F5302D9B236100C5EBF3 /* BackupSettingsViewController.swift */, D92CB5552F030F7A00537EBE /* BackupSubscriptionAlreadyRedeemedSheet.swift */, D9680CF22DBB0B28005DD5BE /* ChooseBackupPlanViewController.swift */, + D92812DD2FA94B6E00667DCF /* DisplayableAccountEntropyPool.swift */, D98CA2BD2DF398DF0060370E /* EnterAccountEntropyPoolViewController.swift */, ); path = Backups; @@ -13890,6 +13895,7 @@ D9324F7B2E1492820095D104 /* BackupAttachmentTrackerTest.swift */, D9A952452E0A1C3C00C3D1F7 /* BackupAttachmentUploadTrackerTest.swift */, 04B975472E43BFE000E20364 /* BackupRefreshManagerTest.swift */, + D92812E12FA95C0D00667DCF /* DisplayableAccountEntropyPoolTest.swift */, ); path = Backups; sourceTree = ""; @@ -18409,6 +18415,7 @@ C147C1722D9C58D60026952D /* DeviceTransferStatusViewController.swift in Sources */, D900E6FF2DE143F9004D01A1 /* DisappearingMessagesCustomTimePickerViewController.swift in Sources */, D900E6F82DE10499004D01A1 /* DisappearingMessagesTimerSettingsViewController.swift in Sources */, + D92812DE2FA94B7200667DCF /* DisplayableAccountEntropyPool.swift in Sources */, 34A95501271B503E00B05242 /* DisplayableText.swift in Sources */, 5011D1CB293FC7E000064098 /* DomainFrontingCountryViewController.swift in Sources */, F96B66AA2912B88B004FFFAA /* DonateChoosePaymentMethodSheet.swift in Sources */, @@ -18881,6 +18888,7 @@ F93999F628C81F2100E34899 /* DataMessagePaddingTests.swift in Sources */, 3494BBE026E66FC30079B11B /* DateUtilTest.swift in Sources */, C17A54962D7B3D9E00E1D267 /* DeviceProvisioningURLTest.swift in Sources */, + D92812E22FA95C1400667DCF /* DisplayableAccountEntropyPoolTest.swift in Sources */, 45E7A6A81E71CA7E00D44FB5 /* DisplayableTextFilterTest.swift in Sources */, F90B7BC12912B90100F50A59 /* DonateViewControllerTest.swift in Sources */, F99D2C8B2926F0DD00748CCB /* DonationPaymentDetailsViewControllerTest.swift in Sources */, diff --git a/Signal/Backups/AccountEntropyPoolTextView.swift b/Signal/Backups/AccountEntropyPoolTextView.swift index b520e99c3e..e9e74681ce 100644 --- a/Signal/Backups/AccountEntropyPoolTextView.swift +++ b/Signal/Backups/AccountEntropyPoolTextView.swift @@ -6,10 +6,16 @@ import SignalServiceKit import SignalUI -class AccountEntropyPoolTextView: UIView { +class AccountEntropyPoolTextView: UIView, TextViewWithPlaceholderDelegate { enum Mode { case entry(onTextViewChanged: () -> Void) - case display(aep: AccountEntropyPool) + case display(DisplayableAccountEntropyPool) + } + + enum AEPContents { + case partialEntry + case malformed + case valid(DisplayableAccountEntropyPool) } private enum Constants { @@ -36,7 +42,26 @@ class AccountEntropyPoolTextView: UIView { private let mode: Mode - var text: String { textView.text ?? "" } + var aepContents: AEPContents { + switch mode { + case .display(let displayableAEP): + return .valid(displayableAEP) + case .entry: + break + } + + let enteredText = textView.text?.filter { !$0.isWhitespace } ?? "" + + guard enteredText.count == AccountEntropyPool.Constants.byteLength else { + return .partialEntry + } + + guard let displayableAEP = try? DisplayableAccountEntropyPool(displayString: enteredText) else { + return .malformed + } + + return .valid(displayableAEP) + } init(mode: Mode) { self.mode = mode @@ -61,9 +86,9 @@ class AccountEntropyPoolTextView: UIView { textView.setTextContentType(val: .password) switch mode { - case .display(let aep): + case .display(let displayableAEP): textView.isEditable = false - textView.text = aep.displayString + textView.text = displayableAEP.displayString case .entry: break } @@ -118,11 +143,8 @@ class AccountEntropyPoolTextView: UIView { ], ) } -} -// MARK: - - -extension AccountEntropyPoolTextView: TextViewWithPlaceholderDelegate { + // MARK: - TextViewWithPlaceholderDelegate func textViewDidUpdateText(_ textView: TextViewWithPlaceholder) { // For autofill, the text is set without first passing through the formatting code. @@ -162,10 +184,11 @@ extension AccountEntropyPoolTextView: TextViewWithPlaceholderDelegate { uiTextView, shouldChangeCharactersIn: range, replacementString: text, - allowedCharacters: .alphanumeric, + allowedCharacters: DisplayableAccountEntropyPool.allowedCharacters, maxCharacters: AccountEntropyPool.Constants.byteLength, format: { unformatted in - return unformatted.uppercased() + return unformatted + .uppercased() .enumerated() .map { index, char -> String in if index > 0, index % Constants.chunkSize == 0 { @@ -214,7 +237,7 @@ private class AEPPreviewViewController: UIViewController { @available(iOS 17, *) #Preview("Display") { - AEPPreviewViewController(mode: .display(aep: AccountEntropyPool())) + AEPPreviewViewController(mode: .display(AccountEntropyPool().forDisplay)) } @available(iOS 17, *) diff --git a/Signal/Backups/BackupConfirmKeyViewController.swift b/Signal/Backups/BackupConfirmKeyViewController.swift index 2442cabefa..6c9cd71ab8 100644 --- a/Signal/Backups/BackupConfirmKeyViewController.swift +++ b/Signal/Backups/BackupConfirmKeyViewController.swift @@ -66,7 +66,7 @@ class BackupConfirmKeyViewController: EnterAccountEntropyPoolViewController, OWS onSeeKeyAgain() }, ), - onEntryConfirmed: { [weak self] aep in + onEntryConfirmed: { [weak self] _ in guard let self else { return } present( diff --git a/Signal/Backups/BackupRecordKeyViewController.swift b/Signal/Backups/BackupRecordKeyViewController.swift index 21ee23d06b..e87b0676fa 100644 --- a/Signal/Backups/BackupRecordKeyViewController.swift +++ b/Signal/Backups/BackupRecordKeyViewController.swift @@ -38,7 +38,7 @@ class BackupRecordKeyViewController: OWSViewController, OWSNavigationChildContro onBackPressedBlock != nil } - private let aep: AccountEntropyPool + private let displayableAEP: DisplayableAccountEntropyPool private let onContinuePressedBlock: (BackupRecordKeyViewController) -> Void private let onCreateNewKeyPressedBlock: (BackupRecordKeyViewController) -> Void private let onBackPressedBlock: (() -> Void)? @@ -57,7 +57,7 @@ class BackupRecordKeyViewController: OWSViewController, OWSNavigationChildContro onContinuePressed: @escaping (BackupRecordKeyViewController) -> Void = { _ in }, onBackPressed: (() -> Void)? = nil, ) { - self.aep = aepMode.aep + self.displayableAEP = DisplayableAccountEntropyPool(aep: aepMode.aep) self.onContinuePressedBlock = onContinuePressed self.onCreateNewKeyPressedBlock = onCreateNewKeyPressed self.onBackPressedBlock = onBackPressed @@ -105,7 +105,7 @@ class BackupRecordKeyViewController: OWSViewController, OWSNavigationChildContro comment: "Subtitle for a view allowing users to record their 'Recovery Key'.", )) - let aepTextView = AccountEntropyPoolTextView(mode: .display(aep: aep)) + let aepTextView = AccountEntropyPoolTextView(mode: .display(displayableAEP)) aepTextView.backgroundColor = .Signal.secondaryGroupedBackground var topButtons: [UIButton] = [ @@ -177,7 +177,7 @@ class BackupRecordKeyViewController: OWSViewController, OWSNavigationChildContro private func copyToClipboard() { UIPasteboard.general.setItems( - [[UIPasteboard.typeAutomatic: aep.displayString]], + [[UIPasteboard.typeAutomatic: displayableAEP.displayString]], options: [.expirationDate: Date().addingTimeInterval(60)], ) @@ -231,7 +231,7 @@ class BackupRecordKeyViewController: OWSViewController, OWSNavigationChildContro ) let password = ASPasswordCredential( user: credentialName, - password: aep.displayString, + password: displayableAEP.displayString, ) let scope = ASAutoFillURLScope(host: "signal.org") diff --git a/Signal/Backups/DisplayableAccountEntropyPool.swift b/Signal/Backups/DisplayableAccountEntropyPool.swift new file mode 100644 index 0000000000..61d1d6cba5 --- /dev/null +++ b/Signal/Backups/DisplayableAccountEntropyPool.swift @@ -0,0 +1,58 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalServiceKit +import SignalUI + +struct DisplayableAccountEntropyPool { + let rawValue: AccountEntropyPool + + init(aep: AccountEntropyPool) { + self.rawValue = aep + } + + init(displayString: String) throws { + let swizzledString = String(displayString.map { char in + return switch char { + case "=": "0" + case "#": "O" + default: char + } + }) + + self.init(aep: try AccountEntropyPool(key: swizzledString)) + } + + var displayString: String { + String( + rawValue.rawString + .uppercased() + .map { char in + switch char { + case "0": "=" + case "O", "o": "#" + default: char + } + }, + ) + } + + // MARK: - + + static let allowedCharacters = FormattedNumberField.AllowedCharacters( + keyboardType: .asciiCapable, + stringFilter: { + return $0.isAsciiAlphanumeric || $0 == "=" || $0 == "#" + }, + ) +} + +// MARK: - + +extension AccountEntropyPool { + var forDisplay: DisplayableAccountEntropyPool { + DisplayableAccountEntropyPool(aep: self) + } +} diff --git a/Signal/Backups/EnterAccountEntropyPoolViewController.swift b/Signal/Backups/EnterAccountEntropyPoolViewController.swift index 5d52c8bca2..23d0b32ff7 100644 --- a/Signal/Backups/EnterAccountEntropyPoolViewController.swift +++ b/Signal/Backups/EnterAccountEntropyPoolViewController.swift @@ -139,27 +139,25 @@ class EnterAccountEntropyPoolViewController: OWSViewController { case notFullyEntered case malformedAEP case wellFormedButMismatched - case success(AccountEntropyPool) + case success(DisplayableAccountEntropyPool) } private func validateAEPText() -> AEPValidationResult { - let enteredAepText = aepTextView.text.filter { - $0.isNumber || $0.isLetter - } - - guard enteredAepText.count == AccountEntropyPool.Constants.byteLength else { + let enteredAep: DisplayableAccountEntropyPool + switch aepTextView.aepContents { + case .partialEntry: return .notFullyEntered - } - - guard let enteredAep = try? AccountEntropyPool(key: enteredAepText) else { + case .malformed: return .malformedAEP + case .valid(let displayableAEP): + enteredAep = displayableAEP } switch aepValidationPolicy! { case .acceptAnyWellFormed: return .success(enteredAep) case .acceptOnly(let expectedAep): - if enteredAep == expectedAep { + if enteredAep.rawValue == expectedAep { return .success(enteredAep) } else { return .wellFormedButMismatched @@ -197,9 +195,9 @@ class EnterAccountEntropyPoolViewController: OWSViewController { switch validateAEPText() { case .notFullyEntered, .malformedAEP, .wellFormedButMismatched: owsFailDebug("Next button should be disabled!") - case .success(let aep): + case .success(let displayableAEP): dismissKeyboard() - onEntryConfirmed(aep) + onEntryConfirmed(displayableAEP.rawValue) } } } @@ -225,7 +223,7 @@ private extension EnterAccountEntropyPoolViewController { title: "Footer Button", action: { print("Footer button!") }, ), - onEntryConfirmed: { print("Confirmed: \($0.displayString)") }, + onEntryConfirmed: { print("Confirmed: \($0.rawString)") }, ) return viewController } diff --git a/Signal/src/ViewControllers/AppSettings/Internal/InternalSettingsViewController.swift b/Signal/src/ViewControllers/AppSettings/Internal/InternalSettingsViewController.swift index 9e7591fe22..388e35d492 100644 --- a/Signal/src/ViewControllers/AppSettings/Internal/InternalSettingsViewController.swift +++ b/Signal/src/ViewControllers/AppSettings/Internal/InternalSettingsViewController.swift @@ -172,14 +172,33 @@ class InternalSettingsViewController: OWSTableViewController2 { self?.presentToast(text: "Backups onboarding enabled!") }) - backupsSection.add(.actionItem(withText: "Backup media integrity check") { [weak self] in - let vc = InternalListMediaViewController() - self?.navigationController?.pushViewController(vc, animated: true) - }) backupsSection.add(.copyableItem( label: "Last Backup chats/messages file size", value: lastBackupDetails.flatMap { ByteCountFormatter().string(for: $0.backupFileSizeBytes) }, )) + backupsSection.add(.actionItem(withText: #"Show "Backup Key Reminder" flow"#) { [weak self] in + guard let self else { return } + + let accountKeyStore = DependenciesBridge.shared.accountKeyStore + let db = DependenciesBridge.shared.db + + guard let aep = db.read(block: { accountKeyStore.getAccountEntropyPool(tx: $0) }) else { + presentToast(text: "Missing AEP?!") + return + } + + BackupRecoveryKeyReminderCoordinator( + aep: aep, + fromViewController: self, + onSuccess: { + self.presentToast(text: "Success!") + }, + ).presentVerifyFlow() + }) + backupsSection.add(.actionItem(withText: "Backup media integrity check") { [weak self] in + let vc = InternalListMediaViewController() + self?.navigationController?.pushViewController(vc, animated: true) + }) contents.add(backupsSection) do { diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIBackups.swift b/Signal/src/ViewControllers/DebugUI/DebugUIBackups.swift index b7f36a583e..0426872df1 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIBackups.swift +++ b/Signal/src/ViewControllers/DebugUI/DebugUIBackups.swift @@ -13,7 +13,6 @@ class DebugUIBackups: DebugUIPage { let name = "Backups" func section(thread: TSThread?) -> OWSTableSection? { - let accountKeyStore = DependenciesBridge.shared.accountKeyStore let backupSettingsStore = BackupSettingsStore() let db = DependenciesBridge.shared.db let issueStore = BackupSubscriptionIssueStore() @@ -21,22 +20,6 @@ class DebugUIBackups: DebugUIPage { var items = [OWSTableItem]() items += [ - OWSTableItem(title: #"Show "Backup Key Reminder" flow"#, actionBlock: { - guard - let frontmostViewController = CurrentAppContext().frontmostViewController(), - let aep = db.read(block: { accountKeyStore.getAccountEntropyPool(tx: $0) }) - else { - return - } - - BackupRecoveryKeyReminderCoordinator( - aep: aep, - fromViewController: frontmostViewController, - onSuccess: { - frontmostViewController.presentToast(text: "Success!") - }, - ).presentVerifyFlow() - }), OWSTableItem(title: "Suspend download queue", actionBlock: { db.write { tx in backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx) diff --git a/Signal/test/Backups/DisplayableAccountEntropyPoolTest.swift b/Signal/test/Backups/DisplayableAccountEntropyPoolTest.swift new file mode 100644 index 0000000000..51838518c9 --- /dev/null +++ b/Signal/test/Backups/DisplayableAccountEntropyPoolTest.swift @@ -0,0 +1,34 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalServiceKit +import Testing +@testable import Signal + +struct DisplayableAccountEntropyPoolTest { + private static let aepLength = AccountEntropyPool.Constants.byteLength + + private let rawString = "0o0ooaa" + String(repeating: "a", count: Self.aepLength - 7) + private var aep: AccountEntropyPool { try! AccountEntropyPool(key: rawString) } + private let expectedDisplayString = "=#=##AA" + String(repeating: "A", count: Self.aepLength - 7) + + @Test + func displayString_filters() { + let display = DisplayableAccountEntropyPool(aep: aep) + + #expect(display.rawValue == aep) + #expect(display.displayString == expectedDisplayString) + } + + @Test + func displayString_constructs() throws { + let display = try DisplayableAccountEntropyPool( + displayString: "=#0oOaA" + String(repeating: "a", count: Self.aepLength - 7), + ) + + #expect(display.rawValue == aep) + #expect(display.displayString == expectedDisplayString) + } +} diff --git a/SignalServiceKit/SecureValueRecovery/AccountEntropyPool/AccountEntropyPool.swift b/SignalServiceKit/SecureValueRecovery/AccountEntropyPool/AccountEntropyPool.swift index 8594ecd114..ffd882ca75 100644 --- a/SignalServiceKit/SecureValueRecovery/AccountEntropyPool/AccountEntropyPool.swift +++ b/SignalServiceKit/SecureValueRecovery/AccountEntropyPool/AccountEntropyPool.swift @@ -17,13 +17,11 @@ public struct AccountEntropyPool: Codable, Equatable { /// The raw representation of the AEP, suitable for passing to LibSignal and /// other APIs. + /// + /// - Important + /// Not suitable for display to the user. See: `DisplayableAccountEntropyPool`. public let rawString: String - /// A stylized representation of the AEP, suitable for display to the user. - public var displayString: String { - rawString.uppercased() - } - public init() { let generatedKey: String = LibSignalClient.AccountEntropyPool.generate() owsAssertBeta(generatedKey == generatedKey.lowercased()) diff --git a/SignalServiceKit/Util/String+SSK.swift b/SignalServiceKit/Util/String+SSK.swift index b895cdc05d..ed0126ca3d 100644 --- a/SignalServiceKit/Util/String+SSK.swift +++ b/SignalServiceKit/Util/String+SSK.swift @@ -93,6 +93,16 @@ extension Optional where Wrapped == String { } } +// MARK: - + +public extension Character { + /// - SeeAlso ``String/asciiDigitsOnly`` + var isAsciiDigit: Bool { isASCII && isNumber } + + /// - SeeAlso ``String/asciiAlphanumericsOnly`` + var isAsciiAlphanumeric: Bool { isASCII && (isLetter || isNumber) } +} + public extension String { /// A version of the string that only contains ASCII digits. /// @@ -105,7 +115,7 @@ public extension String { /// // => "23" /// ``` var asciiDigitsOnly: String { - filter { $0.isASCII && $0.isNumber } + filter { $0.isAsciiDigit } } /// Is every character an ASCII digit between 0 and 9? @@ -119,7 +129,7 @@ public extension String { /// "1.23".isAsciiDigitsOnly // => false /// ``` var isAsciiDigitsOnly: Bool { - allSatisfy { $0.isASCII && $0.isNumber } + allSatisfy { $0.isAsciiDigit } } /// A version of the string that only contains ASCII alphanumerics. @@ -131,7 +141,7 @@ public extension String { /// // => "bc123" /// ``` var asciiAlphanumericsOnly: String { - filter { $0.isASCII && ($0.isLetter || $0.isNumber) } + filter { $0.isAsciiAlphanumeric } } /// Is every character an ASCII alphanumeric? @@ -143,9 +153,11 @@ public extension String { /// "abc12#".isAsciiAlphanumericsOnly // => false /// ``` var isAsciiAlphanumericsOnly: Bool { - allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber) } + allSatisfy { $0.isAsciiAlphanumeric } } + // MARK: - + func substring(withRange range: NSRange) -> String { (self as NSString).substring(with: range) } @@ -158,10 +170,6 @@ public extension String { (self as NSString).substring(from: range.location + range.length) } - enum StringError: Error { - case invalidCharacterShift - } - /// Converts all non arabic numerals within a string to arabic numerals /// /// For example: "Hello ١٢٣" would become "Hello 123" @@ -480,8 +488,7 @@ public extension String { func caesar(shift: UInt32) throws -> String { let shiftedScalars: [UnicodeScalar] = try unicodeScalars.map { c in guard let shiftedScalar = UnicodeScalar((c.value + shift) % 127) else { - owsFailDebug("invalidCharacterShift") - throw StringError.invalidCharacterShift + throw OWSAssertionError("Invalid character shift") } return shiftedScalar } diff --git a/SignalUI/Utils/FormattedNumberField.swift b/SignalUI/Utils/FormattedNumberField.swift index 729721874c..fa029a4630 100644 --- a/SignalUI/Utils/FormattedNumberField.swift +++ b/SignalUI/Utils/FormattedNumberField.swift @@ -27,27 +27,27 @@ public enum FormattedNumberField { case forward } - public enum AllowedCharacters { - case numbers - case alphanumeric + public struct AllowedCharacters { + public let keyboardType: UIKeyboardType + fileprivate let stringFilter: (Character) -> Bool - public var keyboardType: UIKeyboardType { - switch self { - case .numbers: - return .asciiCapableNumberPad - case .alphanumeric: - return .asciiCapable - } + public init( + keyboardType: UIKeyboardType, + stringFilter: @escaping (Character) -> Bool, + ) { + self.keyboardType = keyboardType + self.stringFilter = stringFilter } - fileprivate var stringFilter: KeyPath { - switch self { - case .numbers: - return \.asciiDigitsOnly - case .alphanumeric: - return \.asciiAlphanumericsOnly - } - } + public static let numbers = AllowedCharacters( + keyboardType: .asciiCapableNumberPad, + stringFilter: \.isAsciiDigit, + ) + + public static let alphanumeric = AllowedCharacters( + keyboardType: .asciiCapable, + stringFilter: \.isAsciiAlphanumeric, + ) } /// Call this from your [`UITextFieldDelgate#textField`][0] method. @@ -153,10 +153,15 @@ public enum FormattedNumberField { private static func unformattedPosition( formattedString: String, positionInFormattedString: Int, + allowedCharacters: AllowedCharacters, ) -> Int { - formattedString - .prefix(positionInFormattedString) - .reduce(0) { $0 + (($1.isNumber || $1.isLetter) ? 1 : 0) } + var position = 0 + for char in formattedString.prefix(positionInFormattedString) { + if allowedCharacters.stringFilter(char) { + position += 1 + } + } + return position } /// Turn the cursor position inside an unformatted string into the cursor @@ -192,6 +197,7 @@ public enum FormattedNumberField { unformattedString: String, positionInUnformattedString: Int, formattedString: String, + allowedCharacters: AllowedCharacters, ) -> (lower: Int, upper: Int) { var lower: Int? var upper: Int? @@ -200,6 +206,7 @@ public enum FormattedNumberField { let unformattedCursorPosition = unformattedPosition( formattedString: formattedString, positionInFormattedString: i, + allowedCharacters: allowedCharacters, ) if unformattedCursorPosition == positionInUnformattedString { lower = lower ?? i @@ -241,7 +248,7 @@ public enum FormattedNumberField { direction: SingleDeletionDirection, format: (String) -> String, ) -> OperationResult? { - let oldUnformattedString = formattedString[keyPath: allowedCharacters.stringFilter] + let oldUnformattedString = formattedString.filter(allowedCharacters.stringFilter) if oldUnformattedString.isEmpty { return nil } @@ -249,6 +256,7 @@ public enum FormattedNumberField { let cursorPositionInOldUnformattedString = Self.unformattedPosition( formattedString: formattedString, positionInFormattedString: cursorPosition, + allowedCharacters: allowedCharacters, ) let cursorOffset: Int @@ -275,6 +283,7 @@ public enum FormattedNumberField { unformattedString: newUnformattedString, positionInUnformattedString: cursorPositionInOldUnformattedString + cursorOffset, formattedString: newFormattedString, + allowedCharacters: allowedCharacters, ).lower return .init( @@ -313,17 +322,19 @@ public enum FormattedNumberField { maxCharacters: Int, format: (String) -> String, ) -> OperationResult? { - let insertion = rawInsertion[keyPath: allowedCharacters.stringFilter].uppercased() + let insertion = rawInsertion.filter(allowedCharacters.stringFilter).uppercased() let selectionStartInOldUnformattedString = Self.unformattedPosition( formattedString: formattedString, positionInFormattedString: selectionStart, + allowedCharacters: allowedCharacters, ) let selectionEndInOldUnformattedString = Self.unformattedPosition( formattedString: formattedString, positionInFormattedString: selectionEnd, + allowedCharacters: allowedCharacters, ) - let oldUnformattedString = formattedString[keyPath: allowedCharacters.stringFilter] + let oldUnformattedString = formattedString.filter(allowedCharacters.stringFilter) let newUnformattedString: String = { let prefix = oldUnformattedString.prefix(selectionStartInOldUnformattedString) @@ -354,6 +365,7 @@ public enum FormattedNumberField { unformattedString: newUnformattedString, positionInUnformattedString: selectionStartInOldUnformattedString + insertion.count, formattedString: newFormattedString, + allowedCharacters: allowedCharacters, ).upper return .init(