Skip to content

Commit

Permalink
[Link] Fix OneTimeCodeTextField AutoFill support (#954)
Browse files Browse the repository at this point in the history
* Conform to UITextInput

* Split text storage

* Cleanup

* Cleanup

* Fix test
  • Loading branch information
ramont-stripe authored Apr 6, 2022
1 parent d4b4fb0 commit f5d5f3d
Show file tree
Hide file tree
Showing 5 changed files with 749 additions and 88 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ extension PaymentSheetUITest {
app.buttons["Pay $50.99"].tap()

// Wait for OTP prompt and enter the code
let codeField = app.otherElements["Code field"]
let codeField = app.descendants(matching: .any)["Code field"]
XCTAssert(codeField.waitForExistence(timeout: 10))
codeField.tap()

Expand Down
4 changes: 4 additions & 0 deletions Stripe.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,7 @@
D092E37D27C5A01700B72609 /* STPAnalyticsClient+Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = D092E37C27C5A01700B72609 /* STPAnalyticsClient+Link.swift */; };
D092E38027C5B95000B72609 /* AnalyticsHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D092E37E27C5B91F00B72609 /* AnalyticsHelperTests.swift */; };
D09A20D326DEE66800A0D4D9 /* CardBrandView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09A20D226DEE66800A0D4D9 /* CardBrandView.swift */; };
D09F2FCD27F3D26200D3DFD9 /* OneTimeCodeTextField-TextStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09F2FCC27F3D26200D3DFD9 /* OneTimeCodeTextField-TextStorage.swift */; };
D0BBBC9326DE9243007A9F48 /* STPCardCVCInputTextFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BBBC9226DE9243007A9F48 /* STPCardCVCInputTextFieldTests.swift */; };
D0BEB3FE273CA8E60031D677 /* UIStackView+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB3FD273CA8E60031D677 /* UIStackView+Stripe.swift */; };
D0BEB400273CAA770031D677 /* PayWithLinkViewController-WalletViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BEB3FF273CAA770031D677 /* PayWithLinkViewController-WalletViewController.swift */; };
Expand Down Expand Up @@ -1709,6 +1710,7 @@
D092E37C27C5A01700B72609 /* STPAnalyticsClient+Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAnalyticsClient+Link.swift"; sourceTree = "<group>"; };
D092E37E27C5B91F00B72609 /* AnalyticsHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsHelperTests.swift; sourceTree = "<group>"; };
D09A20D226DEE66800A0D4D9 /* CardBrandView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardBrandView.swift; sourceTree = "<group>"; };
D09F2FCC27F3D26200D3DFD9 /* OneTimeCodeTextField-TextStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OneTimeCodeTextField-TextStorage.swift"; sourceTree = "<group>"; };
D0BBBC9226DE9243007A9F48 /* STPCardCVCInputTextFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardCVCInputTextFieldTests.swift; sourceTree = "<group>"; };
D0BEB3FD273CA8E60031D677 /* UIStackView+Stripe.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIStackView+Stripe.swift"; sourceTree = "<group>"; };
D0BEB3FF273CAA770031D677 /* PayWithLinkViewController-WalletViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PayWithLinkViewController-WalletViewController.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2744,6 +2746,7 @@
isa = PBXGroup;
children = (
D00C2D1B2735C24C00E2283D /* OneTimeCodeTextField.swift */,
D09F2FCC27F3D26200D3DFD9 /* OneTimeCodeTextField-TextStorage.swift */,
);
name = Link;
sourceTree = "<group>";
Expand Down Expand Up @@ -4129,6 +4132,7 @@
319E3659271A03B300460867 /* STPAPIClient+Payments.swift in Sources */,
317ABF3425118C1E00CC59EF /* STPCardScanner.swift in Sources */,
3176C233251A6B0600300ADE /* STPThreeDSLabelCustomization.swift in Sources */,
D09F2FCD27F3D26200D3DFD9 /* OneTimeCodeTextField-TextStorage.swift in Sources */,
B67BBBBA26F1763C002645A9 /* PaymentMethodElement.swift in Sources */,
8F5ED78325552296003BE002 /* STPPaymentMethodUPI.swift in Sources */,
3176C246251A7F6500300ADE /* STPPaymentOptionTableViewCell.swift in Sources */,
Expand Down
216 changes: 216 additions & 0 deletions Stripe/OneTimeCodeTextField-TextStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
//
// OneTimeCodeTextField-TextStorage.swift
// StripeiOS
//
// Created by Ramon Torres on 3/29/22.
// Copyright © 2022 Stripe, Inc. All rights reserved.
//

import UIKit

