-
Notifications
You must be signed in to change notification settings - Fork 122
Support card reader auto-reconnection #16586
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a68e2a8
de35344
fd6084e
6ebc740
0959e62
b33ca6e
ff08de8
599d5d3
298ecf3
da41b83
839be63
dfd279c
b2066db
be3bfe8
821b52e
d43f786
c1a7548
e6243a1
ee5b966
0187e27
8cb0197
cc76fc5
cbe1f6f
16d38d2
c2ad8c5
bc009f2
ddec4df
53d101f
0c79e54
0a7fe82
932b7fa
76620fc
adcfb6d
5241d59
ba2076f
87a0203
e753f62
fad4899
b56fbbe
58a9687
f8c966b
5cfa3df
cc8d4a4
bfdd926
0c3ca2a
3019abd
7bd9dd5
e8af23f
7426f96
8ce334b
8511b02
da8e3d6
9a85ad0
74b1a62
9e817dd
17f408c
fa20768
8a4ba7d
c929acf
2ecad45
51093de
2034bb2
2f6148b
9f1615b
3c40ced
3dca806
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import Foundation | ||
|
|
||
| /// Represents the state of a card reader auto-reconnection attempt. | ||
| /// When a Bluetooth card reader unexpectedly disconnects, the Stripe Terminal SDK | ||
| /// automatically attempts to reconnect. These states track that process. | ||
| public enum CardReaderReconnectionState: Equatable { | ||
| /// No reconnection attempt is in progress. | ||
| case idle | ||
|
|
||
| /// The SDK is attempting to automatically reconnect to the reader. | ||
| case reconnecting(reader: CardReader) | ||
|
|
||
| /// The auto-reconnection succeeded. | ||
| case succeeded(reader: CardReader) | ||
|
|
||
| /// The auto-reconnection failed. | ||
| case failed(reader: CardReader) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ public final class StripeCardReaderService: NSObject { | |
| private var discoveryCancellable: StripeTerminal.Cancelable? | ||
| private var paymentCancellable: StripeTerminal.Cancelable? | ||
| private var refundCancellable: StripeTerminal.Cancelable? | ||
| private var reconnectionCancelable: StripeTerminal.Cancelable? | ||
| private var cancellables = Set<AnyCancellable>() | ||
|
|
||
| private var discoveredReadersSubject = CurrentValueSubject<[CardReader], Error>([]) | ||
|
|
@@ -18,6 +19,7 @@ public final class StripeCardReaderService: NSObject { | |
| private let readerEventsSubject = PassthroughSubject<CardReaderEvent, Never>() | ||
| private let softwareUpdateSubject = CurrentValueSubject<CardReaderSoftwareUpdateState, Never>(.none) | ||
| private let tapToPayCardReaderAcceptToSSubject = PassthroughSubject<Void, Never>() | ||
| private let reconnectionStateSubject = CurrentValueSubject<CardReaderReconnectionState, Never>(.idle) | ||
|
|
||
| private var connectionAttemptInvalidated: Bool = false | ||
|
|
||
|
|
@@ -69,6 +71,10 @@ extension StripeCardReaderService: CardReaderService { | |
| tapToPayCardReaderAcceptToSSubject.eraseToAnyPublisher() | ||
| } | ||
|
|
||
| public var reconnectionEvents: AnyPublisher<CardReaderReconnectionState, Never> { | ||
| reconnectionStateSubject.eraseToAnyPublisher() | ||
| } | ||
|
|
||
| // MARK: - CardReaderService conformance. Commands | ||
|
|
||
| public func checkSupport(for cardReaderType: CardReaderType, | ||
|
|
@@ -493,6 +499,7 @@ extension StripeCardReaderService: CardReaderService { | |
| switch result { | ||
| case .success(let locationId): | ||
| let buildConfig = BluetoothConnectionConfigurationBuilder(delegate: self, locationId: locationId) | ||
| .setAutoReconnectOnUnexpectedDisconnect(true) | ||
| do { | ||
| let config = try buildConfig.build() | ||
| return promise(.success(config)) | ||
|
|
@@ -630,6 +637,44 @@ extension StripeCardReaderService: CardReaderService { | |
| public func installUpdate() -> Void { | ||
| Terminal.shared.installAvailableUpdate() | ||
| } | ||
|
|
||
| public func cancelReconnection() -> Future<Void, Error> { | ||
| Future { [weak self] promise in | ||
| guard let self else { | ||
| return promise(.success(())) | ||
| } | ||
|
|
||
| guard let reconnectionCancelable = self.reconnectionCancelable, | ||
| !reconnectionCancelable.completed else { | ||
|
staskus marked this conversation as resolved.
|
||
| self.reconnectionCancelable = nil | ||
| self.reconnectionStateSubject.send(.idle) | ||
| return promise(.success(())) | ||
| } | ||
|
|
||
| DDLogInfo("💳 Reconnection cancellation requested") | ||
| reconnectionCancelable.cancel { [weak self] error in | ||
| self?.reconnectionCancelable = nil | ||
|
|
||
| // Treat cancelFailedAlreadyCompleted as success - reconnection already completed. | ||
| if let error = error as? ErrorCode, | ||
| error.code == .cancelFailedAlreadyCompleted { | ||
| DDLogInfo("💳 Reconnection cancellation: reconnection already completed") | ||
| self?.reconnectionStateSubject.send(.idle) | ||
| promise(.success(())) | ||
| } else if let error = error { | ||
| self?.connectedReadersSubject.send([]) | ||
| self?.reconnectionStateSubject.send(.idle) | ||
| let underlyingError = Self.logAndDecodeError(error) | ||
| promise(.failure(CardReaderServiceError.reconnectionCancellation(underlyingError: underlyingError))) | ||
| } else { | ||
| DDLogInfo("💳 Reconnection cancellation succeeded") | ||
| self?.connectedReadersSubject.send([]) | ||
| self?.reconnectionStateSubject.send(.idle) | ||
| promise(.success(())) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| struct CardReaderMetadata { | ||
|
|
@@ -994,6 +1039,40 @@ extension StripeCardReaderService: MobileReaderDelegate { | |
| public func reader(_ reader: Reader, didDisconnect reason: DisconnectReason) { | ||
| connectedReadersSubject.send([]) | ||
| } | ||
|
|
||
| // MARK: - Reconnection delegate methods | ||
|
|
||
| public func reader(_ reader: Reader, didStartReconnect cancelable: Cancelable, disconnectReason: DisconnectReason) { | ||
| DDLogInfo("💳 Reader started auto-reconnection, reason: \(Terminal.stringFromDisconnectReason(disconnectReason))") | ||
| reconnectionCancelable = cancelable | ||
| // Clear connected readers so the UI shows reconnecting state instead of connected | ||
| connectedReadersSubject.send([]) | ||
| let cardReader = CardReader(reader: reader) | ||
| reconnectionStateSubject.send(.reconnecting(reader: cardReader)) | ||
| } | ||
|
|
||
| public func readerDidSucceedReconnect(_ reader: Reader) { | ||
| DDLogInfo("💳 Reader auto-reconnection succeeded") | ||
| reconnectionCancelable = nil | ||
| let cardReader = CardReader(reader: reader) | ||
| reconnectionStateSubject.send(.succeeded(reader: cardReader)) | ||
| connectedReadersSubject.send([cardReader]) | ||
| reconnectionStateSubject.send(.idle) | ||
| } | ||
|
|
||
| public func readerDidFailReconnect(_ reader: Reader) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Presumably the real error for this comes in from the connection functions?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, if by error you mean the disconnection reason, which, as you noticed, comes from |
||
| // Only log as an error if reconnection was still in progress. | ||
| // When the user cancels reconnection, the cancelable is nilled out before | ||
| // this delegate fires, so the failure is expected and not worth logging. | ||
| if reconnectionCancelable != nil { | ||
| DDLogError("💳 Reader auto-reconnection failed") | ||
| } | ||
| reconnectionCancelable = nil | ||
| let cardReader = CardReader(reader: reader) | ||
| reconnectionStateSubject.send(.failed(reader: cardReader)) | ||
| connectedReadersSubject.send([]) | ||
| reconnectionStateSubject.send(.idle) | ||
| } | ||
| } | ||
|
|
||
| extension StripeCardReaderService: TapToPayReaderDelegate { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import Foundation | ||
|
|
||
| struct PointOfSaleCardPresentPaymentReconnectingMessageViewModel { | ||
| let title = Localization.title | ||
| let cancelReconnectionButtonTitle = Localization.cancelReconnection | ||
| } | ||
|
|
||
| private extension PointOfSaleCardPresentPaymentReconnectingMessageViewModel { | ||
| enum Localization { | ||
| static let title = NSLocalizedString( | ||
| "pointOfSale.cardPresent.reconnecting.title", | ||
| value: "Reconnecting reader...", | ||
| comment: "Title shown on the Point of Sale Checkout when the card reader is reconnecting" | ||
| ) | ||
|
|
||
| static let cancelReconnection = NSLocalizedString( | ||
| "pointOfSale.cardPresent.cancelReconnection.button.title", | ||
| value: "Cancel reconnection", | ||
| comment: "Button to cancel card reader reconnection, shown on the Point of Sale Checkout" | ||
| ) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ struct CardReaderConnectionStatusView: View { | |
| @Environment(PointOfSaleAggregateModel.self) private var posModel | ||
| @ScaledMetric private var scale: CGFloat = 1.0 | ||
| @Environment(\.isEnabled) var isEnabled | ||
| @State private var isCancellingReconnection = false | ||
|
|
||
| @ViewBuilder | ||
| private func circleIcon(with color: Color) -> some View { | ||
|
|
@@ -57,9 +58,27 @@ struct CardReaderConnectionStatusView: View { | |
| .padding(Constants.disconnectedBorderInset) | ||
| } | ||
| .accessibilityIdentifier("pos-connect-reader-button") | ||
| case .reconnecting: | ||
| Menu { | ||
| Button { | ||
| isCancellingReconnection = true | ||
| posModel.cancelReconnection() | ||
| } label: { | ||
| Text(Localization.cancelReconnection) | ||
| } | ||
| } label: { | ||
| progressIndicatingCardReaderStatus( | ||
| title: isCancellingReconnection ? Localization.cancellingReconnection : Localization.readerReconnecting | ||
| ) | ||
| } | ||
| .disabled(isCancellingReconnection) | ||
| .accessibilityIdentifier("pos-reader-reconnecting") | ||
| } | ||
| } | ||
| .font(Constants.font) | ||
| .onChange(of: posModel.cardReaderConnectionStatus) { | ||
| isCancellingReconnection = false | ||
| } | ||
| .dynamicTypeSize(...DynamicTypeSize.accessibility2) | ||
| .opacity(isEnabled ? 1 : 0.5) | ||
| } | ||
|
|
@@ -148,8 +167,8 @@ private extension CardReaderConnectionStatusView { | |
| ) | ||
|
|
||
| static let disconnectCardReader = NSLocalizedString( | ||
| "pointOfSale.floatingButtons.disconnectCardReader.button.title", | ||
| value: "Disconnect Reader", | ||
| "pointOfSale.floatingButtons.disconnectCardReader.button.title.2", | ||
| value: "Disconnect reader", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure how closely we're still following pecCkj-eD-p2 any more, but I still find it strange to see sentence case on buttons on iOS.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I haven't heard about this P2 myself. It looks like in POS we do use sentence case in a few places: "Connect your reader", "Update firmware", "Cash payment", "Check out". It's a change that we can make in one swoop, but now it looks like the sentence case is the convention |
||
| comment: "The title of the menu button to disconnect a connected card reader, as confirmation." | ||
| ) | ||
|
|
||
|
|
@@ -159,6 +178,24 @@ private extension CardReaderConnectionStatusView { | |
| comment: "The title of the floating button to indicate that the reader is not ready for another " + | ||
| "connection, usually because a connection has just been cancelled" | ||
| ) | ||
|
|
||
| static let readerReconnecting = NSLocalizedString( | ||
| "pointOfSale.floatingButtons.readerReconnecting.title", | ||
| value: "Reconnecting…", | ||
| comment: "The title of the floating button to indicate that the reader is attempting to reconnect." | ||
| ) | ||
|
|
||
| static let cancelReconnection = NSLocalizedString( | ||
| "pointOfSale.floatingButtons.cancelReconnection.button.title", | ||
| value: "Cancel reconnection", | ||
| comment: "The title of the menu button to cancel an ongoing card reader reconnection attempt." | ||
| ) | ||
|
|
||
| static let cancellingReconnection = NSLocalizedString( | ||
| "pointOfSale.floatingButtons.cancellingReconnection.title", | ||
| value: "Cancelling…", | ||
| comment: "The title of the floating button to indicate that the reader reconnection is being cancelled." | ||
| ) | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would help with testing to have log messages about cancellations... they just show as failures at the moment