Use "=/#" in place of "0/O" in Recovery Keys

This commit is contained in:
Sasha Weiss 2026-05-06 13:55:10 -07:00 committed by GitHub
parent d9d762062f
commit 416083dfbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 231 additions and 91 deletions

View File

@ -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 = "<group>"; };
D925F56F298DF41B00158EE4 /* UsernameLookupRecordTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameLookupRecordTest.swift; sourceTree = "<group>"; };
D927372C2CD2DD0D00E15D95 /* StorageServiceRecordIkmMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageServiceRecordIkmMigrator.swift; sourceTree = "<group>"; };
D92812DD2FA94B6E00667DCF /* DisplayableAccountEntropyPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayableAccountEntropyPool.swift; sourceTree = "<group>"; };
D92812E12FA95C0D00667DCF /* DisplayableAccountEntropyPoolTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayableAccountEntropyPoolTest.swift; sourceTree = "<group>"; };
D92A1CD92E314BD000C91E21 /* DebugUIPrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugUIPrompts.swift; sourceTree = "<group>"; };
D92AB7D729E3BEE30081CA7D /* OWSDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSDeviceManager.swift; sourceTree = "<group>"; };
D92C57542A2925AD00A03BB7 /* TSInfoMessage+DisplayableGroupUpdateItemTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+DisplayableGroupUpdateItemTest.swift"; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 */,

View File

@ -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, *)

View File

@ -66,7 +66,7 @@ class BackupConfirmKeyViewController: EnterAccountEntropyPoolViewController, OWS
onSeeKeyAgain()
},
),
onEntryConfirmed: { [weak self] aep in
onEntryConfirmed: { [weak self] _ in
guard let self else { return }
present(

View File

@ -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")

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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())

View File

@ -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
}

View File

@ -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<String, String> {
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(