diff --git a/Experiments/Experiments/DefaultFeatureFlagService.swift b/Experiments/Experiments/DefaultFeatureFlagService.swift index b536727466c..6bbc82edb6d 100644 --- a/Experiments/Experiments/DefaultFeatureFlagService.swift +++ b/Experiments/Experiments/DefaultFeatureFlagService.swift @@ -53,6 +53,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService { return buildConfig == .localDeveloper || buildConfig == .alpha case .analyticsHub: return buildConfig == .localDeveloper || buildConfig == .alpha + case .tapToPayOnIPhone: + return buildConfig == .localDeveloper default: return true } diff --git a/Experiments/Experiments/FeatureFlag.swift b/Experiments/Experiments/FeatureFlag.swift index edb18d13837..4d641904fc3 100644 --- a/Experiments/Experiments/FeatureFlag.swift +++ b/Experiments/Experiments/FeatureFlag.swift @@ -70,6 +70,10 @@ public enum FeatureFlag: Int { /// case inAppPurchases + /// Enables Tap to Pay on iPhone flow in In-Person Payments, on eligible devices. + /// + case tapToPayOnIPhone + /// Store creation MVP. /// case storeCreationMVP diff --git a/Hardware/Hardware.xcodeproj/project.pbxproj b/Hardware/Hardware.xcodeproj/project.pbxproj index f4633f623d6..686974450d8 100644 --- a/Hardware/Hardware.xcodeproj/project.pbxproj +++ b/Hardware/Hardware.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 02B5147A28254ED300750B71 /* Codegen in Frameworks */ = {isa = PBXBuildFile; productRef = 02B5147928254ED300750B71 /* Codegen */; }; 030338102705F7D400764131 /* ReceiptTotalLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0303380F2705F7D400764131 /* ReceiptTotalLine.swift */; }; 035DBA3929251ED6003E5125 /* CardReaderInputOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035DBA3829251ED6003E5125 /* CardReaderInputOptions.swift */; }; + 035DBA41292BBEB2003E5125 /* CardReaderDiscoveryMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035DBA40292BBEB2003E5125 /* CardReaderDiscoveryMethod.swift */; }; 039D948B2760C0660044EF38 /* NoOpCardReaderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039D948A2760C0660044EF38 /* NoOpCardReaderService.swift */; }; 03B440AA2754DFC400759429 /* UnderlyingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B440A92754DFC400759429 /* UnderlyingError.swift */; }; 03CF78D327C6710C00523706 /* interac.svg in Resources */ = {isa = PBXBuildFile; fileRef = 03CF78D227C6710B00523706 /* interac.svg */; }; @@ -155,6 +156,7 @@ 028C39DF28255CFE0007BA25 /* Models+Copiable.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Models+Copiable.generated.swift"; sourceTree = ""; }; 0303380F2705F7D400764131 /* ReceiptTotalLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptTotalLine.swift; sourceTree = ""; }; 035DBA3829251ED6003E5125 /* CardReaderInputOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderInputOptions.swift; sourceTree = ""; }; + 035DBA40292BBEB2003E5125 /* CardReaderDiscoveryMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderDiscoveryMethod.swift; sourceTree = ""; }; 039D948A2760C0660044EF38 /* NoOpCardReaderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoOpCardReaderService.swift; sourceTree = ""; }; 03B440A92754DFC400759429 /* UnderlyingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnderlyingError.swift; sourceTree = ""; }; 03CF78D227C6710B00523706 /* interac.svg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = interac.svg; sourceTree = ""; }; @@ -478,6 +480,7 @@ D845BDCB262D9B7700A3E40F /* CardBrand+Stripe.swift */, D845BDC5262D9A4200A3E40F /* CardPresentDetails+Stripe.swift */, D8DF5F4925DD9F7A008AFE25 /* CardReader+Stripe.swift */, + 035DBA40292BBEB2003E5125 /* CardReaderDiscoveryMethod.swift */, D865C61D261CE001006717B8 /* CardReaderEvent+Stripe.swift */, D8DF5F4D25DD9F91008AFE25 /* CardReaderType+Stripe.swift */, D89B8F1125DDCBCD0001C726 /* Charge+Stripe.swift */, @@ -831,6 +834,7 @@ D89B8F0C25DDC9D30001C726 /* ChargeStatus.swift in Sources */, 035DBA3929251ED6003E5125 /* CardReaderInputOptions.swift in Sources */, E140F61C2668CDC900FDB5FF /* Logging.swift in Sources */, + 035DBA41292BBEB2003E5125 /* CardReaderDiscoveryMethod.swift in Sources */, 03CF78D727DF9BE600523706 /* RefundParameters.swift in Sources */, 03B440AA2754DFC400759429 /* UnderlyingError.swift in Sources */, E1E125AC26EB582B0068A9B0 /* CardReaderSoftwareUpdateState.swift in Sources */, diff --git a/Hardware/Hardware/CardReader/CardReaderService.swift b/Hardware/Hardware/CardReader/CardReaderService.swift index 9c418081153..107dce7b011 100644 --- a/Hardware/Hardware/CardReader/CardReaderService.swift +++ b/Hardware/Hardware/CardReader/CardReaderService.swift @@ -20,7 +20,7 @@ public protocol CardReaderService { /// Starts the service. /// That could imply, for example, that the reader discovery process starts - func start(_ configProvider: CardReaderConfigProvider) throws + func start(_ configProvider: CardReaderConfigProvider, discoveryMethod: CardReaderDiscoveryMethod) throws /// Cancels the discovery process. func cancelDiscovery() -> Future diff --git a/Hardware/Hardware/CardReader/CardReaderType.swift b/Hardware/Hardware/CardReader/CardReaderType.swift index 45591af0cfa..6e938d3c39b 100644 --- a/Hardware/Hardware/CardReader/CardReaderType.swift +++ b/Hardware/Hardware/CardReader/CardReaderType.swift @@ -7,6 +7,8 @@ public enum CardReaderType: CaseIterable { case stripeM2 /// BBPOS WisePad 3 case wisepad3 + /// Tap on Mobile: Apple built in reader + case appleBuiltIn /// Other case other } @@ -26,6 +28,8 @@ extension CardReaderType { return "STRIPE_M2" case .wisepad3: return "WISEPAD_3" + case .appleBuiltIn: + return "BUILT_IN" default: return "UNKNOWN" } diff --git a/Hardware/Hardware/CardReader/StripeCardReader/CardReaderDiscoveryMethod.swift b/Hardware/Hardware/CardReader/StripeCardReader/CardReaderDiscoveryMethod.swift new file mode 100644 index 00000000000..c212372a44f --- /dev/null +++ b/Hardware/Hardware/CardReader/StripeCardReader/CardReaderDiscoveryMethod.swift @@ -0,0 +1,18 @@ +#if !targetEnvironment(macCatalyst) +import Foundation +import StripeTerminal + +public enum CardReaderDiscoveryMethod { + case localMobile + case bluetoothProximity + + func toStripe() -> DiscoveryMethod { + switch self { + case .localMobile: + return .localMobile + case .bluetoothProximity: + return .bluetoothProximity + } + } +} +#endif diff --git a/Hardware/Hardware/CardReader/StripeCardReader/CardReaderType+Stripe.swift b/Hardware/Hardware/CardReader/StripeCardReader/CardReaderType+Stripe.swift index c096f077e8f..f6c9391dc1a 100644 --- a/Hardware/Hardware/CardReader/StripeCardReader/CardReaderType+Stripe.swift +++ b/Hardware/Hardware/CardReader/StripeCardReader/CardReaderType+Stripe.swift @@ -13,6 +13,8 @@ extension CardReaderType { return .stripeM2 case .wisePad3: return .wisepad3 + case .appleBuiltIn: + return appleBuiltIn default: return .other } diff --git a/Hardware/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift b/Hardware/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift index 22d2498abf1..1986fe6fa9e 100644 --- a/Hardware/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift +++ b/Hardware/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift @@ -20,7 +20,7 @@ public struct NoOpCardReaderService: CardReaderService { /// Starts the service. /// That could imply, for example, that the reader discovery process starts - public func start(_ configProvider: CardReaderConfigProvider) throws { + public func start(_ configProvider: CardReaderConfigProvider, discoveryMethod: CardReaderDiscoveryMethod) throws { // no-op } diff --git a/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index ecae21734c6..28fa79d0cb0 100644 --- a/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -58,7 +58,8 @@ extension StripeCardReaderService: CardReaderService { // MARK: - CardReaderService conformance. Commands - public func start(_ configProvider: CardReaderConfigProvider) throws { + public func start(_ configProvider: CardReaderConfigProvider, + discoveryMethod: CardReaderDiscoveryMethod) throws { setConfigProvider(configProvider) Terminal.setLogListener { message in @@ -85,13 +86,12 @@ extension StripeCardReaderService: CardReaderService { } let config = DiscoveryConfiguration( - discoveryMethod: .bluetoothScan, + discoveryMethod: discoveryMethod.toStripe(), simulated: shouldUseSimulatedCardReader ) - // If we're using the simulated reader, we don't want to check for Bluetooth permissions - // as the simulator won't have Bluetooth available. - guard shouldUseSimulatedCardReader || CBCentralManager.authorization != .denied else { + guard shouldSkipBluetoothCheck(discoveryConfiguration: config) || + CBCentralManager.authorization != .denied else { throw CardReaderServiceError.bluetoothDenied } @@ -123,6 +123,14 @@ extension StripeCardReaderService: CardReaderService { }) } + + // If we're using the simulated reader, we don't want to check for Bluetooth permissions + // as the simulator won't have Bluetooth available. + // If we're using the built-in reader, bluetooth is not required. + private func shouldSkipBluetoothCheck(discoveryConfiguration: DiscoveryConfiguration) -> Bool { + shouldUseSimulatedCardReader || discoveryConfiguration.discoveryMethod == .localMobile + } + public func cancelDiscovery() -> Future { Future { [weak self] promise in /** @@ -311,9 +319,20 @@ extension StripeCardReaderService: CardReaderService { }.eraseToAnyPublisher() } - return getBluetoothConfiguration(stripeReader).flatMap { configuration in - self.connect(stripeReader, configuration: configuration) - }.eraseToAnyPublisher() + switch stripeReader.deviceType { + case .appleBuiltIn: + return getLocalMobileConfiguration(stripeReader).flatMap { configuration in + self.connect(stripeReader, configuration: configuration) + } + .share() + .eraseToAnyPublisher() + default: + return getBluetoothConfiguration(stripeReader).flatMap { configuration in + self.connect(stripeReader, configuration: configuration) + } + .share() + .eraseToAnyPublisher() + } } private func getBluetoothConfiguration(_ reader: StripeTerminal.Reader) -> Future { @@ -337,6 +356,27 @@ extension StripeCardReaderService: CardReaderService { } } + private func getLocalMobileConfiguration(_ reader: StripeTerminal.Reader) -> Future { + return Future() { [weak self] promise in + guard let self = self else { + promise(.failure(CardReaderServiceError.connection())) + return + } + + // TODO - If we've recently connected to this reader, use the cached locationId from the + // Terminal SDK instead of making this fetch. See #5116 and #5087 + self.readerLocationProvider?.fetchDefaultLocationID { result in + switch result { + case .success(let locationId): + return promise(.success(LocalMobileConnectionConfiguration(locationId: locationId))) + case .failure(let error): + let underlyingError = UnderlyingError(with: error) + return promise(.failure(CardReaderServiceError.connection(underlyingError: underlyingError))) + } + } + } + } + public func connect(_ reader: StripeTerminal.Reader, configuration: BluetoothConnectionConfiguration) -> Future { // Keep a copy of the battery level in case the connection fails due to low battery // If that happens, the reader object won't be accessible anymore, and we want to show @@ -376,6 +416,40 @@ extension StripeCardReaderService: CardReaderService { } } + public func connect(_ reader: StripeTerminal.Reader, configuration: LocalMobileConnectionConfiguration) -> Future { + return Future { [weak self] promise in + guard let self = self else { + promise(.failure(CardReaderServiceError.connection())) + return + } + + Terminal.shared.connectLocalMobileReader(reader, delegate: self, connectionConfig: configuration) { [weak self] (reader, error) in + guard let self = self else { + promise(.failure(CardReaderServiceError.connection())) + return + } + // Clear cached readers, as per Stripe's documentation. + self.discoveredStripeReadersCache.clear() + + if let error = error { + let underlyingError = UnderlyingError(with: error) + // Starting with StripeTerminal 2.0, required software updates happen transparently on connection + // Any error related to that will be reported here, but we don't want to treat it as a connection error + let serviceError: CardReaderServiceError = underlyingError.isSoftwareUpdateError ? + .softwareUpdate(underlyingError: underlyingError, batteryLevel: nil) : + .connection(underlyingError: underlyingError) + promise(.failure(serviceError)) + } + + if let reader = reader { + self.connectedReadersSubject.send([CardReader(reader: reader)]) + self.switchStatusToIdle() + promise(.success(CardReader(reader: reader))) + } + } + } + } + public func installUpdate() -> Void { Terminal.shared.installAvailableUpdate() } @@ -443,6 +517,7 @@ private extension StripeCardReaderService { if underlyingError == .commandCancelled { DDLogWarn("💳 Warning: collect payment error cancelled. We actively ignore this error \(error)") + promise(.failure(CardReaderServiceError.paymentCancellation(underlyingError: underlyingError))) } } @@ -696,6 +771,46 @@ extension StripeCardReaderService: BluetoothReaderDelegate { } } +extension StripeCardReaderService: LocalMobileReaderDelegate { + public func localMobileReader(_ reader: Reader, didRequestReaderInput inputOptions: ReaderInputOptions = []) { + sendReaderEvent(CardReaderEvent.make(stripeReaderInputOptions: inputOptions)) + } + + public func localMobileReader(_ reader: Reader, didRequestReaderDisplayMessage displayMessage: ReaderDisplayMessage) { + sendReaderEvent(CardReaderEvent.make(displayMessage: displayMessage)) + } + + + // TODO: use a specific `deviceSetup` in these three functions instead of reusing the softwareUpdateSubject + // https://github.com/woocommerce/woocommerce-ios/issues/8088 + public func localMobileReader(_ reader: Reader, didStartInstallingUpdate update: ReaderSoftwareUpdate, cancelable: Cancelable?) { + softwareUpdateSubject.send(.started(cancelable: cancelable.map(StripeCancelable.init(cancelable:)))) + } + + public func localMobileReader(_ reader: Reader, didReportReaderSoftwareUpdateProgress progress: Float) { + softwareUpdateSubject.send(.installing(progress: progress)) + } + + public func localMobileReader(_ reader: Reader, didFinishInstallingUpdate update: ReaderSoftwareUpdate?, error: Error?) { + if let error = error { + softwareUpdateSubject.send(.failed( + error: CardReaderServiceError.softwareUpdate(underlyingError: UnderlyingError(with: error), + batteryLevel: reader.batteryLevel?.doubleValue)) + ) + if let requiredDate = update?.requiredAt, + requiredDate > Date() { + softwareUpdateSubject.send(.available) + } else { + softwareUpdateSubject.send(.none) + } + } else { + softwareUpdateSubject.send(.completed) + connectedReadersSubject.send([CardReader(reader: reader)]) + softwareUpdateSubject.send(.none) + } + } +} + // MARK: - Terminal delegate extension StripeCardReaderService: TerminalDelegate { public func terminal(_ terminal: Terminal, didReportUnexpectedReaderDisconnect reader: Reader) { diff --git a/Storage/Storage/Model/Copiable/Models+Copiable.generated.swift b/Storage/Storage/Model/Copiable/Models+Copiable.generated.swift index c16b4d29897..c4fa36d7fcc 100644 --- a/Storage/Storage/Model/Copiable/Models+Copiable.generated.swift +++ b/Storage/Storage/Model/Copiable/Models+Copiable.generated.swift @@ -27,6 +27,7 @@ extension Storage.GeneralAppSettings { isProductSKUInputScannerSwitchEnabled: CopiableProp = .copy, isCouponManagementSwitchEnabled: CopiableProp = .copy, isInAppPurchasesSwitchEnabled: CopiableProp = .copy, + isTapToPayOnIPhoneSwitchEnabled: CopiableProp = .copy, knownCardReaders: CopiableProp<[String]> = .copy, lastEligibilityErrorInfo: NullableCopiableProp = .copy, lastJetpackBenefitsBannerDismissedTime: NullableCopiableProp = .copy, @@ -38,6 +39,7 @@ extension Storage.GeneralAppSettings { let isProductSKUInputScannerSwitchEnabled = isProductSKUInputScannerSwitchEnabled ?? self.isProductSKUInputScannerSwitchEnabled let isCouponManagementSwitchEnabled = isCouponManagementSwitchEnabled ?? self.isCouponManagementSwitchEnabled let isInAppPurchasesSwitchEnabled = isInAppPurchasesSwitchEnabled ?? self.isInAppPurchasesSwitchEnabled + let isTapToPayOnIPhoneSwitchEnabled = isTapToPayOnIPhoneSwitchEnabled ?? self.isTapToPayOnIPhoneSwitchEnabled let knownCardReaders = knownCardReaders ?? self.knownCardReaders let lastEligibilityErrorInfo = lastEligibilityErrorInfo ?? self.lastEligibilityErrorInfo let lastJetpackBenefitsBannerDismissedTime = lastJetpackBenefitsBannerDismissedTime ?? self.lastJetpackBenefitsBannerDismissedTime @@ -50,6 +52,7 @@ extension Storage.GeneralAppSettings { isProductSKUInputScannerSwitchEnabled: isProductSKUInputScannerSwitchEnabled, isCouponManagementSwitchEnabled: isCouponManagementSwitchEnabled, isInAppPurchasesSwitchEnabled: isInAppPurchasesSwitchEnabled, + isTapToPayOnIPhoneSwitchEnabled: isTapToPayOnIPhoneSwitchEnabled, knownCardReaders: knownCardReaders, lastEligibilityErrorInfo: lastEligibilityErrorInfo, lastJetpackBenefitsBannerDismissedTime: lastJetpackBenefitsBannerDismissedTime, diff --git a/Storage/Storage/Model/GeneralAppSettings.swift b/Storage/Storage/Model/GeneralAppSettings.swift index a0176adfb0b..80744929d95 100644 --- a/Storage/Storage/Model/GeneralAppSettings.swift +++ b/Storage/Storage/Model/GeneralAppSettings.swift @@ -36,6 +36,10 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable { /// public var isInAppPurchasesSwitchEnabled: Bool + /// The state for the Tap to Pay on iPhone feature switch. + /// + public var isTapToPayOnIPhoneSwitchEnabled: Bool + /// A list (possibly empty) of known card reader IDs - i.e. IDs of card readers that should be reconnected to automatically /// e.g. ["CHB204909005931"] /// @@ -58,6 +62,7 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable { isProductSKUInputScannerSwitchEnabled: Bool, isCouponManagementSwitchEnabled: Bool, isInAppPurchasesSwitchEnabled: Bool, + isTapToPayOnIPhoneSwitchEnabled: Bool, knownCardReaders: [String], lastEligibilityErrorInfo: EligibilityErrorInfo? = nil, lastJetpackBenefitsBannerDismissedTime: Date? = nil, @@ -72,6 +77,7 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable { self.lastJetpackBenefitsBannerDismissedTime = lastJetpackBenefitsBannerDismissedTime self.featureAnnouncementCampaignSettings = featureAnnouncementCampaignSettings self.isInAppPurchasesSwitchEnabled = isInAppPurchasesSwitchEnabled + self.isTapToPayOnIPhoneSwitchEnabled = isTapToPayOnIPhoneSwitchEnabled } public static var `default`: Self { @@ -81,6 +87,7 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable { isProductSKUInputScannerSwitchEnabled: false, isCouponManagementSwitchEnabled: false, isInAppPurchasesSwitchEnabled: false, + isTapToPayOnIPhoneSwitchEnabled: false, knownCardReaders: [], lastEligibilityErrorInfo: nil, featureAnnouncementCampaignSettings: [:]) @@ -110,6 +117,7 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable { isProductSKUInputScannerSwitchEnabled: isProductSKUInputScannerSwitchEnabled, isCouponManagementSwitchEnabled: isCouponManagementSwitchEnabled, isInAppPurchasesSwitchEnabled: isInAppPurchasesSwitchEnabled, + isTapToPayOnIPhoneSwitchEnabled: isTapToPayOnIPhoneSwitchEnabled, knownCardReaders: knownCardReaders, lastEligibilityErrorInfo: lastEligibilityErrorInfo, featureAnnouncementCampaignSettings: featureAnnouncementCampaignSettings @@ -130,6 +138,7 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable { isProductSKUInputScannerSwitchEnabled: isProductSKUInputScannerSwitchEnabled, isCouponManagementSwitchEnabled: isCouponManagementSwitchEnabled, isInAppPurchasesSwitchEnabled: isInAppPurchasesSwitchEnabled, + isTapToPayOnIPhoneSwitchEnabled: isTapToPayOnIPhoneSwitchEnabled, knownCardReaders: knownCardReaders, lastEligibilityErrorInfo: lastEligibilityErrorInfo, featureAnnouncementCampaignSettings: updatedSettings @@ -150,6 +159,7 @@ extension GeneralAppSettings { self.isProductSKUInputScannerSwitchEnabled = try container.decodeIfPresent(Bool.self, forKey: .isProductSKUInputScannerSwitchEnabled) ?? false self.isCouponManagementSwitchEnabled = try container.decodeIfPresent(Bool.self, forKey: .isCouponManagementSwitchEnabled) ?? false self.isInAppPurchasesSwitchEnabled = try container.decodeIfPresent(Bool.self, forKey: .isInAppPurchasesSwitchEnabled) ?? false + self.isTapToPayOnIPhoneSwitchEnabled = try container.decodeIfPresent(Bool.self, forKey: .isTapToPayOnIPhoneSwitchEnabled) ?? false self.knownCardReaders = try container.decodeIfPresent([String].self, forKey: .knownCardReaders) ?? [] self.lastEligibilityErrorInfo = try container.decodeIfPresent(EligibilityErrorInfo.self, forKey: .lastEligibilityErrorInfo) self.lastJetpackBenefitsBannerDismissedTime = try container.decodeIfPresent(Date.self, forKey: .lastJetpackBenefitsBannerDismissedTime) diff --git a/Storage/StorageTests/Model/AppSettings/GeneralAppSettingsTests.swift b/Storage/StorageTests/Model/AppSettings/GeneralAppSettingsTests.swift index 04429ca9589..11cac5b92e8 100644 --- a/Storage/StorageTests/Model/AppSettings/GeneralAppSettingsTests.swift +++ b/Storage/StorageTests/Model/AppSettings/GeneralAppSettingsTests.swift @@ -67,6 +67,7 @@ final class GeneralAppSettingsTests: XCTestCase { isProductSKUInputScannerSwitchEnabled: true, isCouponManagementSwitchEnabled: true, isInAppPurchasesSwitchEnabled: false, + isTapToPayOnIPhoneSwitchEnabled: false, knownCardReaders: readers, lastEligibilityErrorInfo: eligibilityInfo, lastJetpackBenefitsBannerDismissedTime: jetpackBannerDismissedDate, @@ -103,6 +104,7 @@ private extension GeneralAppSettingsTests { isProductSKUInputScannerSwitchEnabled: Bool = false, isCouponManagementSwitchEnabled: Bool = false, isInAppPurchasesSwitchEnabled: Bool = false, + isTapToPayOnIPhoneSwitchEnabled: Bool = false, knownCardReaders: [String] = [], lastEligibilityErrorInfo: EligibilityErrorInfo? = nil, lastJetpackBenefitsBannerDismissedTime: Date? = nil, @@ -114,6 +116,7 @@ private extension GeneralAppSettingsTests { isProductSKUInputScannerSwitchEnabled: isProductSKUInputScannerSwitchEnabled, isCouponManagementSwitchEnabled: isCouponManagementSwitchEnabled, isInAppPurchasesSwitchEnabled: isInAppPurchasesSwitchEnabled, + isTapToPayOnIPhoneSwitchEnabled: isTapToPayOnIPhoneSwitchEnabled, knownCardReaders: knownCardReaders, lastEligibilityErrorInfo: lastEligibilityErrorInfo, lastJetpackBenefitsBannerDismissedTime: lastJetpackBenefitsBannerDismissedTime, diff --git a/WooCommerce/Classes/Model/BetaFeature.swift b/WooCommerce/Classes/Model/BetaFeature.swift index 1aeed9db83d..71072c876a8 100644 --- a/WooCommerce/Classes/Model/BetaFeature.swift +++ b/WooCommerce/Classes/Model/BetaFeature.swift @@ -5,6 +5,7 @@ enum BetaFeature: String, CaseIterable { case productSKUScanner case couponManagement case inAppPurchases + case tapToPayOnIPhone } extension BetaFeature { @@ -18,6 +19,8 @@ extension BetaFeature { return Localization.couponManagementTitle case .inAppPurchases: return Localization.inAppPurchasesManagementTitle + case .tapToPayOnIPhone: + return Localization.tapToPayOnIPhoneTitle } } @@ -31,6 +34,8 @@ extension BetaFeature { return Localization.couponManagementDescription case .inAppPurchases: return Localization.inAppPurchasesManagementDescription + case .tapToPayOnIPhone: + return Localization.tapToPayOnIPhoneDescription } } @@ -44,6 +49,8 @@ extension BetaFeature { return \.isCouponManagementSwitchEnabled case .inAppPurchases: return \.isInAppPurchasesSwitchEnabled + case .tapToPayOnIPhone: + return \.isTapToPayOnIPhoneSwitchEnabled } } @@ -62,6 +69,8 @@ extension BetaFeature { switch self { case .inAppPurchases: return ServiceLocator.featureFlagService.isFeatureFlagEnabled(.inAppPurchases) + case .tapToPayOnIPhone: + return ServiceLocator.featureFlagService.isFeatureFlagEnabled(.tapToPayOnIPhone) default: return true } @@ -139,5 +148,14 @@ private extension BetaFeature { static let inAppPurchasesManagementDescription = NSLocalizedString( "Test out in-app purchases as we get ready to launch", comment: "Cell description on beta features screen to enable in-app purchases") + + static let tapToPayOnIPhoneTitle = NSLocalizedString( + "Tap to Pay on iPhone", + comment: "Cell tytle on beta features screen to enable Tap to Pay on iPhone: card payments with the " + + "phone's built in reader") + static let tapToPayOnIPhoneDescription = NSLocalizedString( + "Test out In-Person Payments using your phone's built-in card reader, as we get ready to launch. " + + "Supported on iPhone XS and newer phones, running iOS 15.5 or above.", + comment: "Cell description on beta features screen to enable in-app purchases") } } diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift index a172d57dea9..faa8ff9cde6 100644 --- a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift @@ -321,8 +321,13 @@ private extension CardReaderConnectionController { self.state = .searching var didAutoAdvance = false + // TODO: make this a choice for the user, when the switch is enabled + let tapOnIphoneEnabled = ServiceLocator.generalAppSettings.settings.isTapToPayOnIPhoneSwitchEnabled + let discoveryMethod: CardReaderDiscoveryMethod = tapOnIphoneEnabled ? .localMobile : .bluetoothProximity + let action = CardPresentPaymentAction.startCardReaderDiscovery( siteID: siteID, + discoveryMethod: discoveryMethod, onReaderDiscovered: { [weak self] cardReaders in guard let self = self else { return diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardReaderType+Manual.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardReaderType+Manual.swift index db147d88e99..d5534d59b8f 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardReaderType+Manual.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardReaderType+Manual.swift @@ -25,7 +25,7 @@ extension CardReaderType { name: "Wisepad 3", urlString: "https://stripe.com/files/docs/terminal/wp3_product_sheet.pdf" ) - case .other: + case .other, .appleBuiltIn: return nil } } diff --git a/WooCommerce/Resources/Woo-Debug.entitlements b/WooCommerce/Resources/Woo-Debug.entitlements index 598dddcb43f..ecd83cb7a09 100644 --- a/WooCommerce/Resources/Woo-Debug.entitlements +++ b/WooCommerce/Resources/Woo-Debug.entitlements @@ -49,5 +49,7 @@ $(AppIdentifierPrefix)com.automattic.woocommerce + com.apple.developer.proximity-reader.payment.acceptance + diff --git a/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift b/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift index a6bb554f49e..6b04ac16c40 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockCardPresentPaymentsStoresManager.swift @@ -50,7 +50,7 @@ final class MockCardPresentPaymentsStoresManager: DefaultStoresManager { switch action { case .observeConnectedReaders(let onCompletion): onCompletion(connectedReaders) - case .startCardReaderDiscovery(_, let onReaderDiscovered, let onError): + case .startCardReaderDiscovery(_, _, let onReaderDiscovered, let onError): guard !failDiscovery else { onError(MockErrors.discoveryFailure) return diff --git a/WooCommerce/WooCommerceTests/Stripe Integration Tests/StripeCardReaderIntegrationTests.swift b/WooCommerce/WooCommerceTests/Stripe Integration Tests/StripeCardReaderIntegrationTests.swift index aae2ea2e4ca..285ccdfe1aa 100644 --- a/WooCommerce/WooCommerceTests/Stripe Integration Tests/StripeCardReaderIntegrationTests.swift +++ b/WooCommerce/WooCommerceTests/Stripe Integration Tests/StripeCardReaderIntegrationTests.swift @@ -32,7 +32,7 @@ final class StripeCardReaderIntegrationTests: XCTestCase { } }.store(in: &cancellables) - try! readerService.start(MockTokenProvider()) + try! readerService.start(MockTokenProvider(), discoveryMethod: .bluetoothProximity) wait(for: [receivedReaders], timeout: Constants.expectationTimeout) } @@ -68,7 +68,7 @@ final class StripeCardReaderIntegrationTests: XCTestCase { .fulfillOnCompletion(expectation: discoveredReaders) }.store(in: &cancellables) - try! readerService.start(MockTokenProvider()) + try! readerService.start(MockTokenProvider(), discoveryMethod: .bluetoothProximity) wait(for: [discoveredReaders], timeout: Constants.expectationTimeout) } @@ -105,7 +105,7 @@ final class StripeCardReaderIntegrationTests: XCTestCase { } }.store(in: &self.cancellables) - try! readerService.start(MockTokenProvider()) + try! readerService.start(MockTokenProvider(), discoveryMethod: .bluetoothProximity) wait(for: [discoveredReaders, connectedToReader, connectedreaderIsPublished], timeout: Constants.expectationTimeout) } } diff --git a/Yosemite/Yosemite/Actions/CardPresentPaymentAction.swift b/Yosemite/Yosemite/Actions/CardPresentPaymentAction.swift index e42be78e413..25cb31572a5 100644 --- a/Yosemite/Yosemite/Actions/CardPresentPaymentAction.swift +++ b/Yosemite/Yosemite/Actions/CardPresentPaymentAction.swift @@ -25,7 +25,10 @@ public enum CardPresentPaymentAction: Action { /// Start the Card Reader discovery process. /// - case startCardReaderDiscovery(siteID: Int64, onReaderDiscovered: ([CardReader]) -> Void, onError: (Error) -> Void) + case startCardReaderDiscovery(siteID: Int64, + discoveryMethod: CardReaderDiscoveryMethod, + onReaderDiscovered: ([CardReader]) -> Void, + onError: (Error) -> Void) /// Cancels the Card Reader discovery process. /// diff --git a/Yosemite/Yosemite/Model/Model.swift b/Yosemite/Yosemite/Model/Model.swift index f5419d23dba..8cbe8797547 100644 --- a/Yosemite/Yosemite/Model/Model.swift +++ b/Yosemite/Yosemite/Model/Model.swift @@ -137,6 +137,7 @@ public typealias User = Networking.User public typealias WooAPIVersion = Networking.WooAPIVersion public typealias StoredProductSettings = Networking.StoredProductSettings public typealias CardReader = Hardware.CardReader +public typealias CardReaderDiscoveryMethod = Hardware.CardReaderDiscoveryMethod public typealias CardReaderEvent = Hardware.CardReaderEvent public typealias CardReaderInput = Hardware.CardReaderInput public typealias CardReaderSoftwareUpdateState = Hardware.CardReaderSoftwareUpdateState diff --git a/Yosemite/Yosemite/Stores/CardPresentPaymentStore.swift b/Yosemite/Yosemite/Stores/CardPresentPaymentStore.swift index 6c23d2686d8..57465e819ed 100644 --- a/Yosemite/Yosemite/Stores/CardPresentPaymentStore.swift +++ b/Yosemite/Yosemite/Stores/CardPresentPaymentStore.swift @@ -84,8 +84,11 @@ public final class CardPresentPaymentStore: Store { case .loadAccounts(let siteID, let onCompletion): loadAccounts(siteID: siteID, onCompletion: onCompletion) - case .startCardReaderDiscovery(let siteID, let onReaderDiscovered, let onError): - startCardReaderDiscovery(siteID: siteID, onReaderDiscovered: onReaderDiscovered, onError: onError) + case .startCardReaderDiscovery(let siteID, let discoveryMethod, let onReaderDiscovered, let onError): + startCardReaderDiscovery(siteID: siteID, + discoveryMethod: discoveryMethod, + onReaderDiscovered: onReaderDiscovered, + onError: onError) case .cancelCardReaderDiscovery(let completion): cancelCardReaderDiscovery(completion: completion) case .connect(let reader, let completion): @@ -125,15 +128,18 @@ public final class CardPresentPaymentStore: Store { // MARK: - Services // private extension CardPresentPaymentStore { - func startCardReaderDiscovery(siteID: Int64, onReaderDiscovered: @escaping (_ readers: [CardReader]) -> Void, onError: @escaping (Error) -> Void) { + func startCardReaderDiscovery(siteID: Int64, + discoveryMethod: CardReaderDiscoveryMethod, + onReaderDiscovered: @escaping (_ readers: [CardReader]) -> Void, + onError: @escaping (Error) -> Void) { do { switch usingBackend { case .wcpay: commonReaderConfigProvider.setContext(siteID: siteID, remote: self.remote) - try cardReaderService.start(commonReaderConfigProvider) + try cardReaderService.start(commonReaderConfigProvider, discoveryMethod: discoveryMethod) case .stripe: commonReaderConfigProvider.setContext(siteID: siteID, remote: self.stripeRemote) - try cardReaderService.start(commonReaderConfigProvider) + try cardReaderService.start(commonReaderConfigProvider, discoveryMethod: discoveryMethod) } } catch { return onError(error) @@ -153,7 +159,12 @@ private extension CardPresentPaymentStore { } }, receiveValue: { readers in - let supportedReaders = readers.filter({$0.readerType == .chipper || $0.readerType == .stripeM2 || $0.readerType == .wisepad3}) + let supportedReaders = readers.filter({ + $0.readerType == .chipper || + $0.readerType == .stripeM2 || + $0.readerType == .wisepad3 || + $0.readerType == .appleBuiltIn + }) onReaderDiscovered(supportedReaders) } )) diff --git a/Yosemite/YosemiteTests/Mocks/CardPresentPayments/MockCardReaderService.swift b/Yosemite/YosemiteTests/Mocks/CardPresentPayments/MockCardReaderService.swift index 4844f102d1e..7f4ada59d30 100644 --- a/Yosemite/YosemiteTests/Mocks/CardPresentPayments/MockCardReaderService.swift +++ b/Yosemite/YosemiteTests/Mocks/CardPresentPayments/MockCardReaderService.swift @@ -35,6 +35,9 @@ final class MockCardReaderService: CardReaderService { /// Boolean flag Indicates that clients have provided a CardReaderConfigProvider var didReceiveAConfigurationProvider = false + /// DiscoveryMethod recieved on starting a payment + var spyStartDiscoveryMethod: CardReaderDiscoveryMethod? = nil + /// Boolean flag Indicates that clients have called the cancel payment method var didTapCancelPayment = false @@ -58,9 +61,10 @@ final class MockCardReaderService: CardReaderService { } - func start(_ configProvider: CardReaderConfigProvider) throws { + func start(_ configProvider: Hardware.CardReaderConfigProvider, discoveryMethod: Hardware.CardReaderDiscoveryMethod) throws { didHitStart = true didReceiveAConfigurationProvider = true + spyStartDiscoveryMethod = discoveryMethod DispatchQueue.main.asyncAfter(deadline: .now() + 2) {[weak self] in self?.discoveryStatusSubject.send(.discovering) diff --git a/Yosemite/YosemiteTests/Stores/AppSettings/InAppFeedbackCardVisibilityUseCaseTests.swift b/Yosemite/YosemiteTests/Stores/AppSettings/InAppFeedbackCardVisibilityUseCaseTests.swift index ef271504dfe..53c4d3a3717 100644 --- a/Yosemite/YosemiteTests/Stores/AppSettings/InAppFeedbackCardVisibilityUseCaseTests.swift +++ b/Yosemite/YosemiteTests/Stores/AppSettings/InAppFeedbackCardVisibilityUseCaseTests.swift @@ -162,6 +162,7 @@ final class InAppFeedbackCardVisibilityUseCaseTests: XCTestCase { isProductSKUInputScannerSwitchEnabled: false, isCouponManagementSwitchEnabled: false, isInAppPurchasesSwitchEnabled: false, + isTapToPayOnIPhoneSwitchEnabled: false, knownCardReaders: [], featureAnnouncementCampaignSettings: [:]) let useCase = InAppFeedbackCardVisibilityUseCase(settings: settings, feedbackType: .ordersCreation) @@ -230,6 +231,7 @@ private extension InAppFeedbackCardVisibilityUseCaseTests { isProductSKUInputScannerSwitchEnabled: false, isCouponManagementSwitchEnabled: false, isInAppPurchasesSwitchEnabled: false, + isTapToPayOnIPhoneSwitchEnabled: false, knownCardReaders: [], featureAnnouncementCampaignSettings: [:] ) diff --git a/Yosemite/YosemiteTests/Stores/AppSettingsStoreTests.swift b/Yosemite/YosemiteTests/Stores/AppSettingsStoreTests.swift index e6197ba9137..719bfee43ce 100644 --- a/Yosemite/YosemiteTests/Stores/AppSettingsStoreTests.swift +++ b/Yosemite/YosemiteTests/Stores/AppSettingsStoreTests.swift @@ -1007,6 +1007,7 @@ private extension AppSettingsStoreTests { isProductSKUInputScannerSwitchEnabled: false, isCouponManagementSwitchEnabled: false, isInAppPurchasesSwitchEnabled: false, + isTapToPayOnIPhoneSwitchEnabled: false, knownCardReaders: [], featureAnnouncementCampaignSettings: [:] ) @@ -1021,6 +1022,7 @@ private extension AppSettingsStoreTests { isProductSKUInputScannerSwitchEnabled: false, isCouponManagementSwitchEnabled: false, isInAppPurchasesSwitchEnabled: false, + isTapToPayOnIPhoneSwitchEnabled: false, knownCardReaders: [], featureAnnouncementCampaignSettings: featureAnnouncementCampaignSettings ) diff --git a/Yosemite/YosemiteTests/Stores/CardPresentPaymentStoreTests.swift b/Yosemite/YosemiteTests/Stores/CardPresentPaymentStoreTests.swift index cca00406066..2a288e257ec 100644 --- a/Yosemite/YosemiteTests/Stores/CardPresentPaymentStoreTests.swift +++ b/Yosemite/YosemiteTests/Stores/CardPresentPaymentStoreTests.swift @@ -87,7 +87,10 @@ final class CardPresentPaymentStoreTests: XCTestCase { network: network, cardReaderService: mockCardReaderService) - let action = CardPresentPaymentAction.startCardReaderDiscovery(siteID: sampleSiteID, onReaderDiscovered: { _ in }, onError: { _ in }) + let action = CardPresentPaymentAction.startCardReaderDiscovery( + siteID: sampleSiteID, + discoveryMethod: .bluetoothProximity, + onReaderDiscovered: { _ in }, onError: { _ in }) cardPresentStore.onAction(action) @@ -104,6 +107,7 @@ final class CardPresentPaymentStoreTests: XCTestCase { let action = CardPresentPaymentAction.startCardReaderDiscovery( siteID: sampleSiteID, + discoveryMethod: .bluetoothProximity, onReaderDiscovered: { _ in expectation.fulfill() }, @@ -115,13 +119,32 @@ final class CardPresentPaymentStoreTests: XCTestCase { wait(for: [expectation], timeout: Constants.expectationTimeout) } - func test_start_discovery_action_passes_configuraton_provider_to_service() { + func test_start_discovery_action_passes_configuration_provider_to_service() { + let cardPresentStore = CardPresentPaymentStore(dispatcher: dispatcher, + storageManager: storageManager, + network: network, + cardReaderService: mockCardReaderService) + + let action = CardPresentPaymentAction.startCardReaderDiscovery(siteID: sampleSiteID, + discoveryMethod: .bluetoothProximity, + onReaderDiscovered: { _ in }, + onError: { _ in }) + + cardPresentStore.onAction(action) + + XCTAssertTrue(mockCardReaderService.didReceiveAConfigurationProvider) + } + + func test_start_discovery_action_passes_discovery_method_to_service() { let cardPresentStore = CardPresentPaymentStore(dispatcher: dispatcher, storageManager: storageManager, network: network, cardReaderService: mockCardReaderService) - let action = CardPresentPaymentAction.startCardReaderDiscovery(siteID: sampleSiteID, onReaderDiscovered: { _ in }, onError: { _ in }) + let action = CardPresentPaymentAction.startCardReaderDiscovery(siteID: sampleSiteID, + discoveryMethod: .bluetoothProximity, + onReaderDiscovered: { _ in }, + onError: { _ in }) cardPresentStore.onAction(action) @@ -147,6 +170,7 @@ final class CardPresentPaymentStoreTests: XCTestCase { let action = CardPresentPaymentAction.startCardReaderDiscovery( siteID: sampleSiteID, + discoveryMethod: .bluetoothProximity, onReaderDiscovered: { discoveredReaders in XCTAssertTrue(self.mockCardReaderService.didReceiveAConfigurationProvider) if discoveredReaders.count == 0 { @@ -205,7 +229,11 @@ final class CardPresentPaymentStoreTests: XCTestCase { let expectation = self.expectation(description: "Cancelling discovery changes discoveryStatus to idle") - let startDiscoveryAction = CardPresentPaymentAction.startCardReaderDiscovery(siteID: sampleSiteID, onReaderDiscovered: { _ in }, onError: { _ in }) + let startDiscoveryAction = CardPresentPaymentAction.startCardReaderDiscovery( + siteID: sampleSiteID, + discoveryMethod: .bluetoothProximity, + onReaderDiscovered: { _ in }, + onError: { _ in }) cardPresentStore.onAction(startDiscoveryAction)