From 31368c614b5949a03a717c7e4aaea7b859668274 Mon Sep 17 00:00:00 2001 From: Mike Tomko Date: Wed, 9 Sep 2026 17:42:50 -0600 Subject: [PATCH] Release 340 --- README_device_attestation.md | 132 ++ build.gradle | 6 +- certs/device_root_dev.pem | 21 + certs/device_root_dev_legacy.pem | 20 + certs/device_root_prod.pem | 24 + clover-android-connector-sdk/build.gradle | 2 +- .../src/main/res/values-de-rDE/strings.xml | 5 +- .../src/main/res/values-en-rCA/strings.xml | 7 + .../src/main/res/values-en-rGB/strings.xml | 7 + .../src/main/res/values-en-rIE/strings.xml | 7 + .../src/main/res/values-fr-rCA/strings.xml | 8 +- .../src/main/res/values-ja-rJP/strings.xml | 7 + .../src/main/res/values-nl-rNL/strings.xml | 7 + .../src/main/res/values-pt-rBR/strings.xml | 6 +- .../src/main/res/values/strings.xml | 5 +- clover-android-loyalty-kit/build.gradle | 2 +- clover-android-sdk-examples/build.gradle | 3 +- .../scripts/jwt_verify_device_attestation.py | 223 +++ .../scripts/verify_device_attestation.py | 232 +++ .../src/main/AndroidManifest.xml | 15 +- .../src/main/assets/certs | 1 + .../CreateCustomTenderTestActivity.java | 6 +- .../sdk/examples/CustomReceiptProviderTest.kt | 255 ++- .../CustomReceiptProviderTestActivity.kt | 22 +- .../examples/DeviceAttestationTestActivity.kt | 397 +++++ .../DeviceNotificationTestActivity.kt | 158 ++ .../examples/DeviceNotificationViewModel.kt | 170 ++ .../sdk/examples/IntegratorLogoSync.kt | 107 ++ .../sdk/examples/InventoryTestActivity.java | 40 + .../sdk/examples/SampleReceiptGenerator.kt | 717 ++++++++ .../examples/vas/VasReaderStatusActivity.kt | 264 +++ .../activity_device_attestation_test.xml | 112 ++ .../activity_device_notifications_test.xml | 65 + .../res/layout/activity_vas_reader_status.xml | 84 + .../src/main/res/values-de-rDE/strings.xml | 78 + .../src/main/res/values-en-rCA/strings.xml | 78 + .../src/main/res/values-en-rGB/strings.xml | 78 + .../src/main/res/values-en-rIE/strings.xml | 77 + .../src/main/res/values-fr-rCA/strings.xml | 74 + .../src/main/res/values-ja-rJP/strings.xml | 79 + .../src/main/res/values-nl-rNL/strings.xml | 77 + .../src/main/res/values-pt-rBR/strings.xml | 77 + .../src/main/res/values/strings.xml | 167 +- .../main/res/values/test_receipt_sizes.xml | 1 + clover-android-sdk-retrofit/build.gradle | 2 +- clover-android-sdk/build.gradle | 3 +- clover-android-sdk/overview.md | 1 + .../clover/sdk/v1/tender/ITenderService.aidl | 24 +- .../clover/sdk/v3/base/TenderProperties.aidl | 4 + .../sdk/v3/inventory/BundleDefinition.aidl | 3 + .../clover/sdk/v3/inventory/BundleItem.aidl | 3 + .../sdk/v3/inventory/BundleItemGroup.aidl | 3 + .../sdk/v3/inventory/IInventoryService.aidl | 47 + .../com/clover/sdk/v3/inventory/Marker.aidl | 3 + .../sdk/v3/order/AddLineItemOperation.aidl | 3 + .../v3/order/AddModificationsOperation.aidl | 3 + .../order/DeleteModificationsOperation.aidl | 3 + .../sdk/v3/order/IOrderServiceV3_1.aidl | 31 + .../sdk/v3/order/UpdateBundleComponent.aidl | 3 + .../UpdateBundleComponentFdParcelable.aidl | 3 + .../sdk/v3/order/UpdateBundleGroup.aidl | 3 + .../raw/model/GetManualCardRequest.aidl | 4 + .../service/IRawExtTransactionService.aidl | 3 + .../sdk/v3/shipping/ShippingOrderDetails.aidl | 4 + .../clover/sdk/v3/vas/IVasReaderService.aidl | 64 + .../sdk/v3/vas/IVasReaderSessionListener.aidl | 7 + .../clover/common2/payments/PayIntent.java | 49 +- .../java/com/clover/sdk/GenericClient.java | 4 +- .../java/com/clover/sdk/util/Platform2.java | 13 +- .../java/com/clover/sdk/util/TaxRateLabels.kt | 14 + .../main/java/com/clover/sdk/v1/Intents.java | 38 +- .../com/clover/sdk/v1/app/CloseoutAppEvent.kt | 3 +- .../com/clover/sdk/v1/merchant/Module.java | 36 +- .../sdk/v1/printer/job/CashEventPrintJob.java | 862 ++++++++++ .../clover/sdk/v1/printer/job/PrintJob.java | 7 +- .../sdk/v1/printer/job/ReportPrintJob.java | 2 +- .../v1/printer/job/StaticOrderPrintJob.java | 60 + .../java/com/clover/sdk/v1/tender/Tender.java | 18 +- .../clover/sdk/v1/tender/TenderConnector.java | 28 + .../main/java/com/clover/sdk/v3/apps/App.java | 29 + .../com/clover/sdk/v3/base/BusinessLine.java | 57 + .../clover/sdk/v3/base/CutoffProportion.java | 57 + .../java/com/clover/sdk/v3/base/Tender.java | 48 +- .../clover/sdk/v3/base/TenderCategory.java | 57 + .../clover/sdk/v3/base/TenderPbbConfig.java | 214 +++ .../clover/sdk/v3/base/TenderProperties.java | 372 +++++ .../sdk/v3/base/TenderRefundConfig.java | 286 ++++ .../sdk/v3/base/TenderTransactionLimit.java | 313 ++++ .../sdk/v3/base/TenderTransactionSource.java | 58 + .../clover/sdk/v3/base/TenderVoidConfig.java | 281 ++++ .../com/clover/sdk/v3/cash/CashContract.java | 23 +- .../com/clover/sdk/v3/cash/CashEvent.java | 74 +- .../java/com/clover/sdk/v3/cash/Type.java | 2 +- .../clover/sdk/v3/customers/TokenType.java | 2 +- .../sdk/v3/device/DeviceAttestationClient.kt | 126 ++ .../v3/device/internal/AttestationPayload.kt | 7 + .../sdk/v3/device/internal/CompactJws.kt | 118 ++ .../v3/device/internal/InternalCloverApi.kt | 8 + .../clover/sdk/v3/employees/Permission.java | 2 +- .../sdk/v3/happyhour/HappyHourDiscount.java | 66 + .../sdk/v3/inventory/BundleDefinition.java | 376 +++++ .../clover/sdk/v3/inventory/BundleItem.java | 344 ++++ .../sdk/v3/inventory/BundleItemGroup.java | 387 +++++ .../sdk/v3/inventory/BundlePricingMode.java | 57 + .../clover/sdk/v3/inventory/BundleType.java | 57 + .../com/clover/sdk/v3/inventory/Discount.java | 34 +- .../sdk/v3/inventory/InventoryConnector.java | 27 + .../sdk/v3/inventory/InventoryContract.java | 694 ++++++++ .../com/clover/sdk/v3/inventory/Item.java | 69 +- .../sdk/v3/inventory/ItemEntitlementPlan.java | 954 +++++++++++ .../ItemEntitlementPlanAvailabilityMenu.java | 364 ++++ .../inventory/ItemEntitlementPlanOption.java | 528 ++++++ .../ItemEntitlementPlanOptionType.java | 57 + .../ItemEntitlementPlanPaidExtension.java | 465 ++++++ .../ItemEntitlementPlanThrottle.java | 323 ++++ .../v3/inventory/ItemMarkerAssociation.java | 356 ++++ .../sdk/v3/inventory/ItemTaxRatePrice.java | 390 +++++ .../com/clover/sdk/v3/inventory/ItemType.java | 4 +- .../com/clover/sdk/v3/inventory/Marker.java | 560 +++++++ .../v3/inventory/MobileOrderingCutoff.java | 57 + .../com/clover/sdk/v3/inventory/Modifier.java | 68 +- .../sdk/v3/inventory/ModifierGroup.java | 290 +++- .../sdk/v3/inventory/ModifierGroupType.java | 57 + .../sdk/v3/inventory/OrderFeeTaxRate.java | 348 ++++ .../sdk/v3/inventory/SystemTaxRate.java | 291 ++++ .../com/clover/sdk/v3/inventory/TaxRate.java | 39 +- .../com/clover/sdk/v3/merchant/Gateway.java | 610 ++++++- .../merchant/MerchantAllergenFlagSetting.java | 40 +- .../v3/nfc/connector/NfcServiceConnector.kt | 3 +- .../sdk/v3/onlineorder/OnlineOrder.java | 35 + .../v3/onlineorder/OnlineOrderCustomer.java | 45 +- .../v3/onlineorder/OnlineOrderMerchant.java | 98 +- .../v3/onlineorder/OnlineOrderProvider.java | 64 +- .../sdk/v3/onlineorder/OnlineSupportInfo.java | 249 +++ .../sdk/v3/order/AddLineItemOperation.java | 349 ++++ .../v3/order/AddModificationsOperation.java | 256 +++ .../clover/sdk/v3/order/BundleComponent.java | 377 +++++ .../com/clover/sdk/v3/order/BundleGroup.java | 387 +++++ .../sdk/v3/order/BundleGroupLineItem.java | 347 ++++ .../com/clover/sdk/v3/order/BundleType.java | 57 + .../order/DeleteModificationsOperation.java | 253 +++ .../com/clover/sdk/v3/order/DisplayOrder.java | 70 + .../sdk/v3/order/ItemEntitlementSession.java | 1485 +++++++++++++++++ .../order/ItemEntitlementSessionLineItem.java | 389 +++++ .../ItemEntitlementSessionLineItemRole.java | 57 + .../v3/order/ItemEntitlementSessionState.java | 57 + .../sdk/v3/order/ItemOrderingThrottle.java | 418 +++++ .../com/clover/sdk/v3/order/LineItem.java | 61 + .../com/clover/sdk/v3/order/LineItemType.java | 57 + .../java/com/clover/sdk/v3/order/Order.java | 414 ++++- .../com/clover/sdk/v3/order/OrderIntent.java | 1 + .../clover/sdk/v3/order/OrderTypeTaxRate.java | 422 +++++ .../sdk/v3/order/OrderV31Connector.java | 37 + .../com/clover/sdk/v3/order/TimedSession.java | 1172 +++++++++++++ .../sdk/v3/order/TimedSessionDisposition.java | 57 + .../sdk/v3/order/UpdateBundleComponent.java | 221 +++ .../UpdateBundleComponentFdParcelable.java | 46 + .../sdk/v3/order/UpdateBundleGroup.java | 369 ++++ .../sdk/v3/pay/PaymentRequestCardDetails.java | 86 +- .../clover/sdk/v3/pay/TerminalGroupData.java | 359 ++++ .../v3/payment/raw/model/CurrencyDetail.java | 6 +- .../raw/model/GetManualCardRequest.java | 306 ++++ .../payment/raw/model/OperationalReport.java | 104 +- .../model/OperationalReportHistoryList.java | 216 +++ .../model/OperationalReportHistoryRow.java | 56 +- .../sdk/v3/payments/AdditionalCharge.java | 64 + .../v3/payments/AdditionalChargeAmount.java | 34 + .../v3/payments/AdditionalChargeExtra.java | 221 +++ .../sdk/v3/payments/AdditionalChargeType.java | 2 +- .../sdk/v3/payments/AnomalyReasonType.java | 58 + .../sdk/v3/payments/AnomalyReasons.java | 32 + .../sdk/v3/payments/CardTransaction.java | 46 +- .../v3/payments/CardTransactionConstants.java | 1 + .../clover/sdk/v3/payments/CashDetails.java | 352 ++++ .../sdk/v3/payments/CashDrawerConfig.java | 486 ++++++ .../sdk/v3/payments/CashDrawerSpec.java | 222 +++ .../sdk/v3/payments/CountryDenomination.java | 256 +++ .../clover/sdk/v3/payments/DebitRefund.java | 249 +++ .../clover/sdk/v3/payments/Denomination.java | 251 +++ .../sdk/v3/payments/DenominationConfig.java | 222 +++ .../v3/payments/FiscalizationSignature.java | 286 ++++ .../sdk/v3/payments/FiscalizationTag.java | 272 +++ .../v3/payments/NextShiftOpeningDetails.java | 288 ++++ .../sdk/v3/payments/NonCashDetailItem.java | 314 ++++ .../sdk/v3/payments/NonCashDetails.java | 352 ++++ .../com/clover/sdk/v3/payments/Payment.java | 37 +- .../sdk/v3/payments/ReceiptOptionType.java | 1 + .../sdk/v3/payments/Reconciliation.java | 289 ++++ .../com/clover/sdk/v3/payments/Refund.java | 47 +- .../sdk/v3/payments/RoundingDetails.java | 283 ++++ .../clover/sdk/v3/payments/RoundingMode.java | 58 + .../sdk/v3/payments/RoundingPosition.java | 58 + .../clover/sdk/v3/payments/RoundingStep.java | 58 + .../clover/sdk/v3/payments/TenderDetails.java | 256 +++ .../sdk/v3/payments/TokenizeCardRequest.java | 34 +- .../sdk/v3/payments/TransactionInfo.java | 94 +- .../com/clover/sdk/v3/payments/VasMode.java | 2 +- .../clover/sdk/v3/payments/VasPassInfo.java | 320 ++++ .../sdk/v3/payments/VasServiceProvider.java | 21 + .../clover/sdk/v3/payments/VasSettings.java | 37 + .../payments/api/RequestTipIntentBuilder.java | 15 + .../ReversePaymentRequestIntentBuilder.java | 200 ++- .../clover/sdk/v3/shipping/ShipToAddress.java | 347 ++++ .../sdk/v3/shipping/ShipmentSummary.java | 251 +++ .../sdk/v3/shipping/ShippingContext.java | 283 ++++ .../sdk/v3/shipping/ShippingCustomer.java | 283 ++++ .../clover/sdk/v3/shipping/ShippingLabel.java | 411 +++++ .../sdk/v3/shipping/ShippingOrderDetails.java | 424 +++++ .../com/clover/sdk/v3/tables2/TableOrder.java | 35 + .../sdk/v3/vas/connector/IVasReaderClient.kt | 59 + .../sdk/v3/vas/connector/VasReaderClient.kt | 17 + .../v3/vas/connector/VasReaderClientImpl.kt | 118 ++ .../v3/vas/connector/VasReaderException.kt | 3 + .../v3/vas/connector/VasServiceConnector.kt | 157 ++ .../vas/listener/IVasReaderClientListener.kt | 17 + .../src/main/res/values-de-rDE/strings.xml | 25 +- .../src/main/res/values-en-rCA/strings.xml | 12 + .../src/main/res/values-en-rGB/strings.xml | 12 + .../src/main/res/values-en-rIE/strings.xml | 13 + .../src/main/res/values-fr-rCA/strings.xml | 41 +- .../src/main/res/values-ja-rJP/strings.xml | 14 + .../src/main/res/values-nl-rNL/strings.xml | 26 +- .../src/main/res/values-port/strings.xml | 5 +- .../src/main/res/values-pt-rBR/strings.xml | 27 +- .../src/main/res/values/strings.xml | 21 +- .../common2/payments/PayIntentTest.java | 54 + .../printer/job/StaticOrderPrintJobTest.java | 51 + .../clover/sdk/v3/JsonParcelHelperTest.java | 281 ++++ .../v3/device/DeviceAttestationClientTest.kt | 156 ++ .../sdk/v3/device/internal/CompactJwsTest.kt | 100 ++ gradle/wrapper/gradle-wrapper.properties | 2 +- versions.gradle | 2 + 232 files changed, 32857 insertions(+), 521 deletions(-) create mode 100644 README_device_attestation.md create mode 100644 certs/device_root_dev.pem create mode 100644 certs/device_root_dev_legacy.pem create mode 100644 certs/device_root_prod.pem create mode 100644 clover-android-connector-sdk/src/main/res/values-en-rCA/strings.xml create mode 100644 clover-android-connector-sdk/src/main/res/values-en-rGB/strings.xml create mode 100644 clover-android-connector-sdk/src/main/res/values-en-rIE/strings.xml create mode 100644 clover-android-connector-sdk/src/main/res/values-ja-rJP/strings.xml create mode 100644 clover-android-connector-sdk/src/main/res/values-nl-rNL/strings.xml create mode 100755 clover-android-sdk-examples/scripts/jwt_verify_device_attestation.py create mode 100755 clover-android-sdk-examples/scripts/verify_device_attestation.py create mode 120000 clover-android-sdk-examples/src/main/assets/certs create mode 100644 clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/DeviceAttestationTestActivity.kt create mode 100644 clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/DeviceNotificationTestActivity.kt create mode 100644 clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/DeviceNotificationViewModel.kt create mode 100644 clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/IntegratorLogoSync.kt create mode 100644 clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/SampleReceiptGenerator.kt create mode 100644 clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/vas/VasReaderStatusActivity.kt create mode 100644 clover-android-sdk-examples/src/main/res/layout/activity_device_attestation_test.xml create mode 100644 clover-android-sdk-examples/src/main/res/layout/activity_device_notifications_test.xml create mode 100644 clover-android-sdk-examples/src/main/res/layout/activity_vas_reader_status.xml create mode 100644 clover-android-sdk-examples/src/main/res/values-de-rDE/strings.xml create mode 100644 clover-android-sdk-examples/src/main/res/values-en-rCA/strings.xml create mode 100644 clover-android-sdk-examples/src/main/res/values-en-rGB/strings.xml create mode 100644 clover-android-sdk-examples/src/main/res/values-en-rIE/strings.xml create mode 100644 clover-android-sdk-examples/src/main/res/values-fr-rCA/strings.xml create mode 100644 clover-android-sdk-examples/src/main/res/values-ja-rJP/strings.xml create mode 100644 clover-android-sdk-examples/src/main/res/values-nl-rNL/strings.xml create mode 100644 clover-android-sdk-examples/src/main/res/values-pt-rBR/strings.xml create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/base/TenderProperties.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/inventory/BundleDefinition.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/inventory/BundleItem.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/inventory/BundleItemGroup.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/inventory/Marker.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/order/AddLineItemOperation.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/order/AddModificationsOperation.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/order/DeleteModificationsOperation.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/order/UpdateBundleComponent.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/order/UpdateBundleComponentFdParcelable.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/order/UpdateBundleGroup.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/payment/raw/model/GetManualCardRequest.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/shipping/ShippingOrderDetails.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/vas/IVasReaderService.aidl create mode 100644 clover-android-sdk/src/main/aidl/com/clover/sdk/v3/vas/IVasReaderSessionListener.aidl create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/util/TaxRateLabels.kt create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v1/printer/job/CashEventPrintJob.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/base/BusinessLine.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/base/CutoffProportion.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/base/TenderCategory.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/base/TenderPbbConfig.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/base/TenderProperties.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/base/TenderRefundConfig.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/base/TenderTransactionLimit.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/base/TenderTransactionSource.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/base/TenderVoidConfig.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/device/DeviceAttestationClient.kt create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/device/internal/AttestationPayload.kt create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/device/internal/CompactJws.kt create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/device/internal/InternalCloverApi.kt create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/BundleDefinition.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/BundleItem.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/BundleItemGroup.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/BundlePricingMode.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/BundleType.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/ItemEntitlementPlan.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/ItemEntitlementPlanAvailabilityMenu.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/ItemEntitlementPlanOption.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/ItemEntitlementPlanOptionType.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/ItemEntitlementPlanPaidExtension.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/ItemEntitlementPlanThrottle.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/ItemMarkerAssociation.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/ItemTaxRatePrice.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/Marker.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/MobileOrderingCutoff.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/ModifierGroupType.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/OrderFeeTaxRate.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/inventory/SystemTaxRate.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/onlineorder/OnlineSupportInfo.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/AddLineItemOperation.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/AddModificationsOperation.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/BundleComponent.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/BundleGroup.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/BundleGroupLineItem.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/BundleType.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/DeleteModificationsOperation.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/ItemEntitlementSession.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/ItemEntitlementSessionLineItem.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/ItemEntitlementSessionLineItemRole.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/ItemEntitlementSessionState.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/ItemOrderingThrottle.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/LineItemType.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/OrderTypeTaxRate.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/TimedSession.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/TimedSessionDisposition.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/UpdateBundleComponent.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/UpdateBundleComponentFdParcelable.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/order/UpdateBundleGroup.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/pay/TerminalGroupData.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payment/raw/model/GetManualCardRequest.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payment/raw/model/OperationalReportHistoryList.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/AdditionalChargeExtra.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/AnomalyReasonType.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/CashDetails.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/CashDrawerConfig.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/CashDrawerSpec.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/CountryDenomination.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/DebitRefund.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/Denomination.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/DenominationConfig.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/FiscalizationSignature.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/FiscalizationTag.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/NextShiftOpeningDetails.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/NonCashDetailItem.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/NonCashDetails.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/Reconciliation.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/RoundingDetails.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/RoundingMode.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/RoundingPosition.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/RoundingStep.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/TenderDetails.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/payments/VasPassInfo.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/shipping/ShipToAddress.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/shipping/ShipmentSummary.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/shipping/ShippingContext.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/shipping/ShippingCustomer.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/shipping/ShippingLabel.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/shipping/ShippingOrderDetails.java create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/vas/connector/IVasReaderClient.kt create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/vas/connector/VasReaderClient.kt create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/vas/connector/VasReaderClientImpl.kt create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/vas/connector/VasReaderException.kt create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/vas/connector/VasServiceConnector.kt create mode 100644 clover-android-sdk/src/main/java/com/clover/sdk/v3/vas/listener/IVasReaderClientListener.kt create mode 100644 clover-android-sdk/src/main/res/values-en-rCA/strings.xml create mode 100644 clover-android-sdk/src/main/res/values-en-rGB/strings.xml create mode 100644 clover-android-sdk/src/main/res/values-en-rIE/strings.xml create mode 100644 clover-android-sdk/src/main/res/values-ja-rJP/strings.xml create mode 100644 clover-android-sdk/src/test/java/com/clover/sdk/v3/JsonParcelHelperTest.java create mode 100644 clover-android-sdk/src/test/java/com/clover/sdk/v3/device/DeviceAttestationClientTest.kt create mode 100644 clover-android-sdk/src/test/java/com/clover/sdk/v3/device/internal/CompactJwsTest.kt diff --git a/README_device_attestation.md b/README_device_attestation.md new file mode 100644 index 0000000000..ff2e6b4d96 --- /dev/null +++ b/README_device_attestation.md @@ -0,0 +1,132 @@ +Device attestation +=== +The Device Attestation SDK creates an _attestation_—a message signed with the Clover device private key. This is used to assert a set of truths about the device or the system state, such that the integrity and authenticity of the message are cryptographically guaranteed by the Clover hardware. + +# Create a signed message +``` +val userTruths = mapOf( + "nonce" to theNonce, + "accountId" to theAccountId, + ..., +) + +// Generate with certificate chain in JWS header +val response = DeviceAttestationClient(context).sign(userTruths, CertificateReference.CERTIFICATE) +// OR +// Generate with SHA-256 certificate thumbprint reference +val response = DeviceAttestationClient(context).sign(userTruths, CertificateReference.THUMBPRINT) +``` +`message` is a [Java Web Signature](https://datatracker.ietf.org/doc/html/rfc7515) (JWS) [compact serialization](https://datatracker.ietf.org/doc/html/rfc7515#page-7). JWS is fairly simple, but if you are not comfortable with the specification, there are multiple robust client libraries: +- **Node.js / TypeScript**: jose (https://www.npmjs.com/package/jose) or jsonwebtoken (https://www.npmjs.com/package/jsonwebtoken) +- **Python**: PyJWT (https://pyjwt.readthedocs.io/) or authlib (https://authlib.org/) +- **Java / Kotlin**: nimbus-jose-jwt (https://connect2id.com/products/nimbus-jose-jwt) or okta-jwt-verifier (https://github.com/okta/okta-jwt-verifier-java) +- **Go**: go-jose (https://github.com/go-jose/go-jose) +- **C# / .NET**: System.IdentityModel.Tokens.Jwt (https://www.nuget.org/packages/System.IdentityModel.Tokens.Jwt/) + +The following sections describe what to expect in a Clover device attestation JWS compact serialization. Refer to the JWS specification for details. + +## Header +The JWS header contains _either_: +- `x5c`: The Clover device intermediate certificate chain (default, or when `CertificateReference.CERTIFICATE` is used in signing). +- `x5t#S256`: The base64, url-encoded SHA-256 certificate thumbprint reference (when `CertificateReference.THUMBPRINT` is used in signing). + +When invoking `DeviceAttestationClient.sign`, an optional `CertificateReference` can be supplied. This is either: +- `CertificateReference.CERTIFICATE`: The entire intermediate certificate chain is encoded in the JWS header, in the `x5c` field. +- `CertificateReference.THUMBPRINT`: A SHA-256 hash of the leaf certificate is encoded in the JWS header, in the `x5t#256` field. + +`CERTIFICATE` generates a larger message (roughly ~5k bytes, plus encoded payload length), but it is completely self-contained, and can be verified completely offline. + +`THUMBPRINT` generates a smaller message (roughly 800 bytes, plus encoded payload length). However, the verifier must obtain, or otherwise have access to, the intermediate certificate chain. The verifier must verify that the thumbprint (hash) in the JWS header matches the hash of the actual SHA-256 leaf certificate (in addition to verifying the signature, and validating the certificate chain). + +## Payload +JWS does not define a payload format. Clover uses a well-defined JSON object that defines *device* and *user truths*. +``` +{ + "deviceTruths": { + "timestamp": "2026-06-16T21:12:36Z", + "clover_id": "GARENZRPEFZ6E", + "serial": "C051UQ03660028", + "mid": "12345678901", + "is_prod": "false" + }, + "userTruths": { + "nonce": "c2519b9f-a8bd-459d-bcda-4fba2a014d65", + "accountId": "16433789684660082549" + } +} +``` + +### Device truths +Device truths are attestations made about the signing device, by the signing device. + +- `timestamp`: The time the attestation was signed. +- `clover_id`: The Clover merchant UUID. +- `serial`: The Clover device serial number. +- `mid`: The merchant identifier (must not be confused with the merchant UUID). +- `is_prod`: A flag indicating if the message was signed on a production, or otherwise development, Clover device. This must be used to select the correct device root, to complete message verification. + +The set of device truths is fixed; these same device truth keys exist in all signed messages. + +### User truths +User truths are attestations made by the caller about the device's relationship with their software. While not required, it is recommended that this at least contain a server-generated [cryptographic nonce](https://en.wikipedia.org/wiki/Cryptographic_nonce) that can be authenticated at verification time. It might also contain things like user or account IDs. + +## Signature +Per the JWS specification, the signature signs the header and payload. + +# Verify a signed message +To verify a message: +1. Decode the JWS compact serialization. +2. Verify the cryptographic signature. +3. Verify that the certificate chain chains to either the development or production device Clover root certificate. + +If `THUMBPRINT` mode is used, the verifier must first retrieve and match the intermediate certificate chain before performing signature and path validation. + +A Kotlin and Python samples demonstrating verification is provided. See "Samples" below. + +The device production and development root certificate, in PEM format, can be found at: +- `certs/device_root_prod.pem` (use with production Clover devices) +- `certs/device_root_dev.pem` (use with development Clover devices) +- `certs/device_root_dev_legacy.pem` (use with older development Clover devices) + +Because there are two development root certificates, you may have to write your code to attempt verification with each. + +respectively. + +> [!IMPORTANT] +> Some older, primarily development devices, may have expired intermediate certificates. This does not affect the operation of the device, or the authenticity of the signature. + +# Rate limiting +Callers are limited to 24 signing requests per day (24 invocations of `DeviceAttestationClient.sign`). + +# Samples +To demonstrate message signing and verification, run the Clover Android SDK Examples application, and select "Device attestation test". Find the functions `DeviceAttestationViewModel::sign` and `::verify`. Of course the signer and verifier will never be the same entity, outside sample code. + +Two python samples can be found in `clover-android-sdk-examples/scripts/`: +- `verify_device_attestation.py`: Manual parse and decode the JWS compact serialization, and manual cryptographic verification. +- `jwt_verify_device_attestation.py`: Uses the [PyJWT](https://pyjwt.readthedocs.io/en/stable/) Python module to do the same. + +To use them, first generate a signed message using the "Device attestation test" screen in the Clover Android SDK Examples app on the device. In the device's log, look for messages like: +``` +06-16 15:41:14.030 9492 9492 I device_attestation: Wrote attestation: /storage/emulated/0/Android/data/com.clover.android.sdk.examples/cache/attestation.jws +06-16 15:41:14.035 9492 9492 I device_attestation: Wrote device certificates: /storage/emulated/0/Android/data/com.clover.android.sdk.examples/cache/device_certs.pem +``` +Pull these files from the device to your host PC: +``` +adb pull /storage/emulated/0/Android/data/com.clover.android.sdk.examples/cache/attestation.jws +adb pull /storage/emulated/0/Android/data/com.clover.android.sdk.examples/cache/device_certs.pem +``` +and run either Python script to verify it. + +For messages generated with a `CERTIFICATE` certificate reference: +``` +clover-android-sdk-examples/scripts/verify_device_attestation.py --jws attestation.jws --root device_root.pem +# OR +clover-android-sdk-examples/scripts/jwt_verify_device_attestation.py --jws attestation.jws --root device_root.pem +``` + +For messages generated with a `THUMBPRINT` certificate reference: +``` +clover-android-sdk-examples/scripts/verify_device_attestation.py --jws attestation.jws --root device_root.pem --certs device_certs.pem +# OR +clover-android-sdk-examples/scripts/jwt_verify_device_attestation.py --jws attestation.jws --root device_root.pem --certs device_certs.pem +``` \ No newline at end of file diff --git a/build.gradle b/build.gradle index 122ac03eef..609d1f40f2 100644 --- a/build.gradle +++ b/build.gradle @@ -82,15 +82,15 @@ subprojects { remoteLineSuffix.set("#L") } externalDocumentationLink { - url.set(new URL("https://square.github.io/retrofit/2.x/retrofit/")) + url.set(new URL("https://javadoc.io/doc/com.squareup.retrofit2/retrofit/2.12.0/")) } externalDocumentationLink { - url.set(new URL("https://square.github.io/okhttp/3.x/okhttp/")) + url.set(new URL("https://javadoc.io/doc/com.squareup.okhttp3/okhttp/3.12.13/")) } if(project.name == "clover-android-sdk") includes.from("overview.md") perPackageOption { - matchingRegex.set("com.clover.sdk.internal.*") + matchingRegex.set('com\\.clover\\.sdk(\\..*)?\\.internal(\\..*)?') suppress.set(true) } } diff --git a/certs/device_root_dev.pem b/certs/device_root_dev.pem new file mode 100644 index 0000000000..e84892e39f --- /dev/null +++ b/certs/device_root_dev.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDgzCCAmugAwIBAgIQFG4Y+mStDpqR+MKFT1A89zANBgkqhkiG9w0BAQsFADBa +MQswCQYDVQQGEwJVUzERMA8GA1UECBMITmVicmFza2ExEzARBgNVBAoTCkZpcnN0 +IERhdGExIzAhBgNVBAMTGlRFU1QgQ2xvdmVyIERldmljZSBSb290IENBMB4XDTI2 +MDEyNzAzNDYzOFoXDTM2MDEyNTAzNDYzOFowWjELMAkGA1UEBhMCVVMxETAPBgNV +BAgTCE5lYnJhc2thMRMwEQYDVQQKEwpGaXJzdCBEYXRhMSMwIQYDVQQDExpURVNU +IENsb3ZlciBEZXZpY2UgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAONeQHDe10665RPPHCw2r6QOZ0a3/hXShL4ghORIc5KvDyZRNHQHw2Mn +sPFa1QDUgrwkKqY9+bC/Nnv5Or5XSIfg9oTv3r7xteG16zB53DCtVd6ZDoMq3SIZ +Hk+ExebJpcMtujPjKDZU1917AIlsXXqdJ4ggYDRsMC8tKPVDSFQ62BnVbIr+NSdL +j0dsLbvOsqKPNbrw1BGTF3ySUZjwBSEKwV2TdRfcMSEHJCvHDRhTVHqs941DDfSz +/kNT3fEhK22oBabZglEfsA5bY3GZT0kZKXtX8SQd1TcWYm+Npauqk9p1BGaLN58Y +JRNXfy28DO/ddtWGuo9qbUPvEUyy+MkCAwEAAaNFMEMwDgYDVR0PAQH/BAQDAgEG +MBIGA1UdEwEB/wQIMAYBAf8CAQIwHQYDVR0OBBYEFOC+VmSVJQmJu54y0kMmzEhH +CA5KMA0GCSqGSIb3DQEBCwUAA4IBAQCJvYhisklkVFM3Kpa49h6ZfwypbO+UIYjS +JFlre8reH1fklsVEdTa4eXh86lYUpDEABwThH/WcPtr6ExZfJr0V19IKkXkW2VhV +aazviQIjrIa3T8KD7pDx4voJGOeb2MwxtM6vHm4WEsFWaWa+2EMMYhfCNITBi89Z +6qlqTLFrtvjEWgNlJ1H/X/3tFvdIsXREKaCQ44YGCZbsMT1QoBWARMrYbPj26cTc +jYk5mQ/zVckgtJWZVASQGZN3U2J7wWXupn3yfhJk3CzQg7WEr+rCXzIRDWBvfdv4 +IEs40frtmvZevceJFBOIrvlkEelLjf9BRtCznascQC30epcoRLsY +-----END CERTIFICATE----- diff --git a/certs/device_root_dev_legacy.pem b/certs/device_root_dev_legacy.pem new file mode 100644 index 0000000000..88e7e43001 --- /dev/null +++ b/certs/device_root_dev_legacy.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDSDCCAjCgAwIBAgIRAJqJRWb9mB9jhGX62xXHf3wwDQYJKoZIhvcNAQELBQAw +VTEeMBwGA1UEAxMVQ2xvdmVyIERldmljZSBSb290IENBMQswCQYDVQQGEwJVUzER +MA8GA1UECBMITmVicmFza2ExEzARBgNVBAoTCkZpcnN0IERhdGEwHhcNMTQwOTIz +MjI1NTA3WhcNMjQwOTIwMjI1NTA3WjBVMR4wHAYDVQQDExVDbG92ZXIgRGV2aWNl +IFJvb3QgQ0ExCzAJBgNVBAYTAlVTMREwDwYDVQQIEwhOZWJyYXNrYTETMBEGA1UE +ChMKRmlyc3QgRGF0YTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJJQ +I+gaIp7ibneZOhKYlPkHWl+4KkK1NL0TULP11I/Irj2vy+UBS6gSKWN3joRc8WNf ++TvE6Atje/NcW3vzLhBbpdWK1hE/cxgckYThiK4ezepwo7p3LF4SK4nsBPb0Rw3I +vb6CbTGvPH7VdHfK1rsxxI3teX1m/F6NFYJkZv5+l8Tk5vYV0Vlq5rinOiZD/FvP +be1Z+VCd+6IKa0yrTfZmCo7Rz4kmtf9nJc5k+9njfNw9A0qCdBskD6HwivHGARCW +0zSBOTzRAkbMoony/FEMawdFJGkH5fKMP83Ugg4UD6+ue2H/06G+fTW4EPM0reuo +y/Ixhe0smtxbY91mjs0CAwEAAaMTMBEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG +9w0BAQsFAAOCAQEAAW7asNL8+j1MbDJc5YhZK5znkrXE25/UxyYSO+xJEoaBbLba +9nz4VwsA1zQkSHKJF96MFssf7UajgWz2Mo/JbPFl6gCGgoW2fydOxEyffYKi38Fr +quCp7jqbUoREssZUp07uALKswbhhCtAwm7MfQ/Y/Se6wSYpwtRvun5y2kBruoeku +lWflekfsUHtmzLufWwlysCxJ7hsxaTLDmnpfn7h9PYYWT2kHxmnFl+Rwi4GQocVN +aPWmHIrV8Xh/OrF43EZ6Vm9uyF0nDM8eQSTYINqyrTqnJ2nWfjcCe0Bclgn8+0Db +vyWOxMJ0Zqv7S6Zd5oxfDMykzrStPYKsU2v/bA== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/certs/device_root_prod.pem b/certs/device_root_prod.pem new file mode 100644 index 0000000000..8150a5721f --- /dev/null +++ b/certs/device_root_prod.pem @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEATCCAumgAwIBAgIHTgP8AAAAaTANBgkqhkiG9w0BAQsFADCBtzELMAkGA1UE +BhMCVVMxETAPBgNVBAgMCE5lYnJhc2thMQ4wDAYDVQQHDAVPbWFoYTEfMB0GA1UE +CgwWRmlyc3QgRGF0YSBDb3Jwb3JhdGlvbjEdMBsGA1UECwwUSW5mb3JtYXRpb24g +U2VjdXJpdHkxGjAYBgNVBAMMEUZEIERldmljZSBSb290IENBMSkwJwYJKoZIhvcN +AQkBFhpwa2ktc2VydmljZXNAZmlyc3RkYXRhLmNvbTAeFw0xNDEwMTQwMDAwMDBa +Fw0zNDEwMTQwMDAwMDBaMIG3MQswCQYDVQQGEwJVUzERMA8GA1UECAwITmVicmFz +a2ExDjAMBgNVBAcMBU9tYWhhMR8wHQYDVQQKDBZGaXJzdCBEYXRhIENvcnBvcmF0 +aW9uMR0wGwYDVQQLDBRJbmZvcm1hdGlvbiBTZWN1cml0eTEaMBgGA1UEAwwRRkQg +RGV2aWNlIFJvb3QgQ0ExKTAnBgkqhkiG9w0BCQEWGnBraS1zZXJ2aWNlc0BmaXJz +dGRhdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtmp7eyQ1 +Buvu01Q9Rw7IoAQBymerTV5S9Il+6xU5S0CEZrZhX9RqWMBOj9RaCketH2OjXxfW +w+cyjwDynY04aUSaIAgJARsiQH4Jdg6ctzi/kVXiJzD1MojWQs0qKlgyFL9vfWdQ +os8B4h7UCstF2bh46N76zQY1NCkAs+2Sz6ds87LvqUSXo8EdBAoErPJmEOFoKaqc +IK3v1bOsimoZ/hFW7Z2oDXRkq7Gii3qRIenMMhk1LFPhTQd56kFeQhJIg8Vc7fLU +isARd0jiOSdHZJGX8pLRhyveluQAzNIJWyIRZYWRa51A45l0OYm93h6ybmR7rxQ2 +nEIqEJQgBz/b8wIDAQABoxAwDjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA +A4IBAQCk3w/JnubLwHxhIqJuhhefzkGXciI4w33Xl/ZFyGCmhDjIik5FJJ+mQO6E +7Cjuatq7928MeaZv+v6IhPQbzkf9tfQyIBgUSVqsPx2Vz1M6HmbHLAGn2XV9rOU3 +5aEM0Hliep1XpQpo6v+0sB6a4Nu8dSZL/gGFnpwOp6qasT5G584UsWaFuCWxVQ1P +GG8SzLHFpPjhpIOyZAQaKQpFFMW+WXBV6xUz/s1t5kpiPkVeuBS3iVBaeWimW4ej +wj2w40RNHJXk4BYQZ6ZKiMGrrc/xUbgmYMTlmhQu/w5CzceqRzQWQAFcM0zyB26H +hloCVJz1SdPsAssBTlKnp0iStBww +-----END CERTIFICATE----- diff --git a/clover-android-connector-sdk/build.gradle b/clover-android-connector-sdk/build.gradle index 1a5b558490..886fe4954d 100644 --- a/clover-android-connector-sdk/build.gradle +++ b/clover-android-connector-sdk/build.gradle @@ -16,7 +16,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile * limitations under the License. */ group = 'com.clover.sdk' -version = '334' +version = '340' apply from: file("${project.rootDir}/lib.gradle") diff --git a/clover-android-connector-sdk/src/main/res/values-de-rDE/strings.xml b/clover-android-connector-sdk/src/main/res/values-de-rDE/strings.xml index 0b7ba1457a..eb5e4a010c 100755 --- a/clover-android-connector-sdk/src/main/res/values-de-rDE/strings.xml +++ b/clover-android-connector-sdk/src/main/res/values-de-rDE/strings.xml @@ -1,4 +1,7 @@ + + com.clover.connector.sdk.v3.PaymentConnector + 3.0.0 Manuelle Transaktion Autorisierung - + \ No newline at end of file diff --git a/clover-android-connector-sdk/src/main/res/values-en-rCA/strings.xml b/clover-android-connector-sdk/src/main/res/values-en-rCA/strings.xml new file mode 100644 index 0000000000..52088f6837 --- /dev/null +++ b/clover-android-connector-sdk/src/main/res/values-en-rCA/strings.xml @@ -0,0 +1,7 @@ + + + com.clover.connector.sdk.v3.PaymentConnector + 3.0.0 + Manual Transaction + Authorization + \ No newline at end of file diff --git a/clover-android-connector-sdk/src/main/res/values-en-rGB/strings.xml b/clover-android-connector-sdk/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000000..06155f9fbf --- /dev/null +++ b/clover-android-connector-sdk/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,7 @@ + + + com.clover.connector.sdk.v3.PaymentConnector + 3.0.0 + Manual Transaction + Authorisation + \ No newline at end of file diff --git a/clover-android-connector-sdk/src/main/res/values-en-rIE/strings.xml b/clover-android-connector-sdk/src/main/res/values-en-rIE/strings.xml new file mode 100644 index 0000000000..06155f9fbf --- /dev/null +++ b/clover-android-connector-sdk/src/main/res/values-en-rIE/strings.xml @@ -0,0 +1,7 @@ + + + com.clover.connector.sdk.v3.PaymentConnector + 3.0.0 + Manual Transaction + Authorisation + \ No newline at end of file diff --git a/clover-android-connector-sdk/src/main/res/values-fr-rCA/strings.xml b/clover-android-connector-sdk/src/main/res/values-fr-rCA/strings.xml index c75e44dfac..81ef44205f 100644 --- a/clover-android-connector-sdk/src/main/res/values-fr-rCA/strings.xml +++ b/clover-android-connector-sdk/src/main/res/values-fr-rCA/strings.xml @@ -1,5 +1,7 @@ - Transaction manuelle - Autorisation - + com.clover.connector.sdk.v3.PaymentConnector + 3.0.0 + Transaction manuelle + Autorisation + \ No newline at end of file diff --git a/clover-android-connector-sdk/src/main/res/values-ja-rJP/strings.xml b/clover-android-connector-sdk/src/main/res/values-ja-rJP/strings.xml new file mode 100644 index 0000000000..8578910343 --- /dev/null +++ b/clover-android-connector-sdk/src/main/res/values-ja-rJP/strings.xml @@ -0,0 +1,7 @@ + + + com.clover.connector.sdk.v3.PaymentConnector + 3.0.0 + 直接入力決済 + オーソリ + \ No newline at end of file diff --git a/clover-android-connector-sdk/src/main/res/values-nl-rNL/strings.xml b/clover-android-connector-sdk/src/main/res/values-nl-rNL/strings.xml new file mode 100644 index 0000000000..be84eb7c07 --- /dev/null +++ b/clover-android-connector-sdk/src/main/res/values-nl-rNL/strings.xml @@ -0,0 +1,7 @@ + + + com.clover.connector.sdk.v3.PaymentConnector + 3.0.0 + Handmatige transactie + Autorisatie + \ No newline at end of file diff --git a/clover-android-connector-sdk/src/main/res/values-pt-rBR/strings.xml b/clover-android-connector-sdk/src/main/res/values-pt-rBR/strings.xml index c0dabfd318..9c03b29851 100644 --- a/clover-android-connector-sdk/src/main/res/values-pt-rBR/strings.xml +++ b/clover-android-connector-sdk/src/main/res/values-pt-rBR/strings.xml @@ -1,5 +1,7 @@ - + + com.clover.connector.sdk.v3.PaymentConnector + 3.0.0 Transação manual Autorização - + \ No newline at end of file diff --git a/clover-android-connector-sdk/src/main/res/values/strings.xml b/clover-android-connector-sdk/src/main/res/values/strings.xml index cad8f96819..3c3051d881 100644 --- a/clover-android-connector-sdk/src/main/res/values/strings.xml +++ b/clover-android-connector-sdk/src/main/res/values/strings.xml @@ -15,8 +15,9 @@ ~ limitations under the License. --> + com.clover.connector.sdk.v3.PaymentConnector 3.0.0 - Manual Transaction - Authorization + Manual Transaction + Authorization diff --git a/clover-android-loyalty-kit/build.gradle b/clover-android-loyalty-kit/build.gradle index ec51618ba1..993651ec0b 100644 --- a/clover-android-loyalty-kit/build.gradle +++ b/clover-android-loyalty-kit/build.gradle @@ -16,7 +16,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile * limitations under the License. */ group = 'com.clover.sdk' -version = '334' +version = '340' apply from: file("${project.rootDir}/lib.gradle") diff --git a/clover-android-sdk-examples/build.gradle b/clover-android-sdk-examples/build.gradle index 8067ebe55e..9964a35434 100644 --- a/clover-android-sdk-examples/build.gradle +++ b/clover-android-sdk-examples/build.gradle @@ -16,7 +16,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile * limitations under the License. */ group = 'com.clover.sdk' -version = '334' +version = '340' apply from: file("${project.rootDir}/app.gradle") apply plugin: 'kotlin-android' @@ -92,6 +92,7 @@ dependencies { implementation "androidx.activity:activity-ktx:$ANDROIDX_ACTIVITY_VERSION" implementation "com.google.code.gson:gson:$GSON_VERSION" implementation "androidx.constraintlayout:constraintlayout:$ANDROIDX_CONSTRAINTLAYOUT_VERSION" + implementation "com.squareup.okhttp3:okhttp:$OKHTTP_VERSION" def room_version = "2.6.1" implementation("androidx.room:room-common:$room_version") diff --git a/clover-android-sdk-examples/scripts/jwt_verify_device_attestation.py b/clover-android-sdk-examples/scripts/jwt_verify_device_attestation.py new file mode 100755 index 0000000000..fb747757b3 --- /dev/null +++ b/clover-android-sdk-examples/scripts/jwt_verify_device_attestation.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +Clover Device Attestation Verification Script (using PyJWT) + +Decodes a JWS compact serialization, parses the certificate chain, +verifies the cryptographic signature using PyJWT, and validates the trust path +up to a specified Root CA. + +Requirements: + pip install PyJWT cryptography + +Usage: + python3 jwt_verify_device_attestation.py --jws attestation.jws --root device_root.pem +""" + +import argparse +import base64 +import json +import sys +from datetime import datetime, timezone +import jwt +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding + + +def format_dn(name): + """Formats an X.509 Distinguished Name (DN) into a readable string.""" + return ", ".join(f"{attr.rfc4514_string()}" for attr in name) + + +def check_validity(cert): + """Checks if a certificate is currently active and not expired.""" + now = datetime.now(timezone.utc) + + # Handle older cryptography library versions that do not have _utc properties + try: + not_before = cert.not_valid_before_utc + not_after = cert.not_valid_after_utc + except AttributeError: + not_before = cert.not_valid_before.replace(tzinfo=timezone.utc) + not_after = cert.not_valid_after.replace(tzinfo=timezone.utc) + + if now < not_before: + raise ValueError(f"Certificate is not active yet (starts: {not_before})") + if now > not_after: + raise ValueError(f"Certificate has expired (expired: {not_after})") + + +def verify_certificate_signature(cert, issuer_public_key): + """Verifies that a certificate was signed by the owner of the issuer public key.""" + try: + issuer_public_key.verify( + cert.signature, + cert.tbs_certificate_bytes, + padding.PKCS1v15(), + cert.signature_hash_algorithm, + ) + except Exception as e: + raise ValueError(f"Certificate signature verification failed: {e}") + + +def main(): + parser = argparse.ArgumentParser(description="Decode and verify a Clover JWS attestation payload using PyJWT.") + parser.add_argument("--jws", required=True, help="Path to JWS compact file") + parser.add_argument("--root", required=True, help="Path to Root CA PEM file") + parser.add_argument("--certs", required=False, help="Path to PEM file containing the device certificate chain (required for 'x5t#S256' thumbprint mode)") + args = parser.parse_args() + + print("=== Clover Device Attestation Verifier (PyJWT) ===") + print(f"[*] JWS Input File: {args.jws}") + print(f"[*] Root CA PEM File: {args.root}\n") + + try: + # 1. Load JWS Compact string + with open(args.jws, "r", encoding="utf-8") as f: + jws_content = f.read().strip() + + # 2. Use PyJWT to extract the unverified header and the x5c certificates + header = jwt.get_unverified_header(jws_content) + + # Output the raw JOSE header JSON + print("--- JOSE Header ---") + print(json.dumps(header, indent=2)) + print() + + # 3. Parse and display certificate chain from x5c or --certs + x5t_s256 = header.get("x5t#S256") + certs = [] + + if "x5c" in header and header["x5c"]: + print("--- Certificate Chain Details (from JWS Header x5c) ---") + for idx, cert_b64 in enumerate(header["x5c"]): + der_bytes = base64.b64decode(cert_b64) + cert = x509.load_der_x509_certificate(der_bytes) + certs.append(cert) + + print(f"[{idx}] Subject: {format_dn(cert.subject)}") + print(f" Issuer : {format_dn(cert.issuer)}") + print(f" Serial : {cert.serial_number}") + try: + not_before = cert.not_valid_before_utc + not_after = cert.not_valid_after_utc + except AttributeError: + not_before = cert.not_valid_before + not_after = cert.not_valid_after + print(f" Validity: {not_before} to {not_after}\n") + elif x5t_s256: + if not args.certs: + raise ValueError("JWS uses 'x5t#S256' thumbprint mode, but no external certificate chain was supplied via --certs.") + + with open(args.certs, "rb") as f: + pem_data = f.read() + + # Fallback manual PEM parser for older cryptography library versions + pem_certs_bytes = pem_data.split(b"-----BEGIN CERTIFICATE-----") + for part in pem_certs_bytes: + if not part.strip(): + continue + full_pem = b"-----BEGIN CERTIFICATE-----" + part + try: + certs.append(x509.load_pem_x509_certificate(full_pem)) + except Exception: + pass + + if not certs: + raise ValueError(f"Failed to load any valid PEM certificates from {args.certs}") + + print(f"--- Certificate Chain Details (from {args.certs}) ---") + for idx, cert in enumerate(certs): + print(f"[{idx}] Subject: {format_dn(cert.subject)}") + print(f" Issuer : {format_dn(cert.issuer)}") + print(f" Serial : {cert.serial_number}") + try: + not_before = cert.not_valid_before_utc + not_after = cert.not_valid_after_utc + except AttributeError: + not_before = cert.not_valid_before + not_after = cert.not_valid_after + print(f" Validity: {not_before} to {not_after}\n") + + # Verify that the leaf certificate's SHA-256 matches x5t#S256 + leaf_cert = certs[0] + digest = hashes.Hash(hashes.SHA256()) + digest.update(leaf_cert.public_bytes(encoding=serialization.Encoding.DER)) + thumbprint_bytes = digest.finalize() + thumbprint = base64.urlsafe_b64encode(thumbprint_bytes).decode("utf-8").rstrip("=") + + if thumbprint != x5t_s256: + raise ValueError(f"Leaf certificate thumbprint mismatch!\nExpected: {x5t_s256}\nActual: {thumbprint}") + print(f"[✓] Leaf certificate matches 'x5t#S256' thumbprint: {x5t_s256}\n") + else: + raise ValueError("JWS Header lacks both 'x5c' and 'x5t#S256' certificate references.") + + # 4. Load the Root CA Certificate + with open(args.root, "rb") as f: + root_pem = f.read() + root_cert = x509.load_pem_x509_certificate(root_pem) + print(f"[R] Root CA Trusted Anchor: {format_dn(root_cert.subject)}") + try: + root_not_before = root_cert.not_valid_before_utc + root_not_after = root_cert.not_valid_after_utc + except AttributeError: + root_not_before = root_cert.not_valid_before + root_not_after = root_cert.not_valid_after + print(f" Validity: {root_not_before} to {root_not_after}\n") + + # 5. Use PyJWT to verify the signature cryptographically using the leaf public key + print("--- Verification Progress ---") + leaf_cert = certs[0] + leaf_public_key = leaf_cert.public_key() + + try: + # PyJWT automatically handles: + # - Separating header, payload, and signature. + # - Reconstructing the signature verification input. + # - Performing RSA-SHA256 signature verification. + # Disable JWT-specific claims checks (exp, iss, aud) as we are using a standard JWS payload. + payload = jwt.decode( + jws_content, + leaf_public_key, + algorithms=["RS256"], + options={ + "verify_signature": True, + "verify_aud": False, + "verify_exp": False, + "verify_iss": False, + }, + ) + print("[✓] JWS Cryptographic Signature: VALID (Verified via PyJWT)") + except Exception as e: + print(f"[✗] JWS Cryptographic Signature: INVALID ({e})") + sys.exit(1) + + # 6. Validate the trust path up to the trusted Root CA + try: + # Check current system clock validity on all certificates + check_validity(root_cert) + for cert in certs: + check_validity(cert) + + # Verify intermediate chain signature link-by-link + for i in range(len(certs) - 1): + verify_certificate_signature(certs[i], certs[i + 1].public_key()) + + # Verify final intermediate against our Root CA + verify_certificate_signature(certs[-1], root_cert.public_key()) + + print("[✓] Certificate Trust Path: VERIFIED (Successfully anchored to Root CA)") + except Exception as e: + print(f"[✗] Certificate Trust Path: INVALID ({e})") + + # 7. Print the formatted payload decoded by PyJWT + print("\n--- Parsed Payload Claims ---") + print(json.dumps(payload, indent=2)) + + except Exception as e: + print(f"\n[!] Verification halted due to error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/clover-android-sdk-examples/scripts/verify_device_attestation.py b/clover-android-sdk-examples/scripts/verify_device_attestation.py new file mode 100755 index 0000000000..481099255c --- /dev/null +++ b/clover-android-sdk-examples/scripts/verify_device_attestation.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +""" +Device Attestation verification + +Decodes a JWS compact serialization, parses and displays the certificate chain, +verifies the cryptographic RSA-SHA256 signature, and validates the trust path +up to a specified root CA. + +Requirements: + pip install cryptography + +Usage: + python3 verify_attestation.py --jws attestation.jws --root device_root.pem +""" + +import argparse +import base64 +import json +import sys +from datetime import datetime, timezone +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding + + +def base64url_decode(payload): + """Decodes base64url encoded strings, restoring padding if necessary.""" + rem = len(payload) % 4 + if rem > 0: + payload += "=" * (4 - rem) + return base64.urlsafe_b64decode(payload) + + +def format_dn(name): + """Formats an X.509 Distinguished Name (DN) into a readable string.""" + return ", ".join(f"{attr.rfc4514_string()}" for attr in name) + + +def check_validity(cert): + """Checks if a certificate is currently active and not expired.""" + now = datetime.now(timezone.utc) + + # Handle older cryptography library versions that do not have _utc properties + try: + not_before = cert.not_valid_before_utc + not_after = cert.not_valid_after_utc + except AttributeError: + not_before = cert.not_valid_before.replace(tzinfo=timezone.utc) + not_after = cert.not_valid_after.replace(tzinfo=timezone.utc) + + if now < not_before: + raise ValueError(f"Certificate is not active yet (starts: {not_before})") + if now > not_after: + raise ValueError(f"Certificate has expired (expired: {not_after})") + + +def verify_certificate_signature(cert, issuer_public_key): + """Verifies that a certificate was signed by the owner of the issuer public key.""" + try: + issuer_public_key.verify( + cert.signature, + cert.tbs_certificate_bytes, + padding.PKCS1v15(), + cert.signature_hash_algorithm, + ) + except Exception as e: + raise ValueError(f"Certificate signature verification failed: {e}") + + +def main(): + parser = argparse.ArgumentParser(description="Decode and verify a Clover JWS attestation payload.") + parser.add_argument("--jws", required=True, help="Path to JWS compact file") + parser.add_argument("--root", required=True, help="Path to Root CA PEM file") + parser.add_argument("--certs", required=False, help="Path to PEM file containing the device certificate chain (required for 'x5t#S256' thumbprint mode)") + args = parser.parse_args() + + print("=== Clover Device Attestation Verifier ===") + print(f"[*] JWS Input File: {args.jws}") + print(f"[*] Root CA PEM File: {args.root}\n") + + try: + # 1. Load and parse the JWS Compact string + with open(args.jws, "r", encoding="utf-8") as f: + jws_content = f.read().strip() + + parts = jws_content.split(".") + if len(parts) != 3: + raise ValueError("Invalid JWS structure. Expected exactly 3 dot-separated parts.") + + header_b64, payload_b64, signature_b64 = parts + + # 2. Decode JWS Headers and Payload + header_json = base64url_decode(header_b64).decode("utf-8") + payload_json = base64url_decode(payload_b64).decode("utf-8") + signature_bytes = base64url_decode(signature_b64) + + header = json.loads(header_json) + payload = json.loads(payload_json) + + # Output raw JOSE Header + print("--- JOSE Header ---") + print(json.dumps(header, indent=2)) + print() + + # 3. Parse and display certificate chain from x5c or --certs + x5t_s256 = header.get("x5t#S256") + certs = [] + + if "x5c" in header and header["x5c"]: + print("--- Certificate Chain Details (from JWS Header x5c) ---") + for idx, cert_b64 in enumerate(header["x5c"]): + der_bytes = base64.b64decode(cert_b64) + cert = x509.load_der_x509_certificate(der_bytes) + certs.append(cert) + + print(f"[{idx}] Subject: {format_dn(cert.subject)}") + print(f" Issuer : {format_dn(cert.issuer)}") + print(f" Serial : {cert.serial_number}") + try: + not_before = cert.not_valid_before_utc + not_after = cert.not_valid_after_utc + except AttributeError: + not_before = cert.not_valid_before + not_after = cert.not_valid_after + print(f" Validity: {not_before} to {not_after}\n") + elif x5t_s256: + if not args.certs: + raise ValueError("JWS uses 'x5t#S256' thumbprint mode, but no external certificate chain was supplied via --certs.") + + with open(args.certs, "rb") as f: + pem_data = f.read() + + # Fallback manual PEM parser for older cryptography library versions + pem_certs_bytes = pem_data.split(b"-----BEGIN CERTIFICATE-----") + for part in pem_certs_bytes: + if not part.strip(): + continue + full_pem = b"-----BEGIN CERTIFICATE-----" + part + try: + certs.append(x509.load_pem_x509_certificate(full_pem)) + except Exception: + pass + + if not certs: + raise ValueError(f"Failed to load any valid PEM certificates from {args.certs}") + + print(f"--- Certificate Chain Details (from {args.certs}) ---") + for idx, cert in enumerate(certs): + print(f"[{idx}] Subject: {format_dn(cert.subject)}") + print(f" Issuer : {format_dn(cert.issuer)}") + print(f" Serial : {cert.serial_number}") + try: + not_before = cert.not_valid_before_utc + not_after = cert.not_valid_after_utc + except AttributeError: + not_before = cert.not_valid_before + not_after = cert.not_valid_after + print(f" Validity: {not_before} to {not_after}\n") + + # Verify that the leaf certificate's SHA-256 matches x5t#S256 + leaf_cert = certs[0] + digest = hashes.Hash(hashes.SHA256()) + digest.update(leaf_cert.public_bytes(encoding=serialization.Encoding.DER)) + thumbprint_bytes = digest.finalize() + thumbprint = base64.urlsafe_b64encode(thumbprint_bytes).decode("utf-8").rstrip("=") + + if thumbprint != x5t_s256: + raise ValueError(f"Leaf certificate thumbprint mismatch!\nExpected: {x5t_s256}\nActual: {thumbprint}") + print(f"[✓] Leaf certificate matches 'x5t#S256' thumbprint: {x5t_s256}\n") + else: + raise ValueError("JWS Header lacks both 'x5c' and 'x5t#S256' certificate references.") + + # 4. Load the Root CA Certificate + with open(args.root, "rb") as f: + root_pem = f.read() + root_cert = x509.load_pem_x509_certificate(root_pem) + print(f"[R] Root CA Trusted Anchor: {format_dn(root_cert.subject)}") + try: + root_not_before = root_cert.not_valid_before_utc + root_not_after = root_cert.not_valid_after_utc + except AttributeError: + root_not_before = root_cert.not_valid_before + root_not_after = root_cert.not_valid_after + print(f" Validity: {root_not_before} to {root_not_after}\n") + + # 5. Cryptographic signature verification over the JWS signing input using the Leaf certificate + print("--- Verification Progress ---") + signing_input = f"{header_b64}.{payload_b64}".encode("utf-8") + leaf_cert = certs[0] + leaf_public_key = leaf_cert.public_key() + + try: + leaf_public_key.verify( + signature_bytes, + signing_input, + padding.PKCS1v15(), + hashes.SHA256(), + ) + print("[✓] JWS Cryptographic Signature: VALID") + except Exception as e: + print(f"[✗] JWS Cryptographic Signature: INVALID ({e})") + sys.exit(1) + + # 6. Validate the trust path up to the trusted Root CA + try: + # Check current system clock validity on all certificates + check_validity(root_cert) + for cert in certs: + check_validity(cert) + + # Verify intermediate chain signature link-by-link + for i in range(len(certs) - 1): + verify_certificate_signature(certs[i], certs[i + 1].public_key()) + + # Verify final intermediate against our Root CA + verify_certificate_signature(certs[-1], root_cert.public_key()) + + print("[✓] Certificate Trust Path: VERIFIED (Successfully anchored to Root CA)") + except Exception as e: + print(f"[✗] Certificate Trust Path: INVALID ({e})") + + # 7. Print the formatted payload + print("\n--- Parsed Payload Claims ---") + print(json.dumps(payload, indent=2)) + + except Exception as e: + print(f"\n[!] Verification halted due to error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/clover-android-sdk-examples/src/main/AndroidManifest.xml b/clover-android-sdk-examples/src/main/AndroidManifest.xml index 276201a811..a259e3a534 100644 --- a/clover-android-sdk-examples/src/main/AndroidManifest.xml +++ b/clover-android-sdk-examples/src/main/AndroidManifest.xml @@ -22,6 +22,7 @@ xmlns:tools="http://schemas.android.com/tools" + @@ -80,6 +81,14 @@ xmlns:tools="http://schemas.android.com/tools" android:label="@string/app_notification_test" android:taskAffinity="com.clover.sdk.examples.app" > + + + + @@ -221,7 +234,7 @@ xmlns:tools="http://schemas.android.com/tools" tools:ignore="ExportedContentProvider"> + android:value="Payment,Bill,Order,Refund,Credit"/> () { + tenderConnector.checkAndCreateTenderV2(tenderName, packageName, true, false, tenderProperties, new TenderConnector.TenderCallback() { @Override public void onServiceSuccess(Tender result, ResultStatus status) { super.onServiceSuccess(result, status); diff --git a/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/CustomReceiptProviderTest.kt b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/CustomReceiptProviderTest.kt index 8cd7f1e9dc..01bfdcca8b 100644 --- a/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/CustomReceiptProviderTest.kt +++ b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/CustomReceiptProviderTest.kt @@ -15,6 +15,7 @@ import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor.AutoCloseOutputStream import android.provider.BaseColumns import android.util.Log +import androidx.core.net.toUri import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Database @@ -24,6 +25,8 @@ import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.Room import androidx.room.RoomDatabase +import com.clover.sdk.SimpleSyncClient +import com.clover.sdk.internal.util.UnstableContentResolverClient import com.clover.sdk.util.CloverAccount import com.clover.sdk.v1.ServiceConnector import com.clover.sdk.v1.ServiceConnector.OnServiceConnectedListener @@ -37,13 +40,25 @@ import com.clover.sdk.v1.printer.job.StaticBillPrintJob import com.clover.sdk.v1.printer.job.StaticCreditPrintJob import com.clover.sdk.v1.printer.job.StaticGiftReceiptPrintJob import com.clover.sdk.v1.printer.job.StaticLabelPrintJob +import com.clover.sdk.v1.printer.job.StaticOrderBasedPrintJob import com.clover.sdk.v1.printer.job.StaticOrderPrintJob import com.clover.sdk.v1.printer.job.StaticPaymentPrintJob import com.clover.sdk.v1.printer.job.StaticRefundPrintJob import com.clover.sdk.v1.printer.job.TextPrintJob import com.clover.sdk.v1.printer.job.TokenRequestBasedPrintJob +import com.clover.sdk.v3.device.Device +import com.clover.sdk.v3.employees.Employee +import com.clover.sdk.v3.employees.EmployeeConnector +import com.clover.sdk.v3.merchant.LogoType +import com.clover.sdk.v3.merchant.Merchant +import com.clover.sdk.v3.merchant.MerchantDevicesV2Connector +import com.clover.sdk.v3.order.Order +import com.clover.sdk.v3.order.OrderConnector +import com.clover.sdk.v3.payments.Payment +import com.clover.sdk.v3.payments.Refund import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.MainScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -52,11 +67,16 @@ import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream import java.io.FileNotFoundException import java.io.IOException +import androidx.core.graphics.scale class CustomReceiptProviderTest : ContentProvider(), OnServiceConnectedListener, CoroutineScope by MainScope() { private var printer: Printer? = null private var printerConnector: PrinterConnector? = null + + private val devicesConnector by lazy { MerchantDevicesV2Connector(context) } + private var orderConnector: OrderConnector? = null + private var employeeConnector: EmployeeConnector? = null private var account: Account? = null private var supportedReceiptWidth: Int? = null private var selectedFileResId = R.drawable.test_receipt_auto_select @@ -76,14 +96,21 @@ class CustomReceiptProviderTest : ContentProvider(), OnServiceConnectedListener, const val TABLE_NAME = "receipt_bitmaps" const val SEGMENT_URI = "segment_uri" lateinit var database: AppDatabase - const val SHARED_PREFS = "customReceiptProviderPrefs" const val N_CHUNKS = "nChunks" const val SELECTED_FILE_RES_ID = "selectedFileResId" const val DELAYED_RESPONSE_URIS = "delayedResponseUris" const val DELAYED_RESPONSE_BITMAPS = "delayedResponseBitmaps" const val MAX_RECEIPT_HEIGHT = 2048 - const val TAG = "CustomReceiptProviderTest" + + /** + * Sentinel "selected file" value (not a real drawable id): instead of returning a canned + * test image, generate the receipt from the print job's order, payment and merchant data + * with [SampleReceiptGenerator]. + */ + const val SELECTED_FILE_GENERATED = 0 + + const val TAG = "CRPTest" } @Entity(tableName = TABLE_NAME) @@ -187,30 +214,36 @@ class CustomReceiptProviderTest : ContentProvider(), OnServiceConnectedListener, @Throws(FileNotFoundException::class) override fun openFile(contentUri: Uri, mode: String): ParcelFileDescriptor { - val bitmap = getReceiptSegmentBitmap(contentUri) - val rescaledBitmap = if (selectedFileResId == R.drawable.test_receipt_auto_select && bitmap != null && supportedReceiptWidth != null) { + // Segments are already stored as PNG bytes, so stream them as-is. Decoding and + // re-encoding here roughly doubles the per-segment latency for no benefit. + val segmentBytes = if (selectedFileResId == R.drawable.test_receipt_auto_select && supportedReceiptWidth != null) { // WARNING: Generate the receipt bitmap with width = supportedReceiptWidth and height up to // CustomReceiptProviderTest.MAX_RECEIPT_HEIGHT. Instead of generating a receipt bitmap // matching the supportedReceiptWidth, for testing purpose this app resizes the test bitmap // resource to supportedReceiptWidth x supportedReceiptWidth - Bitmap.createScaledBitmap(bitmap, supportedReceiptWidth!!, supportedReceiptWidth!!, false) + getReceiptSegmentBytes(contentUri)?.let { bytes -> + val opts = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.RGB_565 } + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) + val rescaled = bitmap.scale(supportedReceiptWidth!!, supportedReceiptWidth!!, false) + ByteArrayOutputStream().also { + rescaled.compress(Bitmap.CompressFormat.PNG, 100, it) + }.toByteArray() + } } else { - getReceiptSegmentBitmap(contentUri) + getReceiptSegmentBytes(contentUri) } if (delayedResponseBitmaps == true) { runBlocking { delay(ReceiptContentContract.PROVIDER_TIMEOUT + 1000) } } - return openPipeHelper( - contentUri, "*/*", null, rescaledBitmap - ) { output: ParcelFileDescriptor, uri: Uri, mimeType: String?, opts: Bundle?, args: Bitmap? -> + return openPipeHelper( + contentUri, "*/*", null, segmentBytes + ) { output: ParcelFileDescriptor, uri: Uri, mimeType: String?, opts: Bundle?, bytes: ByteArray? -> try { - AutoCloseOutputStream(output).use { - rescaledBitmap?.compress(Bitmap.CompressFormat.PNG, 100, it) - } + AutoCloseOutputStream(output).use { it.write(bytes ?: ByteArray(0)) } } catch (e: IOException) { - e.printStackTrace() + Log.d(TAG, "Receipt segment pipe closed by reader: $e") } } } @@ -218,8 +251,9 @@ class CustomReceiptProviderTest : ContentProvider(), OnServiceConnectedListener, private fun connect() { disconnect() if (account != null) { - printerConnector = PrinterConnector(context, account, this) - printerConnector?.connect() + printerConnector = PrinterConnector(context, account, this).apply { connect() } + orderConnector = OrderConnector(context, account, this).apply { connect() } + employeeConnector = EmployeeConnector(context, account, this).apply { connect() } } } @@ -228,22 +262,26 @@ class CustomReceiptProviderTest : ContentProvider(), OnServiceConnectedListener, printerConnector?.disconnect() printerConnector = null } + orderConnector?.disconnect() + orderConnector = null + employeeConnector?.disconnect() + employeeConnector = null } - private fun getReceiptSegmentBitmap(contentUri: Uri): Bitmap? { + private fun getReceiptSegmentBytes(contentUri: Uri): ByteArray? { val cursor = query(contentUri, null, null, null, null) - var bitmap: Bitmap? = null + var bytes: ByteArray? = null cursor?.let { it.moveToFirst() - val bitmapData = it.getBlob(it.getColumnIndex(COLUMN_NAME)) - val opts = BitmapFactory.Options() - opts.inPreferredConfig = Bitmap.Config.RGB_565 - bitmap = BitmapFactory.decodeByteArray(bitmapData, 0, bitmapData.size, opts) + val columnIndex = it.getColumnIndex(COLUMN_NAME) + if (columnIndex >= 0) { + bytes = it.getBlob(columnIndex) + } it.close() } - return bitmap + return bytes } override fun call(method: String, arg: String?, extras: Bundle?): Bundle { @@ -344,8 +382,15 @@ class CustomReceiptProviderTest : ContentProvider(), OnServiceConnectedListener, } } - val bitmapUri = storeInCP(selectedFileResId) - Log.d(TAG, "bitmapUri: $bitmapUri") + val contentUris: ArrayList = if (selectedFileResId == SELECTED_FILE_GENERATED) { + // Generate a real receipt (line items, tax summaries, tip, total, merchant header) + // from the print job data instead of returning a canned test image. + buildGeneratedReceiptUris(printJob, printer) + } else { + val bitmapUri = storeInCP(selectedFileResId) + Log.d(TAG, "bitmapUri: $bitmapUri") + ArrayList(List(nChunksToSend) { bitmapUri }) + } if (delayedResponseUris == true) { runBlocking { delay(ReceiptContentContract.PROVIDER_TIMEOUT + 1000) } @@ -353,22 +398,172 @@ class CustomReceiptProviderTest : ContentProvider(), OnServiceConnectedListener, result.putParcelableArrayList( ReceiptContentContract.EXTRA_RECEIPT_CONTENT_URIS, - ArrayList(List(nChunksToSend){bitmapUri}) + contentUris ) } } return result } + /** + * Builds receipt bitmap chunks from the [printJob]'s own data and returns their content URIs + * in print order (header chunk first). This is the path third-party receipt apps should + * model: extract the order/payment from the print job, fall back to the connectors for + * anything missing, compute amounts with OrderCalc, then render. + * + * Runs on a binder thread, so the synchronous connector calls below are safe. + */ + private fun buildGeneratedReceiptUris(printJob: PrintJob?, printer: Printer?): ArrayList { + val uris = ArrayList() + val context = context ?: return uris + if (printJob == null) { + Log.w(TAG, "No print job in extras, cannot generate a receipt") + return uris + } + + var order: Order? = null + var payment: Payment? = null + var refund: Refund? = null + when (printJob) { + is StaticPaymentPrintJob -> { + order = printJob.order + payment = printJob.payment + refund = printJob.refund + } + is StaticRefundPrintJob -> { + order = printJob.order + refund = printJob.refund + } + + is StaticOrderBasedPrintJob -> order = printJob.order + else -> Log.w(TAG, "Unsupported print job type for generated receipts: $printJob") + } + + // Each lookup below is a blocking IPC round-trip; run the independent ones concurrently + // instead of paying their latencies back to back on every print. + var employee: Employee? = null + var merchant: Merchant? = null + var device: Device? = null + var receiptWidth: Int? = null + var businessLogo: Bitmap? = null + var receiptLogo: Bitmap? = null + runBlocking(Dispatchers.IO) { + val orderAndEmployee = async { + // Prints triggered from the Transactions app send a StaticPaymentPrintJob WITHOUT the + // order. Fetching the order by id with OrderConnector is mandatory for compatibility. + // This is a quirk of the Transactions app's print implementation and not a general + // requirement for third-party receipt apps, but this code shows how to do it defensively + // just in case. The order id is available in the print job for both StaticPaymentPrintJob + // and StaticRefundPrintJob. + var resolvedOrder = order + if (resolvedOrder == null) { + val orderId = payment?.order?.id + ?: (printJob as? StaticRefundPrintJob)?.orderId + resolvedOrder = orderId?.let { id -> + kotlin.runCatching { orderConnector?.getOrder(id) } + .onFailure { Log.e(TAG, "Failed to fetch order $id", it) } + .getOrNull() + } + } + // The order only carries an employee reference; resolve it for the staff number. + val resolvedEmployee = resolvedOrder?.employee?.id?.let { id -> + kotlin.runCatching { employeeConnector?.getEmployee(id) } + .onFailure { Log.e(TAG, "Failed to fetch employee $id", it) } + .getOrNull() + } + resolvedOrder to resolvedEmployee + } + + val merchantAsync = async { + // Always fetch the merchant separately; it is never included in the print job. The v3 + // merchant carries the name/address/phone for the header plus the receipt properties + // (merchant-configured header/footer text). Reading it requires the Clover MERCHANT_R + // permission; without it the provider returns no data. + kotlin.runCatching { + val authorityUri = "content://com.clover.v3.merchant" + val result = UnstableContentResolverClient(context.contentResolver, authorityUri.toUri()) + .call(SimpleSyncClient.METHOD_GET, null, null, null) + .getByteArray("data") + ?: return@runCatching null + Merchant(String(result)) + }.onFailure { Log.e(TAG, "Failed to fetch merchant", it) }.getOrNull() + } + + val deviceAsync = async { + // This device's record, for the merchant-assigned device name (e.g. "reg001") on the + // register line. + kotlin.runCatching { devicesConnector.device } + .onFailure { Log.e(TAG, "Failed to fetch device", it) } + .getOrNull() + } + + val widthAsync = async { + // The bitmap width must match the printer's dot width. call() already kicked off this + // lookup; only ask the connector again if it hasn't landed yet. + supportedReceiptWidth + ?: printer?.let { printerConnector?.getPrinterTypeDetails(it)?.numDotsWidth } + } + + val (resolvedOrder, resolvedEmployee) = orderAndEmployee.await() + order = resolvedOrder + employee = resolvedEmployee + merchant = merchantAsync.await() + device = deviceAsync.await() + receiptWidth = widthAsync.await() + + if (merchant == null) { + Log.w(TAG, "Merchant object is null, cannot fetch logo.") + } + merchant?.let { + Log.d(TAG, "Merchant object fetched successfully.") + val logoSync = IntegratorLogoSync(context) + try { + businessLogo = logoSync.getLogo(it, LogoType.BUSINESS) + Log.d(TAG, "Business logo fetched. Is null: ${businessLogo == null}") + receiptLogo = logoSync.getLogo(it, LogoType.RECEIPT) + Log.d(TAG, "Receipt logo fetched. Is null: ${receiptLogo == null}") + } catch (e: Exception) { + Log.e(TAG, "Error fetching logos", e) + } + } + } + if (payment == null) { + payment = order?.payments?.firstOrNull() + } + + val width = receiptWidth + ?: throw IllegalStateException("Failed to get printer type details: printer=$printer, printerConnector=$printerConnector") + + val chunks = SampleReceiptGenerator(context).generateReceiptChunks( + SampleReceiptGenerator.ReceiptParams( + printJob = printJob, + order = order, + payment = payment, + refund = refund, + merchant = merchant, + employee = employee, + receiptWidth = width, + device = device, + businessLogo = businessLogo, + receiptLogo = receiptLogo + ) + ) + chunks.forEach { uris.add(storeBitmapInCP(it)) } + Log.i(TAG, "Generated receipt: ${chunks.size} chunk(s), width=$width") + return uris + } + private fun storeInCP(res: Int): Uri? { + val b: Bitmap = BitmapFactory.decodeResource(this.context?.resources, res) + return storeBitmapInCP(b) + } + + private fun storeBitmapInCP(bitmap: Bitmap): Uri? { val values = ContentValues() val stream = ByteArrayOutputStream() - val b: Bitmap = BitmapFactory.decodeResource(this.context?.resources, res) - b.compress(Bitmap.CompressFormat.PNG, 0, stream) - val blob = stream.toByteArray() - - values.put(SEGMENT_URI, blob) + bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream) + values.put(SEGMENT_URI, stream.toByteArray()) return context?.contentResolver?.insert( Uri.parse("$CONTENT_URI$TABLE_NAME"), values diff --git a/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/CustomReceiptProviderTestActivity.kt b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/CustomReceiptProviderTestActivity.kt index d47bfbdc0a..4168940889 100644 --- a/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/CustomReceiptProviderTestActivity.kt +++ b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/CustomReceiptProviderTestActivity.kt @@ -35,7 +35,8 @@ class CustomReceiptProviderTestActivity : Activity() { R.drawable.test_receipt_auto_select, R.drawable.test_receipt_small, R.drawable.test_receipt_medium, - R.drawable.test_receipt_large + R.drawable.test_receipt_large, + CustomReceiptProviderTest.SELECTED_FILE_GENERATED ) ) @@ -68,10 +69,23 @@ class CustomReceiptProviderTestActivity : Activity() { testReceiptSizeSelector.adapter = adapter } + // Restore the persisted selection so the spinner reflects what the provider will use. + selectedFileResId = + sharedPrefs.getInt(SELECTED_FILE_RES_ID, R.drawable.test_receipt_auto_select) + testReceiptSizeSelector.setSelection(receiptRes.indexOf(selectedFileResId).coerceAtLeast(0)) + testReceiptSizeSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { selectedFileResId = receiptRes[pos] - Log.d(TAG, "Selected file: ${resources.getResourceEntryName(selectedFileResId)}") + // Persist immediately: the provider reads this pref on every print, and waiting for + // the Save button risks losing the choice if the process is killed first. + sharedPrefs.edit().putInt(SELECTED_FILE_RES_ID, selectedFileResId).apply() + val name = if (selectedFileResId == CustomReceiptProviderTest.SELECTED_FILE_GENERATED) { + "generated from order data" + } else { + resources.getResourceEntryName(selectedFileResId) + } + Log.d(TAG, "Selected file: $name") } override fun onNothingSelected(parent: AdapterView<*>) { @@ -120,6 +134,8 @@ class CustomReceiptProviderTestActivity : Activity() { val conProvCN = ComponentName(this, "com.clover.android.sdk.examples.CustomReceiptProviderTest") val pm: PackageManager = this.packageManager - pm.setComponentEnabledSetting(conProvCN, providerState, 0) + // DONT_KILL_APP: without it the system kills this process immediately, which can drop + // shared-preference writes that haven't been flushed to disk yet. + pm.setComponentEnabledSetting(conProvCN, providerState, PackageManager.DONT_KILL_APP) } } \ No newline at end of file diff --git a/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/DeviceAttestationTestActivity.kt b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/DeviceAttestationTestActivity.kt new file mode 100644 index 0000000000..d1af07ead4 --- /dev/null +++ b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/DeviceAttestationTestActivity.kt @@ -0,0 +1,397 @@ +package com.clover.android.sdk.examples + +import android.app.Application +import android.os.Bundle +import android.util.Base64 +import android.view.View +import android.widget.TextView +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewModelScope +import com.clover.android.sdk.examples.databinding.ActivityDeviceAttestationTestBinding +import com.clover.sdk.v3.device.DeviceAttestationClient +import com.clover.sdk.v3.device.internal.AttestationPayload +import com.clover.sdk.v3.device.internal.CompactJws +import com.clover.sdk.v3.device.internal.CompactJwsSigner +import com.clover.sdk.v3.device.internal.InternalCloverApi +import com.google.gson.Gson +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.ByteArrayInputStream +import java.io.File +import android.util.Log +import com.clover.sdk.v3.device.DeviceAttestationClient.CertificateReference +import com.clover.sdk.v3.device.isProd +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigInteger +import java.security.MessageDigest +import java.security.SignatureException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Random +import java.util.UUID + +@OptIn(InternalCloverApi::class) +class DeviceAttestationTestActivity : AppCompatActivity() { + + data class VerificationResult( + val isSignatureValid: Boolean, + val leafSubject: String, + val leafIssuer: String, + val isChainValid: Boolean, + val chainError: String?, + val rootSubject: String, + val deviceTruths: Map, + val userTruths: Map + ) + + sealed interface VerificationState { + object Idle : VerificationState + object Loading : VerificationState + data class Success(val result: VerificationResult) : VerificationState + data class Error(val error: String) : VerificationState + } + + sealed interface AttestationState { + object Idle : AttestationState + object Loading : AttestationState + data class Success(val serializedCompactJws: String) : AttestationState + data class Error(val error: String) : AttestationState + } + + class AttestationViewModel(application: Application) : AndroidViewModel(application) { + companion object { + private const val DEVICE_ROOT_PROD_ASSET = "certs/device_root_prod.pem" + private const val DEVICE_ROOT_DEV_ASSET = "certs/device_root_dev.pem" + + private val gson = Gson() + } + + private val client = DeviceAttestationClient(application) + + private val _state = MutableStateFlow(AttestationState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _verificationState = MutableStateFlow(VerificationState.Idle) + val verificationState: StateFlow = _verificationState.asStateFlow() + + fun sign( + userTruths: Map, + certificateReference: CertificateReference = CertificateReference.CERTIFICATE, + ) { + _state.value = AttestationState.Loading + _verificationState.value = VerificationState.Idle + viewModelScope.launch { + try { + val serializedCompactJws = client.sign(userTruths, certificateReference) + writeAttestation(serializedCompactJws) + + val compactJws = CompactJws.decode(serializedCompactJws) + compactJws.header.x5c?.let { writeDeviceCerts(it) } + + _state.value = AttestationState.Success(serializedCompactJws) + } catch (e: Exception) { + _state.value = AttestationState.Error(e.toString()) + } + } + } + + private suspend fun writeAttestation(serializedCompactJws: String) = withContext(Dispatchers.IO) { + val context = getApplication() + val cacheDir = context.externalCacheDir ?: context.cacheDir + val file = File(cacheDir, "attestation.jws") + file.writeText(serializedCompactJws) + Log.i(TAG, "Wrote attestation: $file") + } + + private suspend fun writeDeviceCerts(x5c: List) = withContext(Dispatchers.IO) { + val context = getApplication() + val cacheDir = context.externalCacheDir ?: context.cacheDir + val file = File(cacheDir, "device_certs.pem") + val pemString = x5c.joinToString("\n") { base64Der -> + val formattedDer = base64Der.chunked(64).joinToString("\n") + "-----BEGIN CERTIFICATE-----\n$formattedDer\n-----END CERTIFICATE-----" + } + file.writeText(pemString) + Log.i(TAG, "Wrote device certificates: $file") + } + + private suspend fun loadDeviceCerts(): List? = withContext(Dispatchers.IO) { + try { + val context = getApplication() + val cacheDir = context.externalCacheDir ?: context.cacheDir + val file = File(cacheDir, "device_certs.pem") + if (file.exists()) { + val certFactory = CertificateFactory.getInstance("X.509") + file.inputStream().use { stream -> + certFactory.generateCertificates(stream).mapNotNull { it as? X509Certificate } + } + } else { + null + } + } catch (ex: Exception) { + Log.w(TAG, "Failed to load device certificates", ex) + null + } + } + + fun verify(serializedCompactJws: String) { + _verificationState.value = VerificationState.Loading + viewModelScope.launch { + try { + val compactJws = CompactJws.decode(serializedCompactJws) + + val certFactory = CertificateFactory.getInstance("X.509") + + // Resolve intermediate certificate chain + + val x5c = compactJws.header.x5c + val x5t = compactJws.header.x5tS256 + + val certs = if (x5c != null) { + // We have certificates + writeDeviceCerts(x5c) + x5c.map { base64Der -> + val derBytes = Base64.decode(base64Der, Base64.NO_WRAP) + certFactory.generateCertificate(ByteArrayInputStream(derBytes)) as X509Certificate + } + } else if (x5t != null) { + // We have a leaf thumbprint + val decodedCerts = loadDeviceCerts() ?: throw IllegalStateException( + "No cached device certificates found. Run verification in certificate reference \"certificate\" to first to cache the device certificates." + ) + val leafCert = decodedCerts.first() + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(leafCert.encoded) + val leafThumbprint = Base64.encodeToString( + hashBytes, + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + ) + if (leafThumbprint != x5t) { + throw SignatureException("Leaf certificate thumbprint mismatch!\nexpected: $x5t\nactual: $leafThumbprint") + } + decodedCerts + } else { + throw IllegalArgumentException("Header is missing both x5c and x5t#S256 certificate references") + } + + require(certs.isNotEmpty()) { "Header is missing certificate chain reference" } + + // Cryptographically verify signature + + val leafCert = certs.first() + val isSignatureValid = CompactJwsSigner.verify(compactJws, leafCert.publicKey) + if (!isSignatureValid) { + throw SignatureException("Cryptographic signature verification failed!") + } + + // Parse payload JSON directly into structured typed payload and determine + // target root CA + + val payload = gson.fromJson(compactJws.payload, AttestationPayload::class.java) + val deviceTruths = payload.deviceTruths + val userTruths = payload.userTruths + + val isProd = deviceTruths.isProd + + // Fetch, and validate root cert + + val rootFileName = if (isProd) DEVICE_ROOT_PROD_ASSET else DEVICE_ROOT_DEV_ASSET + val rootCert = loadRoot(rootFileName) + var isChainValid = false + val chainError: String? = try { + rootCert.checkValidity() + for (cert in certs) { + cert.checkValidity() + } + + // Verify vert chain + + for (i in 0 until certs.size - 1) { + certs[i].verify(certs[i + 1].publicKey) + } + certs.last().verify(rootCert.publicKey) + + isChainValid = true + null + } catch (e: Exception) { + e.toString() + } + + val result = VerificationResult( + isSignatureValid = true, + leafSubject = leafCert.subjectDN.toString(), + leafIssuer = leafCert.issuerDN.toString(), + isChainValid = isChainValid, + chainError = chainError, + rootSubject = rootCert.subjectDN.toString(), + deviceTruths = deviceTruths, + userTruths = userTruths, + ) + + _verificationState.value = VerificationState.Success(result) + } catch (e: Exception) { + Log.w(TAG, "Verification failed", e) + _verificationState.value = VerificationState.Error(e.toString()) + } + } + } + + private fun loadRoot(fileName: String): X509Certificate { + val application = getApplication() + val stream = application.assets.open(fileName) + val factory = CertificateFactory.getInstance("X.509") + return factory.generateCertificate(stream) as X509Certificate + } + } + + companion object { + private const val TAG = "device_attestation" + } + + val userTruthNonce: String = UUID.randomUUID().toString() + val userTruthAccountId: String = BigInteger(64, Random()).toString(10) + + private val viewModel: AttestationViewModel by viewModels() + private lateinit var binding: ActivityDeviceAttestationTestBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityDeviceAttestationTestBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.editNonce.setText(userTruthNonce, TextView.BufferType.NORMAL) + binding.editAccountId.setText(userTruthAccountId, TextView.BufferType.NORMAL) + + // Setup sign action listener + binding.buttonSign.setOnClickListener { + val selectedCertRef = if (binding.radioThumbprint.isChecked) { + CertificateReference.THUMBPRINT + } else { + CertificateReference.CERTIFICATE + } + viewModel.sign( + mapOf( + "nonce" to binding.editNonce.text.toString(), + "accountId" to binding.editAccountId.text.toString(), + ), + selectedCertRef + ) + } + + // Setup verify action listener + binding.buttonVerify.setOnClickListener { + val state = viewModel.state.value + if (state is AttestationState.Success) { + viewModel.verify(state.serializedCompactJws) + } + } + + // Observe Attestation State Flow + lifecycleScope.launch { + viewModel.state.collect { state -> + when (state) { + is AttestationState.Idle -> { + binding.progressBar.visibility = View.GONE + binding.buttonSign.isEnabled = true + binding.buttonVerify.isEnabled = false + binding.textResult.text = "Click \"sign\" to generate attestation." + } + is AttestationState.Loading -> { + binding.progressBar.visibility = View.VISIBLE + binding.buttonSign.isEnabled = false + binding.buttonVerify.isEnabled = false + binding.textResult.text = "Generating attestation..." + } + is AttestationState.Success -> { + binding.progressBar.visibility = View.GONE + binding.buttonSign.isEnabled = true + binding.buttonVerify.isEnabled = true + val serializedCompactJws = state.serializedCompactJws + try { + val compactJws = CompactJws.decode(serializedCompactJws) + binding.textResult.text = buildString { + append(serializedCompactJws.truncateB64("JWS compact serialization")).append("\n\n") + append(compactJws.headerB64.truncateB64("Header")).append("\n") + append(compactJws.payloadB64.truncateB64("Payload")).append("\n") + append(compactJws.signatureB64.truncateB64("Signature")).append("\n") + } + } catch (e: Exception) { + binding.textResult.text = "Error decoding JWS:\n${e.message}" + } + } + is AttestationState.Error -> { + binding.progressBar.visibility = View.GONE + binding.buttonSign.isEnabled = true + binding.buttonVerify.isEnabled = false + binding.textResult.text = "Error:\n${state.error}" + } + } + } + } + + // Observe Verification State Flow + lifecycleScope.launch { + viewModel.verificationState.collect { state -> + when (state) { + is VerificationState.Idle -> { + // Keep current result text unchanged + } + is VerificationState.Loading -> { + binding.progressBar.visibility = View.VISIBLE + binding.buttonSign.isEnabled = false + binding.buttonVerify.isEnabled = false + binding.textResult.text = "Verifying cryptographic signature and trust chain..." + } + is VerificationState.Success -> { + binding.progressBar.visibility = View.GONE + binding.buttonSign.isEnabled = true + binding.buttonVerify.isEnabled = true + val result = state.result + val isProd = result.deviceTruths["is_prod"]?.toBoolean() ?: false + binding.textResult.text = buildString { + append("Verification successful!\n\n") + append("✓ Cryptographic signature: valid\n") + append(" - Leaf subject: ${result.leafSubject}\n") + append(" - Leaf issuer: ${result.leafIssuer}\n\n") + if (result.isChainValid) { + append("✓ Certificate trust path: verified\n") + append(" - Trusted anchor: ${result.rootSubject}\n") + append(" - Full chain validated up to ${if (isProd) "production" else "development"} device root certificate.") + } else { + append("✗ Certificate trust path: invalid\n") + append(" - Error: ${result.chainError}\n") + append(" - Path could not be verified up to the root CA.") + } + append("\n\nDevice truths:\n") + result.deviceTruths.forEach { (k, v) -> append(" $k: $v\n") } + append("\nUser truths:\n") + result.userTruths.forEach { (k, v) -> append(" $k: $v\n") } + } + } + is VerificationState.Error -> { + binding.progressBar.visibility = View.GONE + binding.buttonSign.isEnabled = true + binding.buttonVerify.isEnabled = true + binding.textResult.text = + "Verification failed:\n${state.error}\n\nCheck logs for details." + } + } + } + } + } +} + +private fun String.truncateB64(label: String, size: Int = 32): String { + val content = if (this.length > size) + "${this.substring(0, size / 2)}…${this.substring(this.length - (size / 2) + 1, this.length)}" + else + this + + return "$label: $content (${this.length} bytes)" +} diff --git a/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/DeviceNotificationTestActivity.kt b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/DeviceNotificationTestActivity.kt new file mode 100644 index 0000000000..c26ca4c1fd --- /dev/null +++ b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/DeviceNotificationTestActivity.kt @@ -0,0 +1,158 @@ +package com.clover.android.sdk.examples + +import android.content.Context +import android.graphics.Color +import android.os.Bundle +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.util.Log +import android.view.View +import android.widget.CheckBox +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.clover.sdk.util.CloverAccount +import com.clover.sdk.v1.app.AppNotification +import com.clover.sdk.v1.app.AppNotificationReceiver +import com.clover.sdk.v3.merchant.MerchantDevicesV2Connector +import com.clover.android.sdk.examples.databinding.ActivityDeviceNotificationsTestBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.core.graphics.toColorInt +import com.clover.android.sdk.examples.DeviceNotificationViewModel.LogEntry.Level +import com.clover.android.sdk.examples.DeviceNotificationViewModel.LogEntry.Level.* + +class DeviceNotificationTestActivity : AppCompatActivity() { + companion object { + private val TAG = DeviceNotificationTestActivity::class.simpleName + + private const val EVENT_TEST_REBOOT = "test_reboot" + private const val EVENT_TEST_FORCE_REBOOT = "test_force_reboot" + } + + private val viewModel: DeviceNotificationViewModel by viewModels() + + private lateinit var binding: ActivityDeviceNotificationsTestBinding + + private val devicesConnector = MerchantDevicesV2Connector(this) + + private val receiver = object : AppNotificationReceiver() { + override fun onReceive(context: Context, notification: AppNotification) { + log(INFO, "Received Notification: event=${notification.appEvent}") + + if (notification.appEvent == EVENT_TEST_REBOOT || + notification.appEvent == EVENT_TEST_FORCE_REBOOT + ) { + val force = notification.appEvent == EVENT_TEST_FORCE_REBOOT + log(INFO, "Received event: '${notification.appEvent}' rebooting (force=$force)...") + lifecycleScope.launch { + withContext(Dispatchers.IO) { + devicesConnector.reboot("Test reboot triggered by device notification", force) + } + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityDeviceNotificationsTestBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Verify we have an account + if (CloverAccount.getAccount(this) == null) { + log(ERROR, "Clover account not found") + return + } + + receiver.register(this) + + viewModel.logEntry.observe(this) { entry -> + log(entry.level, entry.message) + } + + viewModel.devices.observe(this) { devices -> + binding.devicesContainer.removeAllViews() + for (device in devices) { + val checkBox = CheckBox(this).apply { + text = device.serial + if (device.name.isNotEmpty()) { + append(" (${device.name})") + } + + isChecked = device.isChecked + setOnCheckedChangeListener { _, isChecked -> + viewModel.toggleDevice(device.id, isChecked) + } + } + + binding.devicesContainer.addView(checkBox) + } + } + + class RebootClickListener(private val force: Boolean) : View.OnClickListener { + override fun onClick(p0: View?) { + val authResult = viewModel.authResult.value + if (authResult == null) { + log(ERROR, "Not authenticated") + return + } + + val targetDeviceIds = viewModel.devices.value + ?.filter { it.isChecked } + ?.map { it.id } + ?: emptyList() + + viewModel.sendReboot(authResult, targetDeviceIds, force) + } + } + + binding.sendRebootButton.setOnClickListener(RebootClickListener(false)) + binding.sendForceRebootButton.setOnClickListener(RebootClickListener(true)) + + viewModel.authenticate() + viewModel.loadDevices() + } + + override fun onDestroy() { + super.onDestroy() + receiver.unregister() + } + + private fun log(level: Level, text: String) { + when (level) { + ERROR -> Log.e(TAG, text) + INFO -> Log.i(TAG, text) + SUCCESS -> Log.i(TAG, text) + } + + lifecycleScope.launch(Dispatchers.Main) { + val newLine = if (binding.logText.text.isEmpty()) "" else "\n" + val fullText = "$newLine$text" + binding.logText.append(SpannableString(fullText).setSpan(level, fullText)) + + binding.logScrollView.post { + binding.logScrollView.fullScroll(View.FOCUS_DOWN) + } + } + } +} + +fun SpannableString.setSpan(level: Level, text: String): SpannableString { + setSpan( + ForegroundColorSpan(level.color()), + 0, + text.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + return this +} + +private fun Level.color() = when (this) { + ERROR -> Color.RED + SUCCESS -> "#008800".toColorInt() // Dark green + INFO -> Color.BLACK +} diff --git a/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/DeviceNotificationViewModel.kt b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/DeviceNotificationViewModel.kt new file mode 100644 index 0000000000..c479a89335 --- /dev/null +++ b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/DeviceNotificationViewModel.kt @@ -0,0 +1,170 @@ +package com.clover.android.sdk.examples + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.clover.android.sdk.examples.DeviceNotificationViewModel.LogEntry.Level +import com.clover.android.sdk.examples.DeviceNotificationViewModel.LogEntry.Level.* +import com.clover.sdk.util.CloverAuth +import com.clover.sdk.v3.merchant.MerchantDevicesV2Contract +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +data class DeviceItem( + val id: String, + val name: String, + val serial: String, + var isChecked: Boolean = false +) + +class DeviceNotificationViewModel(application: Application) : AndroidViewModel(application) { + + data class LogEntry(val level: Level, val message: String) { + enum class Level { INFO, ERROR, SUCCESS } + } + + companion object { + private val TAG = DeviceNotificationViewModel::class.java.simpleName + private val okHttpClient = OkHttpClient() + } + + private val _authResult = MutableLiveData() + val authResult: LiveData = _authResult + + private val _devices = MutableLiveData>() + val devices: LiveData> = _devices + + private val _logEntry = MutableLiveData() + val logEntry: LiveData = _logEntry + + fun authenticate() { + viewModelScope.launch { + try { + val result = withContext(Dispatchers.IO) { + CloverAuth.authenticate( + getApplication().applicationContext, + false, + 10, TimeUnit.SECONDS + ) + } + _authResult.value = result + log(SUCCESS, "Authentication successful") + } catch (e: Exception) { + log(ERROR, "Authentication failed: $e") + } + } + } + + fun loadDevices() { + viewModelScope.launch { + try { + val loadedDevices = mutableListOf() + withContext(Dispatchers.IO) { + val context = getApplication().applicationContext + val cursor = context.contentResolver.query( + MerchantDevicesV2Contract.Device.CONTENT_URI, + null, null, null, null + ) + + cursor?.use { + while (it.moveToNext()) { + val device = MerchantDevicesV2Contract.Device.fromCursor(it) + // Filter out all the junk entries that are bound to exist + // in a development environment. + if (device.id != null && + device.model != null && + device.serial != null && + device.model.startsWith("Clover_") + ) { + loadedDevices.add( + DeviceItem( + device.id, + device.name ?: "", + device.serial, + ) + ) + } + } + } + } + loadedDevices.sortBy { it.serial } + _devices.value = loadedDevices + log(INFO, "Loaded ${loadedDevices.size} devices") + } catch (e: Exception) { + log(ERROR, "Failed to load devices: $e") + } + } + } + + fun toggleDevice(deviceId: String, isChecked: Boolean) { + val currentDevices = _devices.value?.toMutableList() ?: return + val index = currentDevices.indexOfFirst { it.id == deviceId } + if (index != -1) { + currentDevices[index] = currentDevices[index].copy(isChecked = isChecked) + _devices.value = currentDevices + } + } + + fun sendReboot(authResult: CloverAuth.AuthResult, targetDeviceIds: List, force: Boolean) { + if (targetDeviceIds.isEmpty()) { + log(INFO, "No devices selected.") + return + } + + val eventName = if (force) "test_force_reboot" else "test_reboot" + log(INFO, "Sending ${if (force) "force " else ""}reboot notification ($eventName) to ${targetDeviceIds.size} devices...") + + viewModelScope.launch { + withContext(Dispatchers.IO) { + val mediaType = MediaType.parse("application/json; charset=utf-8") + val requestBody = RequestBody.create(mediaType, JSONObject().apply { + put("event", eventName) + put("payload", "Trigger reboot") + }.toString()) + + for (deviceId in targetDeviceIds) { + try { + val uri = + "${authResult.baseUrl}/v3/apps/${authResult.appId}/devices/$deviceId/notifications" + + val request = Request.Builder() + .url(uri) + .addHeader("Authorization", "Bearer ${authResult.authToken}") + .post(requestBody) + .build() + + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("Received non-OK status from server: HTTP/1.1 ${response.code()} ${response.message()}") + } + log(SUCCESS, "Notification sent to $deviceId") + } + } catch (e: Exception) { + log(ERROR, "Failed to send to $deviceId: $e") + } + } + } + } + } + + private fun log(level: Level, message: String) { + when (level) { + ERROR -> Log.e(TAG, message) + INFO -> Log.i(TAG, message) + SUCCESS -> Log.i(TAG, message) + } + viewModelScope.launch(Dispatchers.Main) { + _logEntry.value = LogEntry(level, message) + } + } +} diff --git a/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/IntegratorLogoSync.kt b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/IntegratorLogoSync.kt new file mode 100644 index 0000000000..ba9a6058b7 --- /dev/null +++ b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/IntegratorLogoSync.kt @@ -0,0 +1,107 @@ +package com.clover.android.sdk.examples + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import com.clover.sdk.v3.merchant.LogoType +import com.clover.sdk.v3.merchant.Merchant +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +class IntegratorLogoSync(private val context: Context) { + + private val inMemoryCache = mutableMapOf() + private val diskCacheDir = File(context.cacheDir, "logo_cache") + private val client = OkHttpClient() + + companion object { + private const val TAG = "IntegratorLogoSync" + } + + init { + if (!diskCacheDir.exists()) { + diskCacheDir.mkdirs() + } + } + + @Throws(IOException::class) + fun getLogo(merchant: Merchant, type: LogoType): Bitmap? { + val logo = merchant.logos?.find { it.logoType == type } + val url = logo?.url + + Log.d(TAG, "Attempting to get logo of type $type. URL: $url") + + if (url == null || url.isEmpty()) { + Log.d(TAG, "Logo URL is null or empty.") + return null + } + + // Check in-memory cache + var bitmap = inMemoryCache[url] + if (bitmap != null) { + Log.d(TAG, "Logo found in in-memory cache.") + return bitmap + } + Log.d(TAG, "Logo not found in in-memory cache.") + + // Check disk cache + val cacheFile = File(diskCacheDir, url.hashCode().toString()) + if (cacheFile.exists()) { + try { + bitmap = BitmapFactory.decodeFile(cacheFile.absolutePath) + if (bitmap != null) { + Log.d(TAG, "Logo found in disk cache.") + inMemoryCache[url] = bitmap + return bitmap + } else { + Log.w(TAG, "Failed to decode logo from disk cache.") + } + } catch (e: Exception) { + Log.e(TAG, "Error reading logo from disk cache", e) + } + } + Log.d(TAG, "Logo not found in disk cache.") + + // Fetch from network + Log.d(TAG, "Fetching logo from network: $url") + try { + val request = Request.Builder().url(url).build() + val response = client.newCall(request).execute() + + Log.d(TAG, "Network response code: ${response.code()}") + + if (response.isSuccessful) { + response.body()?.byteStream()?.use { inputStream -> + bitmap = BitmapFactory.decodeStream(inputStream) + if (bitmap != null) { + Log.d(TAG, "Successfully downloaded and decoded logo from network.") + inMemoryCache[url] = bitmap + // Save to disk cache + try { + FileOutputStream(cacheFile).use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + Log.d(TAG, "Logo saved to disk cache.") + } + } catch (e: Exception) { + Log.e(TAG, "Error saving logo to disk cache", e) + } + return bitmap + } else { + Log.w(TAG, "Failed to decode logo from network stream.") + } + } + } else { + Log.w(TAG, "Failed to fetch logo from network. Response: $response") + } + } catch (e: Exception) { + Log.e(TAG, "Error fetching logo from network", e) + } + + Log.d(TAG, "Returning null, could not get logo.") + return null + } +} diff --git a/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/InventoryTestActivity.java b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/InventoryTestActivity.java index fdccca53d3..3d2148fbed 100644 --- a/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/InventoryTestActivity.java +++ b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/InventoryTestActivity.java @@ -44,6 +44,9 @@ import com.clover.sdk.v1.ServiceConnector; import com.clover.sdk.v1.ServiceException; import com.clover.sdk.v3.inventory.Attribute; +import com.clover.sdk.v3.inventory.BundleDefinition; +import com.clover.sdk.v3.inventory.BundleItem; +import com.clover.sdk.v3.inventory.BundleItemGroup; import com.clover.sdk.v3.inventory.Category; import com.clover.sdk.v3.inventory.Discount; import com.clover.sdk.v3.inventory.IInventoryService; @@ -52,6 +55,7 @@ import com.clover.sdk.v3.inventory.InventoryIntent; import com.clover.sdk.v3.inventory.Item; import com.clover.sdk.v3.inventory.ItemGroup; +import com.clover.sdk.v3.inventory.Marker; import com.clover.sdk.v3.inventory.Menu; import com.clover.sdk.v3.inventory.Modifier; import com.clover.sdk.v3.inventory.ModifierGroup; @@ -86,6 +90,7 @@ import java.io.IOException; import java.lang.reflect.Constructor; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Locale; @@ -851,6 +856,31 @@ public boolean checkModifiersAvailability(List modifierIdsList, ResultSt throw new UnsupportedOperationException("Need to implement checkModifiersAvailability()"); } + @Override + public List getBundleItems(String bundleItemGroupId, ResultStatus resultStatus) throws RemoteException { + throw new UnsupportedOperationException("Not supported through web service API"); + } + + @Override + public List getBundleItemGroups(String itemId, ResultStatus resultStatus) throws RemoteException { + throw new UnsupportedOperationException("Not supported through web service API"); + } + + @Override + public BundleDefinition getBundleDefinition(String itemId, ResultStatus resultStatus) throws RemoteException { + throw new UnsupportedOperationException("Not supported through web service API"); + } + + @Override + public List getBundleDefinitions(ResultStatus resultStatus) throws RemoteException { + throw new UnsupportedOperationException("Not supported through web service API"); + } + + @Override + public void associateMarkerToItem(String itemId, List markerIdsToAssociate, List markerIdsToDissociate, ResultStatus resultStatus) throws RemoteException { + throw new UnsupportedOperationException("Need to implement associateMarkerToItem()"); + } + @Override public Item getItemWithCategories(String itemId, ResultStatus resultStatus) throws RemoteException { throw new UnsupportedOperationException("getItemWithCategories() not supported through web service API"); @@ -891,6 +921,11 @@ public List getTaxRatesExcludedForItem(String orderTypeId, String itemI throw new UnsupportedOperationException("getTaxRatesForItem() not supported through web service API"); } + @Override + public List getMarkersForItem(String itemId, ResultStatus resultStatus) throws RemoteException { + throw new UnsupportedOperationException("getMarkersForItem() not supported through web service API"); + } + @Override public void bulkAssignColorToItems(List itemIds, String colorHexCode, ResultStatus resultStatus) throws RemoteException { throw new UnsupportedOperationException("Need to implement bulkAssignColorToItems"); @@ -906,6 +941,11 @@ public void removeTaxRatesFromItem(String itemId, List taxRates, ResultS throw new UnsupportedOperationException("Need to implement removeTaxRatesFromItem()"); } + @Override + public List getAllMarkers(ResultStatus resultStatus) throws RemoteException { + throw new UnsupportedOperationException("Need to implement getAllMarkers()"); + } + @Override public TaxRate getTaxRate(String taxRateId, ResultStatus resultStatus) throws RemoteException { String uri = "/v2/merchant/" + merchantId + "/tax_rates/" + taxRateId; diff --git a/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/SampleReceiptGenerator.kt b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/SampleReceiptGenerator.kt new file mode 100644 index 0000000000..35612ada81 --- /dev/null +++ b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/SampleReceiptGenerator.kt @@ -0,0 +1,717 @@ +package com.clover.android.sdk.examples + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Typeface +import android.util.Log +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import com.clover.sdk.v1.printer.job.PrintJob +import com.clover.sdk.v3.device.Device +import com.clover.sdk.v3.employees.Employee +import com.clover.sdk.v3.merchant.Merchant +import com.clover.sdk.v3.order.LineItem +import com.clover.sdk.v3.order.Order +import com.clover.sdk.v3.order.OrderCalc +import com.clover.sdk.v3.payments.Payment +import com.clover.sdk.v3.payments.Refund +import org.json.JSONObject +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.Currency +import java.util.Date +import java.util.Locale +import kotlin.math.min +import kotlin.math.pow + +/** + * Generates printable receipt bitmaps from real order, payment and merchant data instead of + * canned test images. + * + * This class intentionally mirrors how the native Clover receipt engine renders receipts: + * receipt data is computed up front (totals, tax summaries, tips via [OrderCalc]), each receipt + * section is generated as an Android [View] element, the sections are stacked in a vertical + * [LinearLayout], and the laid-out view is drawn to one or more [Bitmap] chunks no taller than + * [CustomReceiptProviderTest.MAX_RECEIPT_HEIGHT] pixels, at exactly the printer's dot width. + * + * Receipt data elements demonstrated here, and where each one comes from: + * + * - Store name: v3 [Merchant.getName], fetched with `MerchantV3SyncClient.getMerchant()` + * (requires the Clover `MERCHANT_R` permission). + * - Store address / phone number: [Merchant.getAddress] / [Merchant.getPhoneNumber]. + * (Store logo and tax registration number (TIN) are not yet exposed through the SDK + * merchant objects.) + * - Receipt header and footer comments: the merchant's + * `MerchantProperties.getReceiptProperties()` JSON (keys `storeHeadline` and + * `customFooter`). + * - Date and time: [Payment.getCreatedTime] for a sale, [Refund.getCreatedTime] for a refund, + * with [Order.getCreatedTime] used for the original order/accounting date. + * - Staff number: [Order.getEmployee] is a reference; resolve it with + * `EmployeeConnector.getEmployee(id)`. [Employee.getCustomId] is the best fit for a short + * numeric staff number; fall back to nickname/name. + * - Slip name / order number: [Order.getId]; order pickup number: [Order.getTitle] (only + * populated when the merchant has order titles/rolling order numbers configured). + * - Transaction number: [Payment.getId]. + * - Item names and prices: [Order.getLineItems], [LineItem.getName], [LineItem.getPrice], + * [LineItem.getUnitQty] (unit quantity is stored in thousandths). + * - Reduced tax rate indicator: inspect [LineItem.getTaxRates]; rates are encoded as + * 1% == 100,000. Items taxed at [REDUCED_TAX_RATE] are prefixed with + * [REDUCED_TAX_MARKER]. + * - Total amount: [OrderCalc.getTotal] — handles discounts, service charges and both + * tax-inclusive (VAT) and tax-exclusive merchants. + * - Total tax amount: [OrderCalc.getTax]. + * - Tax summaries (per-rate net subtotal and tax, e.g. a reduced vs standard rate split): + * [OrderCalc.getTaxSummaries]. For partial payments use the overload that takes a split + * percent. + * - Tip: [Payment.getTipAmount] (or [OrderCalc.getTip] across all payments). + * - Payment method: [Payment.getTender] label plus card type/last four from + * [Payment.getCardTransaction]. + * - Receipt type / flags: [PrintJob.flags] (reprint, void, refund, customer vs merchant copy, + * bill). + * - Device: the human-friendly device name (e.g. "reg001") from [Device.getName], fetched + * with `MerchantDevicesV2Connector.getDevice()`; falls back to [Device.getSerial]. + * - Barcode: not rendered here; encode [Payment.getId] or [Order.getId] with any barcode + * library and add the resulting bitmap as another section view. + * - Total customers (guest count): count of distinct non-empty [LineItem.getBinName] values + * across the order's line items. Each unique binName represents one guest/seat; computed + * with [getGuestCountByBinName] and rendered near the top of a sale receipt. + */ +class SampleReceiptGenerator(private val context: Context) { + + /** + * Everything the generator needs. [order] should already be hydrated — when a print job + * arrives without an embedded order (e.g. reprints from the Transactions app), fetch it with + * `OrderConnector.getOrder(orderId)` before calling [generateReceiptChunks]. + */ + data class ReceiptParams( + val printJob: PrintJob, + val order: Order?, + val payment: Payment?, + val refund: Refund?, + val merchant: Merchant?, + val employee: Employee?, + val receiptWidth: Int, + val device: Device? = null, + val businessLogo: Bitmap? = null, + val receiptLogo: Bitmap? = null + ) + + companion object { + const val TAG = "SampleReceiptGenerator" + + /** + * Clover adds a taxtype to each line item tax rate; for a standard tax rate item, the label is + */ + const val STANDARD_TAX_RATE_LABEL_KEY= "com.clover.tax.rate.standard" + + /** + * Clover adds a taxType to each line item tax rate; for a reduced tax rate item, the taxType is + */ + const val REDUCED_TAX_RATE_LABEL_KEY = "com.clover.tax.rate.reduced" + + /** + * Clover adds a taxType to each line item tax rate; for a no-tax tax rate item, the taxType is + */ + const val NO_TAX_RATE_TAX_TYPE_LABEL_KEY = "com.clover.tax.rate.no_tax" + + /** Marker printed in front of items taxed at the reduced rate. */ + const val REDUCED_TAX_MARKER = "※" + private const val RATE_PER_PERCENT = 100_000.0 + private const val TEXT_SIZE_SMALL = 20f + private const val TEXT_SIZE_MEDIUM = 28f + private const val TEXT_SIZE_LARGE = 36f + } + + /** + * A display group of receipt lines that are identical except for quantity. Quantities use + * Clover's thousandths representation so both separate rows and unitQty are accumulated. + */ + internal data class GroupedLineItem( + val lineItem: LineItem, + var quantityThousandths: Long + ) + + private data class LineItemGroupKey( + val itemIdentity: String, + val displayName: String?, + val alternateName: String?, + val price: Long?, + val binName: String?, + val refunded: Boolean?, + val modifications: List, + val discounts: List, + val taxRates: List + ) + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + + /** + * Builds the receipt view from [params] and renders it to RGB_565 bitmap chunks of at most + * [maxChunkHeight] pixels, in print order (header chunk first). + */ + fun generateReceiptChunks( + params: ReceiptParams, + maxChunkHeight: Int = CustomReceiptProviderTest.MAX_RECEIPT_HEIGHT + ): List { + val receiptView = generateReceiptView(params) + return renderToChunks(receiptView, params.receiptWidth, maxChunkHeight) + } + + /** + * Stacks receipt sections in the same top-to-bottom order as the SMCC receipt layout/questions. + * Fields owned by Stera GW are intentionally omitted because they are not available from the + * Clover objects passed to this sample. + */ + fun generateReceiptView(params: ReceiptParams): View { + val orderCalc = params.order?.let { OrderCalc(it) } + val root = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(Color.WHITE) + val pad = params.receiptWidth / 24 + setPadding(pad, pad, pad, pad) + } + + val isRefund = params.refund != null || + params.printJob.flags and PrintJob.FLAG_REFUND == PrintJob.FLAG_REFUND + + val sections = mutableListOf() + sections += generateHeaderLogoView(params) + if (!isRefund) { + sections += generateSlipAndCustomerView(params) + } + sections += generateMerchantIdentityView(params) + if (isRefund) { + sections += generateFlagBannerView(params) + sections += generateHeaderCommentView(params) + } else { + sections += generateHeaderCommentView(params) + sections += generateFlagBannerView(params) + } + sections += generateTransactionDatesView(params, isRefund) + sections += generateLineItemsView(params) + sections += generateTotalsView(params, orderCalc) + sections += generateTotalItemCountView(params) + sections += generateTenderView(params) + sections += generateFooterView(params) + sections += generateReducedTaxNoteView(params) + sections += generateMerchantAndTerminalInfoView(params) + sections += generateTransactionIdentifierView(params) + if (!isRefund) { + sections += generateOrderNumberView(params) + } + + sections.filterNotNull().forEachIndexed { index, section -> + if (index > 0) root.addView(divider(params)) + root.addView(section) + } + return root + } + + private fun generateHeaderLogoView(params: ReceiptParams): View { + val logoBitmap = params.businessLogo ?: params.receiptLogo + Log.d(TAG, "generateHeaderLogoView: businessLogo is null: ${params.businessLogo == null}, receiptLogo is null: ${params.receiptLogo == null}") + return verticalSection(params) { + logoBitmap?.let { + Log.d(TAG, "Logo bitmap is not null, adding to receipt view.") + val imageView = android.widget.ImageView(context) + imageView.setImageBitmap(it) + imageView.layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + (imageView.layoutParams as LinearLayout.LayoutParams).gravity = Gravity.CENTER_HORIZONTAL + addView(imageView) + } ?: run { + Log.d(TAG, "Logo bitmap is null, not adding to receipt view.") + } + } + } + + /** Slip/order ID followed by the guest count, matching the first SMCC receipt rows. */ + private fun generateSlipAndCustomerView(params: ReceiptParams): View { + val totalGuests = getGuestCountByBinName(params.order) + return verticalSection(params) { + params.order?.id?.let { addView(row("Order", it, params)) } + addView( + row( + label = "Total customers", + value = totalGuests.toString(), + params = params, + bold = true + ) + ) + } + } + + /** Receipt type banner derived from [PrintJob.flags]: reprint, void, refund, bill, copy. */ + private fun generateFlagBannerView(params: ReceiptParams): View? { + val flags = params.printJob.flags + val labels = mutableListOf() + if (flags and PrintJob.FLAG_REPRINT == PrintJob.FLAG_REPRINT) labels += "*** REPRINT ***" + if (flags and PrintJob.FLAG_PRINT_VOID_RECEIPT == PrintJob.FLAG_PRINT_VOID_RECEIPT) labels += "*** VOIDED ***" + if (flags and PrintJob.FLAG_REFUND == PrintJob.FLAG_REFUND || params.refund != null) labels += "*** REFUND ***" + if (flags and PrintJob.FLAG_BILL == PrintJob.FLAG_BILL) labels += "BILL — NOT A RECEIPT" + if (flags and PrintJob.FLAG_MERCHANT == PrintJob.FLAG_MERCHANT) labels += "MERCHANT COPY" + if (flags and PrintJob.FLAG_CUSTOMER == PrintJob.FLAG_CUSTOMER) labels += "CUSTOMER COPY" + if (labels.isEmpty()) return null + + return verticalSection(params) { + labels.forEach { addView(centeredText(it, params, bold = true)) } + } + } + + /** Store name, address and phone from the v3 [Merchant] object. */ + private fun generateMerchantIdentityView(params: ReceiptParams): View { + val merchant = params.merchant + return verticalSection(params) { + addView(centeredText(merchant?.name ?: "Merchant name unavailable", params, bold = true, sizePx = TEXT_SIZE_LARGE)) + merchant?.address?.let { address -> + listOfNotNull( + address.address1, + address.address2, + listOfNotNull(address.city, address.state, address.zip) + .filter { it.isNotEmpty() } + .joinToString(" ") + ) + .filter { it.isNotEmpty() } + .forEach { addView(centeredText(it, params)) } + } + merchant?.phoneNumber?.takeIf { it.isNotEmpty() }?.let { + addView(centeredText(it, params)) + } + } + } + + /** Merchant-configured receipt header text, placed after merchant identity. */ + private fun generateHeaderCommentView(params: ReceiptParams): View? { + val headline = receiptProperty(params, "storeHeadline") ?: return null + return verticalSection(params) { addView(centeredText(headline, params)) } + } + + /** Sale date, or refund operation date followed by the original accounting date. */ + private fun generateTransactionDatesView(params: ReceiptParams, isRefund: Boolean): View? { + val section = verticalSection(params) { + if (isRefund) { + params.refund?.createdTime?.let { + addView(row("Refund date", dateFormat.format(Date(it)), params)) + } + params.order?.createdTime?.let { + addView(row("Original date", dateFormat.format(Date(it)), params)) + } + } else { + val createdTime = params.payment?.createdTime ?: params.order?.createdTime + createdTime?.let { addView(row("Date", dateFormat.format(Date(it)), params)) } + } + } + return section.takeIf { it.childCount > 0 } + } + + /** One row per identical-item group, with combined quantity, tax marker, name and price. */ + private fun generateLineItemsView(params: ReceiptParams): View { + val lineItems = params.order?.lineItems + return verticalSection(params) { + if (lineItems.isNullOrEmpty()) { + addView(centeredText("(no line items)", params)) + return@verticalSection + } + groupLineItems(lineItems).forEach { group -> + val line = group.lineItem + val marker = if (isReducedTaxRate(line)) "$REDUCED_TAX_MARKER " else "" + val name = line.name ?: line.alternateName ?: "(unnamed item)" + val qty = group.quantityThousandths / 1000.0 + val qtyLabel = if (qty != 1.0) "${trimQty(qty)} x " else "" + addView(row("$qtyLabel$marker$name", formatAmount(line.price, params.merchant), params)) + } + } + } + + /** + * Subtotal, service charge, per-rate tax summaries, total, tip — all computed with + * [OrderCalc] so discounts and tax-inclusive (VAT) pricing are handled the same way the + * native receipt engine handles them. + */ + private fun generateTotalsView(params: ReceiptParams, orderCalc: OrderCalc?): View { + val lineItems = params.order?.lineItems + return verticalSection(params) { + if (orderCalc == null || lineItems == null) { + addView(centeredText("(order data unavailable)", params)) + return@verticalSection + } + + addView(row("Subtotal", formatAmount(orderCalc.getLineSubtotal(lineItems), params.merchant), params)) + + val discounted = orderCalc.getDiscountedSubtotal(lineItems) + val undiscounted = orderCalc.getLineSubtotal(lineItems) + if (discounted != undiscounted) { + addView(row("Discounts", formatAmount(discounted - undiscounted, params.merchant), params)) + } + + val serviceCharge = orderCalc.getServiceCharge(lineItems) + if (serviceCharge > 0) { + val name = params.order?.serviceCharge?.name ?: "Service charge" + addView(row(name, formatAmount(serviceCharge, params.merchant), params)) + } + + // Per-rate tax summaries: one net/tax pair per distinct tax rate on the order. + // summary.net is the taxable subtotal at that rate, summary.tax the tax collected. + orderCalc.getTaxSummaries(lineItems).forEach { summary -> + val ratePercent = (summary.taxRate.getRateAsLong() ?: 0L) / RATE_PER_PERCENT + val rateLabel = "${summary.taxRate.getName()} (${trimQty(ratePercent)}%)" + addView(row("Net $rateLabel", formatAmount(summary.net.cents, params.merchant), params)) + addView(row("Tax $rateLabel", formatAmount(summary.tax.cents, params.merchant), params)) + } + addView(row("Total tax", formatAmount(orderCalc.tax, params.merchant), params)) + addView(row("Total", formatAmount(orderCalc.getTotal(lineItems), params.merchant), params, bold = true, sizePx = TEXT_SIZE_LARGE)) + + val tip = params.payment?.tipAmount ?: orderCalc.tip + if (tip > 0) { + addView(row("Tip", formatAmount(tip, params.merchant), params)) + addView(row("Total + tip", formatAmount(orderCalc.getTotal(lineItems) + tip, params.merchant), params, bold = true)) + } + } + } + + /** Total quantity follows totals in the SMCC layout; unitQty is stored in thousandths. */ + private fun generateTotalItemCountView(params: ReceiptParams): View? { + val lineItems = params.order?.lineItems + if (lineItems.isNullOrEmpty()) return null + val totalQuantity = groupLineItems(lineItems).sumOf { it.quantityThousandths } + return verticalSection(params) { + addView(row("Total items", trimQty(totalQuantity / 1000.0), params, bold = true)) + } + } + + /** + * Groups line items using the SMCC data-model rules. Item identity, price, modifiers and + * discounts must match. Tax treatment, guest/bin and refunded state are also included so rows + * that require different receipt labels are never merged. + */ + internal fun groupLineItems(lineItems: List): List { + val groups = linkedMapOf() + lineItems.forEach { line -> + val key = lineItemGroupKey(line) + val quantity = line.unitQty?.toLong() ?: 1_000L + val existing = groups[key] + if (existing == null) { + groups[key] = GroupedLineItem(line, quantity) + } else { + existing.quantityThousandths += quantity + } + } + return groups.values.toList() + } + + private fun lineItemGroupKey(line: LineItem): LineItemGroupKey { + val itemIdentity = line.item?.id?.let { "item:$it" } + ?: "custom:${line.name.orEmpty()}:${line.alternateName.orEmpty()}" + val modifications = line.modifications.orEmpty().map { modification -> + listOf( + modification.id, + modification.name, + modification.alternateName, + modification.amount?.toString() + ).joinToString("|") { it.orEmpty() } + }.sorted() + val discounts = line.discounts.orEmpty().map { discount -> + listOf( + discount.id, + discount.name, + discount.amount?.toString(), + discount.percentage?.toString() + ).joinToString("|") { it.orEmpty() } + }.sorted() + val taxRates = line.taxRates.orEmpty().map { taxRate -> + listOf( + taxRate.id, + taxRate.name, + taxRate.rate?.toString(), + taxRate.systemTaxRate?.labelKey + ).joinToString("|") { it.orEmpty() } + }.sorted() + return LineItemGroupKey( + itemIdentity = itemIdentity, + displayName = line.name, + alternateName = line.alternateName, + price = line.price, + binName = line.binName, + refunded = line.refunded, + modifications = modifications, + discounts = discounts, + taxRates = taxRates + ) + } + + /** Payment method, card details, amount paid and transaction/refund identifiers. */ + private fun generateTenderView(params: ReceiptParams): View? { + if (params.payment == null && params.refund == null) return null + return verticalSection(params) { + params.payment?.let { payment -> + val tenderLabel = payment.tender?.label ?: "Payment" + addView(row(tenderLabel, formatAmount(payment.amount, params.merchant), params)) + payment.cardTransaction?.let { card -> + val cardLabel = listOfNotNull(card.cardType?.name, card.last4?.let { "****$it" }) + .joinToString(" ") + if (cardLabel.isNotEmpty()) addView(row(cardLabel, null, params)) + card.authCode?.let { addView(row("Auth code", it, params)) } + } + payment.result?.let { addView(row("Result", it.name, params)) } + } + params.refund?.let { refund -> + addView(row("Refund", formatAmount(refund.amount, params.merchant), params)) + } + } + } + + private fun generateFooterView(params: ReceiptParams): View { + return verticalSection(params) { + // Merchant-configured receipt footer text from the v3 merchant's receipt properties. + val footer = receiptProperty(params, "customFooter") ?: "Thank you!" + addView(centeredText(footer, params)) + addView(centeredText("Printed by ${params.printJob.callerPackageName ?: context.packageName}", params, sizePx = TEXT_SIZE_SMALL)) + } + } + + /** Reduced-rate legend follows the configured footer in the SMCC layout. */ + private fun generateReducedTaxNoteView(params: ReceiptParams): View? { + if (params.order?.lineItems?.any(::isReducedTaxRate) != true) return null + return verticalSection(params) { + addView(centeredText("$REDUCED_TAX_MARKER Reduced tax rate item", params)) + } + } + + /** Tax registration, register and staff fields appear near the bottom of the SMCC layout. */ + private fun generateMerchantAndTerminalInfoView(params: ReceiptParams): View? { + val section = verticalSection(params) { + getBusinessRegistrationNumber(params).takeIf { it.isNotEmpty() }?.let { + addView(row("Registration", it, params)) + } + params.device?.let { device -> + (device.name ?: device.serial)?.let { addView(row("Register", it, params)) } + } + params.employee?.let { employee -> + val staffNumber = employee.customId ?: employee.nickname ?: employee.name + staffNumber?.let { addView(row("Staff", it, params)) } + } + } + return section.takeIf { it.childCount > 0 } + } + + /** Transaction number is near the bottom rather than inside the tender block. */ + private fun generateTransactionIdentifierView(params: ReceiptParams): View? { + val section = verticalSection(params) { + params.payment?.id?.let { addView(row("Transaction", it, params)) } + params.refund?.id?.let { addView(row("Refund ID", it, params)) } + } + return section.takeIf { it.childCount > 0 } + } + + /** Order title is the SMCC call/order number printed at the end of a sale receipt. */ + private fun generateOrderNumberView(params: ReceiptParams): View? { + val orderNumber = params.order?.title?.takeIf { it.isNotEmpty() } ?: return null + return verticalSection(params) { + addView(row("Order No.", orderNumber, params)) + } + } + + /** + * Reads one key from the merchant's receipt properties — a JSON string on + * `MerchantProperties.getReceiptProperties()`. The receipt header text is under + * `storeHeadline` and the footer text under `customFooter`. + */ + private fun receiptProperty(params: ReceiptParams, key: String): String? { + val json = params.merchant?.properties?.receiptProperties ?: return null + return kotlin.runCatching { JSONObject(json).optString(key).takeIf { it.isNotEmpty() } } + .getOrNull() + } + + /** + * Renders a built receipt view to bitmap chunks: measure at exactly [widthPx], lay out, then + * draw 2048px-tall (max) windows into RGB_565 bitmaps — the same chunking contract the + * native bitmap providers follow. + */ + fun renderToChunks(view: View, widthPx: Int, maxChunkHeight: Int): List { + view.measure( + View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + view.layout(0, 0, view.measuredWidth, view.measuredHeight) + + val totalHeight = view.measuredHeight.coerceAtLeast(1) + val chunks = mutableListOf() + var top = 0 + while (top < totalHeight) { + val chunkHeight = min(maxChunkHeight, totalHeight - top) + val bitmap = Bitmap.createBitmap(widthPx, chunkHeight, Bitmap.Config.RGB_565) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.WHITE) + canvas.translate(0f, -top.toFloat()) + view.draw(canvas) + chunks.add(bitmap) + top += chunkHeight + } + return chunks + } + + private fun isReducedTaxRate(line: LineItem): Boolean = + line.taxRates?.any { + //This is the expected attribute on a tax-exempt item, but it's not currently populated in the sandbox or on real orders. + //Will be part of the next SDK release. For now, we can only check that the tax rate is 8%. + + //it.systemTaxRate.labelKey == REDUCED_TAX_RATE_LABEL_KEY + 800000L == it.rate + } == true + + private fun isTaxExcept(line: LineItem): Boolean = + line.taxRates?.any { + //This is the expected attribute on a tax-exempt item, but it's not currently populated in the sandbox or on real orders. + //Will be part of the next SDK release. For now, we can only check that the tax rate is zero, which is a necessary but not sufficient condition for tax exemption. + + //it.systemTaxRate.labelKey == NO_TAX_RATE_TAX_TYPE_LABEL_KEY + 0L == it.rate + } == true + + /** + * Formats an amount in the merchant's currency. Clover amounts are expressed in the + * currency's minor unit (e.g. 1234 == $12.34 for USD; currencies without minor units are + * not scaled), so scale by the currency's default fraction digits. + * + * The v3 merchant carries these as strings: [Merchant.getDefaultCurrency] is an ISO 4217 + * code (e.g. "USD") and the properties' locale a language tag (e.g. "en-US"). + */ + private fun formatAmount(amount: Long?, merchant: Merchant?): String { + if (amount == null) return "" + val locale = merchant?.properties?.locale + ?.let { Locale.forLanguageTag(it.replace('_', '-')) } + ?: Locale.getDefault() + val currency = (merchant?.defaultCurrency ?: merchant?.properties?.defaultCurrency) + ?.let { runCatching { Currency.getInstance(it) }.getOrNull() } + ?: Currency.getInstance(Locale.getDefault()) + val format = NumberFormat.getCurrencyInstance(locale).apply { this.currency = currency } + return format.format(amount / 10.0.pow(currency.defaultFractionDigits)) + } + + private fun trimQty(value: Double): String = + if (value == value.toLong().toDouble()) value.toLong().toString() else value.toString() + + private fun divider(params: ReceiptParams): View = View(context).apply { + setBackgroundColor(Color.BLACK) + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 2).apply { + val margin = (TEXT_SIZE_MEDIUM / 2).toInt() + topMargin = margin + bottomMargin = margin + } + } + + private fun verticalSection(params: ReceiptParams, build: LinearLayout.() -> Unit): LinearLayout = + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, (TEXT_SIZE_MEDIUM / 2).toInt(), 0, (TEXT_SIZE_MEDIUM / 2).toInt()) + build() + } + + private fun row( + label: String, + value: String?, + params: ReceiptParams, + bold: Boolean = false, + sizePx: Float = TEXT_SIZE_MEDIUM + ): View = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + addView(text(label, params, bold, sizePx).apply { + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + }) + value?.let { + addView(text(it, params, bold, sizePx).apply { gravity = Gravity.END }) + } + } + + private fun centeredText(value: String, params: ReceiptParams, bold: Boolean = false, sizePx: Float = TEXT_SIZE_MEDIUM): TextView = + text(value, params, bold, sizePx).apply { + gravity = Gravity.CENTER_HORIZONTAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT + ) + } + + private fun text(value: String, params: ReceiptParams, bold: Boolean = false, sizePx: Float = TEXT_SIZE_MEDIUM): TextView = + TextView(context).apply { + text = value + setTextColor(Color.BLACK) + typeface = receiptTypeface(params, bold) + setTextSize(TypedValue.COMPLEX_UNIT_PX, sizePx) + // Match the native renderer: no font padding, and no anti-aliasing — gray AA edges + // dither into fuzzy dots on a thermal printer. + includeFontPadding = false + paint.isAntiAlias = false + } + + /** + * The native receipt clover renders 384-dot receipts in Roboto Condensed and wider + * (576-dot) receipts in the default font; "sans-serif-condensed" is the system Roboto + * Condensed family. + */ + private fun receiptTypeface(params: ReceiptParams, bold: Boolean): Typeface = + if (params.receiptWidth > 384) { + Typeface.defaultFromStyle(if (bold) Typeface.BOLD else Typeface.NORMAL) + } else { + Typeface.create("sans-serif-condensed", if (bold) Typeface.BOLD else Typeface.NORMAL) + } + + /** + * Returns total guest count for an order where each distinct, non-empty binName represents one guest. + */ + fun getGuestCountByBinName(order: Order?): Int { + if (order == null || order.lineItems.isNullOrEmpty()) { + return 0 + } + return getGuestCountByBinName(order.lineItems) + } + + /** + * Returns true if the print job is eligible for stamp duty, based on the FLAG_STAMP_DUTY_ELIGIBLE flag. + */ + fun isStampDutyEligible(params: ReceiptParams): Boolean { + val flags = params.printJob.flags + return flags and PrintJob.FLAG_STAMP_DUTY_ELIGIBLE == PrintJob.FLAG_STAMP_DUTY_ELIGIBLE + } + + /** + * Returns true if the print job is an RSS receipt, based on the FLAG_PRINT_RSS_RECEIPT flag. + */ + fun isRSSRReceipt(params: ReceiptParams): Boolean { + val flags = params.printJob.flags + return flags and PrintJob.FLAG_PRINT_RSS_RECEIPT == PrintJob.FLAG_PRINT_RSS_RECEIPT + } + + /** + * Returns the Japanese business registration number (BRN) from the merchant's gateway, or an + * empty string if not available. + */ + fun getBusinessRegistrationNumber(params: ReceiptParams): String { + return params.merchant?.gateway?.brn ?: "" + } + + /** + * Returns total guest count from a list of line items, using distinct non-empty binName values. + */ + fun getGuestCountByBinName(lineItems: List?): Int { + if (lineItems.isNullOrEmpty()) { + return 0 + } + + val guestBins = mutableSetOf() + lineItems.forEach { lineItem -> + val normalizedBinName = lineItem.binName?.trim() + if (!normalizedBinName.isNullOrEmpty()) { + guestBins.add(normalizedBinName) + } + } + + return guestBins.size + } +} diff --git a/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/vas/VasReaderStatusActivity.kt b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/vas/VasReaderStatusActivity.kt new file mode 100644 index 0000000000..909238de6a --- /dev/null +++ b/clover-android-sdk-examples/src/main/java/com/clover/android/sdk/examples/vas/VasReaderStatusActivity.kt @@ -0,0 +1,264 @@ +package com.clover.android.sdk.examples.vas + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.clover.android.sdk.examples.R +import com.clover.sdk.v3.payments.IVasProvider +import com.clover.sdk.v3.payments.VasMode +import com.clover.sdk.v3.payments.VasPayload +import com.clover.sdk.v3.payments.VasPayloadResponse +import com.clover.sdk.v3.payments.VasPayloadResponseType +import com.clover.sdk.v3.payments.VasProtocol +import com.clover.sdk.v3.payments.VasServiceProvider +import com.clover.sdk.v3.payments.VasProviderConfig +import com.clover.sdk.v3.payments.VasSettings +import com.clover.sdk.v3.vas.connector.VasReaderClient +import com.clover.sdk.v3.vas.listener.IVasReaderClientListener +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class VasReaderStatusActivity : AppCompatActivity() { + + private var isVasServiceConnected = false + private var isVasServiceConnecting = false + private var isVasReading = false + private lateinit var vasStatusText: TextView + private lateinit var passTypeIdsInput: EditText + private lateinit var vasClientBtn: Button + private lateinit var vasReadBtn: Button + + private val vasReaderClient by lazy { VasReaderClient.getInstance(application) } + + private val vasProvider = object : IVasProvider.Stub() { + override fun handlePayload( + payload: VasPayload?, + vasMode: VasMode?, + extras: Intent? + ): VasPayloadResponse { + return VasPayloadResponse().setResponseType(VasPayloadResponseType.ACCEPTED) + } + + override fun getVasProviders(): MutableList = buildVasProviders() + } + + private val readerClientListener = object : IVasReaderClientListener { + override fun onConnect() { + isVasServiceConnecting = false + isVasServiceConnected = true + updateStatus("Client connected. Tap Start VAS Read.") + updateClientButton() + updateReadButtonEnabled(true) + updateReadButton(false) + } + + override fun onDisconnect() { + isVasServiceConnecting = false + isVasServiceConnected = false + updateStatus("Client disconnected") + updateClientButton() + updateReadButtonEnabled(false) + updateReadButton(false) + } + + override fun onUserInterventionRequired() { + updateStatus("User action required. Please follow the device prompt.") + } + + override fun onUserInterventionCleared() { + updateStatus("User action cleared. Continue reading.") + } + + override fun onVasReadTimeout() { + stopVasRead("VAS session timed out. Please start VAS read again.") + } + + override fun onConnectFailed() { + updateStatus("Connect failed immediately") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_vas_reader_status) + + vasStatusText = findViewById(R.id.text_vas_status) + passTypeIdsInput = findViewById(R.id.edit_pass_type_ids) + + vasClientBtn = findViewById(R.id.button_connect_client) + vasClientBtn.setOnClickListener { + if (isVasServiceConnected || isVasServiceConnecting) { + disconnectClient() + } else { + connectClient() + } + } + + vasReadBtn = findViewById