Use "=/#" in place of "0/O" in Recovery Keys
This commit is contained in:
parent
d9d762062f
commit
416083dfbc
@ -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 */,
|
||||
|
||||
@ -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, *)
|
||||
|
||||
@ -66,7 +66,7 @@ class BackupConfirmKeyViewController: EnterAccountEntropyPoolViewController, OWS
|
||||
onSeeKeyAgain()
|
||||
},
|
||||
),
|
||||
onEntryConfirmed: { [weak self] aep in
|
||||
onEntryConfirmed: { [weak self] _ in
|
||||
guard let self else { return }
|
||||
|
||||
present(
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
58
Signal/Backups/DisplayableAccountEntropyPool.swift
Normal file
58
Signal/Backups/DisplayableAccountEntropyPool.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
34
Signal/test/Backups/DisplayableAccountEntropyPoolTest.swift
Normal file
34
Signal/test/Backups/DisplayableAccountEntropyPoolTest.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user