extension OneTimeCodeTextField {
final class TextStorage {
var value: String = ""

let capacity: Int

var start: TextPosition {
return TextPosition(0)
}

var end: TextPosition {
return TextPosition(value.count)
}

/// Returns a range for placing the caret at the end of the content.
///
/// A zero-length range is `UITextInput`'s way of representing the caret position. This property will
/// always return a zero-length range at the end of the content.
var endCaretRange: TextRange {
return TextRange(start: end, end: end)
}

/// A range that covers from the beginning to the end of the content.
var extent: TextRange {
return TextRange(start: start, end: end)
}

var isFull: Bool {
return value.count >= capacity
}

private let allowedCharacters: CharacterSet = .init(charactersIn: "0123456789")

init(capacity: Int) {
self.capacity = capacity
}

func insert(_ text: String, at range: TextRange) -> TextRange {
let sanitizedText = text.filter({
$0.unicodeScalars.allSatisfy(allowedCharacters.contains(_:))
})

value.replaceSubrange(range.stringRange(for: value), with: sanitizedText)

if value.count > capacity {
// Truncate to capacity
value = value.stp_safeSubstring(to: capacity)
}

let newInsertionPoint = TextPosition(range._start.index + sanitizedText.count)
return TextRange(start: newInsertionPoint, end: newInsertionPoint)
}

func delete(range: TextRange) -> TextRange {
value.removeSubrange(range.stringRange(for: value))
return TextRange(start: range._start, end: range._start)
}

func text(in range: TextRange) -> String? {
guard !range.isEmpty else {
return nil
}

let stringRange = range.stringRange(for: value)
return String(value[stringRange])
}

/// Utility method for creating a text range.
///
/// Returns `nil` if any of the given positions is out of bounds.
///
/// - Parameters:
/// - start: Start position of the range.
/// - end: End position of the range.
/// - Returns: Text position.
func makeRange(from start: TextPosition, to end: TextPosition) -> TextRange? {
guard
extent.contains(start.index),
extent.contains(end.index)
else {
return nil
}

return TextRange(start: start, end: end)
}
}
}

// MARK: - UITextPosition

extension OneTimeCodeTextField {
/// Represents a position within our text storage.
///
/// For internal SDK use only
@objc(STP_Internal_OneTimeCodeTextField_TextPosition)
final class TextPosition: UITextPosition {
let index: Int

init(_ index: Int) {
self.index = index
}

override var description: String {
let props: [String] = [
String(format: "%@: %p", NSStringFromClass(type(of: self)), self),
"index = \(String(describing: index))"
]
return "<\(props.joined(separator: "; "))>"
}

override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? TextPosition else {
return false
}

return self.index == other.index
}

func compare(_ otherPosition: TextPosition) -> ComparisonResult {
if index < otherPosition.index {
return .orderedAscending
}

if index > otherPosition.index {
return .orderedDescending
}

return .orderedSame
}
}

}

// MARK: - TextRange

extension OneTimeCodeTextField {
/// A range within our text storage.
///
/// For internal SDK use only.
@objc(STP_Internal_OneTimeCodeTextField_TextRange)
final class TextRange: UITextRange {
let _start: TextPosition
let _end: TextPosition

override var isEmpty: Bool {
return _start.index == _end.index
}

override var start: UITextPosition {
return _start
}

override var end: UITextPosition {
return _end
}

convenience init?(start: UITextPosition, end: UITextPosition) {
guard
let start = start as? TextPosition,
let end = end as? TextPosition
else {
return nil
}

self.init(start: start, end: end)
}

init(start: TextPosition, end: TextPosition) {
self._start = start
self._end = end
}

override var description: String {
let props: [String] = [
String(format: "%@: %p", NSStringFromClass(type(of: self)), self),
"start = \(String(describing: start))",
"end = \(String(describing: end))"
]
return "<\(props.joined(separator: "; "))>"
}

override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? TextRange else {
return false
}

return self.start == other.start && self.end == other.end
}

func contains(_ index: Int) -> Bool {
let lowerBound = min(_start.index, _end.index)
let upperBound = max(_start.index, _end.index)
return index >= lowerBound && index <= upperBound
}

func stringRange(for string: String) -> Range<String.Index> {
let lowerBound = min(_start.index, _end.index)
let upperBound = max(_start.index, _end.index)

let beginIndex = string.index(string.startIndex, offsetBy: min(lowerBound, string.count))
let endIndex = string.index(string.startIndex, offsetBy: min(upperBound, string.count))

return beginIndex..<endIndex
}
}
}
Loading

0 comments on commit f5d5f3d

Please sign in to comment.