Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
a68e2a8
Add card reader reconnection support to Hardware module
staskus Jan 29, 2026
de35344
Add reconnection actions to Yosemite layer
staskus Jan 29, 2026
fd6084e
Add reconnecting status to POS payment interfaces
staskus Jan 29, 2026
6ebc740
Wire up reconnection events in CardPresentPaymentService
staskus Jan 29, 2026
0959e62
Add reconnecting UI state to POS
staskus Jan 29, 2026
b33ca6e
Add tests for card reader reconnection
staskus Jan 29, 2026
ff08de8
Add reconnection state to Card Reader Settings
staskus Jan 29, 2026
599d5d3
Cancel reconnection before starting new reader search
staskus Jan 29, 2026
298ecf3
Add release note for card reader auto-reconnection
staskus Jan 29, 2026
da41b83
Fix tests for cancelReconnection action
staskus Jan 30, 2026
839be63
Merge trunk into woomob-2028-support-card-reader-auto-reconnection
staskus Jan 30, 2026
dfd279c
Handle card reader reconnection in tests
staskus Jan 30, 2026
b2066db
Enable auto-reconnection for Bluetooth card readers
staskus Jan 30, 2026
be3bfe8
Update reconnection delegate to include disconnect reason
staskus Jan 30, 2026
821b52e
Treat cancel reconnection race condition as success
staskus Jan 30, 2026
d43f786
Refine reconnection cancel behavior & Combine updates
staskus Jan 30, 2026
c1a7548
Restore comment explaining Tap to Pay reader handling
staskus Jan 30, 2026
e6243a1
Add reconnection state UI to card reader settings
staskus Jan 30, 2026
ee5b966
Add reconnection state UI to POS Settings
staskus Jan 30, 2026
0187e27
Add reconnection state UI to TotalsView checkout screen
staskus Feb 2, 2026
8cb0197
Revert unrelated test refactoring in POSOrderListControllerTests
staskus Feb 2, 2026
cc76fc5
Fix "Try another payment method" button when reader disconnects
staskus Feb 2, 2026
cbe1f6f
Show reconnecting UI during payment error state
staskus Feb 2, 2026
16d38d2
Clear stale reconnectionCancelable in early-return branch
staskus Feb 4, 2026
c2ad8c5
Rename test to better reflect payment states being tested
staskus Feb 4, 2026
bc009f2
Simplify cancelFailedAlreadyCompleted comment
staskus Feb 4, 2026
ddec4df
Use human-readable disconnect reason in reconnection log
staskus Feb 4, 2026
53d101f
Add loading state to cancel button in payment reconnecting view
staskus Feb 4, 2026
0c79e54
Add loading state to cancel reconnection in floating button menu
staskus Feb 4, 2026
0a7fe82
Add loading state to cancel reconnection in POS settings hardware view
staskus Feb 4, 2026
932b7fa
Track reconnection cancellation in progress state
staskus Feb 4, 2026
76620fc
Add cancellingReconnection button state in card reader settings
staskus Feb 4, 2026
adcfb6d
Skip auto-search after reconnection cancellation
staskus Feb 4, 2026
5241d59
Add tests for skip auto-search after reconnection cancellation
staskus Feb 4, 2026
ba2076f
Add log messages for reconnection cancellation
staskus Feb 4, 2026
87a0203
Revert "Show reconnecting UI during payment error state"
staskus Feb 4, 2026
e753f62
Cancel reconnection before cancelPayment to prevent UI hang
staskus Feb 4, 2026
fad4899
Cancel reconnection before starting a new payment
staskus Feb 4, 2026
b56fbbe
Reset reconnection flags and update cancel
staskus Feb 4, 2026
58a9687
Merge branch 'trunk' into woomob-2028-support-card-reader-auto-reconn…
staskus Feb 4, 2026
f8c966b
Update MockCardPresentPaymentsStoresManager.swift
staskus Feb 4, 2026
5cfa3df
Merge trunk and resolve conflicts for card reader reconnection
staskus Mar 9, 2026
cc8d4a4
Add reconnection cancellation to POSPaymentModel.cancelThenCollectPay…
staskus Mar 9, 2026
bfdd926
Remove accidentally committed worktree submodule reference
staskus Mar 9, 2026
0c3ca2a
Pass cancelReconnectionAction to bookings flow and add @MainActor
staskus Mar 10, 2026
3019abd
Fix cancelThenCollectPayment tests to set state before creating SUT
staskus Mar 10, 2026
7bd9dd5
Fix race condition in cancelReconnectionIfNeeded by making it properl…
staskus Mar 10, 2026
e8af23f
Promote TotalsViewHelper to stored property in TotalsView
staskus Mar 10, 2026
7426f96
Replace Task.sleep with deterministic test setup for reconnection test
staskus Mar 10, 2026
8ce334b
Handle cancelReconnection error in BluetoothCardReaderSettingsConnect…
staskus Mar 10, 2026
8511b02
Reset isCancellingReconnection on status change instead of onAppear
staskus Mar 10, 2026
da8e3d6
Fix Cancel Reconnection button to use sentence case
staskus Mar 10, 2026
9a85ad0
Return success from NoOpCardReaderService.cancelReconnection
staskus Mar 10, 2026
74b1a62
Make cancelReconnectionAction non-optional in POSCardPaymentContentView
staskus Mar 10, 2026
9e817dd
Guard reconnection failure log behind active cancelable check
staskus Mar 10, 2026
17f408c
Remove extra blank lines in POSSettingsControllerTests
staskus Mar 10, 2026
fa20768
Merge trunk into woomob-2028-support-card-reader-auto-reconnection
staskus Apr 9, 2026
8a4ba7d
Move POS reconnection release note from 24.4 to 24.6
staskus Apr 9, 2026
c929acf
Improve reconnection handling and logging
staskus Apr 9, 2026
2ecad45
Merge branch 'trunk' into woomob-2028-support-card-reader-auto-reconn…
staskus Apr 13, 2026
51093de
Reuse progressIndicatingCardReaderStatus in reconnecting state
staskus Apr 13, 2026
2034bb2
Update button casing to match each view's convention
staskus Apr 13, 2026
2f6148b
Simplify isShowingPaymentView and remove redundant comment
staskus Apr 13, 2026
9f1615b
Use nillableContinuation in cancelReconnection for safety
staskus Apr 13, 2026
3c40ced
Extract compound condition into descriptive variable in reevaluateSho…
staskus Apr 13, 2026
3dca806
Tie DisconnectButtonState action and enabled state together
staskus Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
8 changes: 8 additions & 0 deletions Modules/Sources/Hardware/CardReader/CardReaderService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ public protocol CardReaderService {
/// The Publisher that emits when TTP Terms and Services are accepted
var tapToPayCardReaderAcceptToSEvents: AnyPublisher<Void, Never> { get }

/// The Publisher that emits reconnection state changes for Bluetooth readers
var reconnectionEvents: AnyPublisher<CardReaderReconnectionState, Never> { get }

// MARK: - Commands

/// Checks for support of a given reader type and discovery method combination. Does not start discovery.
Expand Down Expand Up @@ -73,4 +76,9 @@ public protocol CardReaderService {
///
/// To check the progress of the update, observe the softwareUpdateEvents publisher.
func installUpdate() -> Void

/// Cancels an in-progress auto-reconnection attempt.
/// Use this when the user wants to manually connect a different reader
/// or cancel the automatic reconnection process.
func cancelReconnection() -> Future<Void, Error>
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ public enum CardReaderServiceError: Error {
/// Error thrown while updating the reader firmware
case softwareUpdate(underlyingError: UnderlyingError = .internalServiceError, batteryLevel: Double?)

/// Error thrown while cancelling an auto-reconnection attempt
case reconnectionCancellation(underlyingError: UnderlyingError = .internalServiceError)

/// The user has denied the app permission to use Bluetooth
case bluetoothDenied

Expand Down Expand Up @@ -74,7 +77,8 @@ extension CardReaderServiceError: LocalizedError {
.refundCreation(let underlyingError),
.refundPayment(let underlyingError, _),
.refundCancellation(let underlyingError),
.softwareUpdate(let underlyingError, _):
.softwareUpdate(let underlyingError, _),
.reconnectionCancellation(let underlyingError):
return underlyingError.errorDescription
case .paymentCaptureWithPaymentMethod(underlyingError: let underlyingError, paymentMethod: _):
return underlyingError.errorDescription ?? underlyingError.localizedDescription
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public struct NoOpCardReaderService: CardReaderService {
public var tapToPayCardReaderAcceptToSEvents: AnyPublisher<Void, Never>
= PassthroughSubject<Void, Never>().eraseToAnyPublisher()

/// The Publisher that emits reconnection state changes for Bluetooth readers
public var reconnectionEvents: AnyPublisher<CardReaderReconnectionState, Never>
= CurrentValueSubject<CardReaderReconnectionState, Never>(.idle).eraseToAnyPublisher()

public init() {}
// MARK: - Commands

Expand Down Expand Up @@ -110,4 +114,11 @@ public struct NoOpCardReaderService: CardReaderService {
public func installUpdate() -> Void {
// no-op
}

/// Cancels an in-progress auto-reconnection attempt.
public func cancelReconnection() -> Future<Void, Error> {
return Future() { promise in
promise(.success(()))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>([])
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -630,6 +637,44 @@ extension StripeCardReaderService: CardReaderService {
public func installUpdate() -> Void {
Terminal.shared.installAvailableUpdate()
}

public func cancelReconnection() -> Future<Void, Error> {
Copy link
Copy Markdown
Contributor

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

Future { [weak self] promise in
guard let self else {
return promise(.success(()))
}

guard let reconnectionCancelable = self.reconnectionCancelable,
!reconnectionCancelable.completed else {
Comment thread
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 {
Expand Down Expand Up @@ -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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Presumably the real error for this comes in from the connection functions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 func reader(_ reader: Reader, didStartReconnect cancelable: Cancelable, disconnectReason: DisconnectReason).

// 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,9 @@ public protocol CardPresentPaymentFacade {

/// Cancels any in-progress payment, returning when complete
func cancelPayment() async throws

/// Cancels an in-progress auto-reconnection attempt.
/// Use this when the user wants to manually connect a different reader
/// or cancel the automatic reconnection process.
func cancelReconnection() async
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ final class CardPresentPaymentPreviewService: CardPresentPaymentFacade {
// no-op
}

func cancelPayment() async throws {
// no-op
}

func cancelReconnection() async {
// no-op
}

func updateCardReaderSoftware() async throws {
// no-op
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ public enum CardPresentPaymentReaderConnectionStatus: Equatable {
case connected(CardPresentPaymentCardReader)
case cancellingConnection
case disconnecting
case reconnecting(CardPresentPaymentCardReader)
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ private extension CardReaderServiceError {
.paymentCancellation(underlyingError: let underlyingError),
.refundCreation(underlyingError: let underlyingError),
.refundCancellation(underlyingError: let underlyingError),
.softwareUpdate(underlyingError: let underlyingError, _):
.softwareUpdate(underlyingError: let underlyingError, _),
.reconnectionCancellation(underlyingError: let underlyingError):
return underlyingError.retryApproach(with: retryAction)
case .refundPayment(underlyingError: let underlyingError, shouldRetry: let shouldRetry):
guard shouldRetry else {
Expand Down
16 changes: 15 additions & 1 deletion Modules/Sources/PointOfSale/Controllers/POSPaymentModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ extension POSPaymentModel {
switch status {
case .connected:
return true
case .disconnected, .disconnecting, .cancellingConnection:
case .disconnected, .disconnecting, .cancellingConnection, .reconnecting:
return false
}
}
Expand Down Expand Up @@ -171,7 +171,15 @@ extension POSPaymentModel {
}

func cancelThenCollectPayment() async {
if case .reconnecting = cardReaderConnectionStatus {
await cardPresentPaymentService.cancelReconnection()
}

try? await cardPresentPaymentService.cancelPayment()

guard case .connected = cardReaderConnectionStatus else {
return
}
await collectCardPayment()
}

Expand All @@ -182,6 +190,12 @@ extension POSPaymentModel {
}
}

func cancelReconnection() {
Task { @MainActor [weak self] in
await self?.cardPresentPaymentService.cancelReconnection()
}
}

func disconnectCardReader() {
analytics.track(.cardReaderDisconnectTapped)
Task { @MainActor [weak self] in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,11 @@ extension PointOfSaleAggregateModel {
paymentModel.connectCardReader()
}

@MainActor
func cancelReconnection() {
paymentModel.cancelReconnection()
}

@MainActor
func disconnectCardReader() {
paymentModel.disconnectCardReader()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ protocol POSSettingsControllerProtocol {
guard let self else { return }
let cardReader: CardPresentPaymentCardReader?
switch connectionStatus {
case .connected(let reader):
case .connected(let reader), .reconnecting(let reader):
cardReader = reader
default:
cardReader = nil
Expand Down
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
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

@staskus staskus Apr 13, 2026

Choose a reason for hiding this comment

The 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

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

Expand All @@ -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."
)
}
}

Expand Down
Loading