From a64343d702a8743f8d85e6980a0d47796980dfed Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 17 Dec 2024 09:34:55 +0000 Subject: [PATCH 1/4] Invoke geolocation callback by default --- README.md | 2 +- .../CheckoutEventProcessor.kt | 48 ++++++++++++++++++- .../DefaultCheckoutEventProcessorTest.kt | 44 +++++++++++++---- .../MainActivity.kt | 37 +++++--------- .../common/MobileBuyEventProcessor.kt | 3 +- 5 files changed, 98 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 6a72b032..230bc8e2 100644 --- a/README.md +++ b/README.md @@ -351,7 +351,7 @@ val processor = object : DefaultCheckoutEventProcessor(activity) { ``` > [!Note] -> The `DefaultCheckoutEventProcessor` provides default implementations for current and future callback functions (such as `onLinkClicked()`), which can be overridden by clients wanting to change default behavior. +> The `DefaultCheckoutEventProcessor` provides default implementations for current and future callback functions (such as `onCheckoutLinkClicked()`), which can be overridden by clients wanting to change default behavior. ### Error handling diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutEventProcessor.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutEventProcessor.kt index e01295ac..44d0dc09 100644 --- a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutEventProcessor.kt +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutEventProcessor.kt @@ -22,9 +22,11 @@ */ package com.shopify.checkoutsheetkit +import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import android.webkit.GeolocationPermissions import android.webkit.PermissionRequest @@ -141,6 +143,11 @@ public abstract class DefaultCheckoutEventProcessor @JvmOverloads constructor( private val log: LogWrapper = LogWrapper(), ) : CheckoutEventProcessor { + private val LOCATION_PERMISSIONS: Array = arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + override fun onCheckoutLinkClicked(uri: Uri) { when (uri.scheme) { "tel" -> context.launchPhoneApp(uri.schemeSpecificPart) @@ -166,14 +173,51 @@ public abstract class DefaultCheckoutEventProcessor @JvmOverloads constructor( return false } - override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) { - // no-op override to implement + /** + * Called when the webview requests location permissions. For example when using 'Use my location' to locate pickup points. + * The default implementation here will check for both manifest and runtime permissions. If both have been granted, + * permission will be granted to present the location prompt to the user. + * + * Runtime permissions must be requested by your host app. The Checkout Sheet kit cannot request them on your behalf. + */ + override fun onGeolocationPermissionsShowPrompt( + origin: String, + callback: GeolocationPermissions.Callback + ) { + // Check manifest permissions + val manifestPermissions = try { + context.packageManager + .getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + .requestedPermissions + ?.toSet() ?: emptySet() + } catch (e: Exception) { + emptySet() + } + + // Check if either permission is declared in manifest + val hasManifestPermission = LOCATION_PERMISSIONS.any { permission -> + manifestPermissions.contains(permission) + } + + if (!hasManifestPermission) { + callback.invoke(origin, false, false) + return + } + + // Check runtime permissions + val hasRuntimePermission = LOCATION_PERMISSIONS.any { permission -> + context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED + } + + callback.invoke(origin, hasRuntimePermission, hasRuntimePermission) } override fun onGeolocationPermissionsHidePrompt() { // no-op override to implement } + // Private + private fun Context.launchEmailApp(to: String) { val intent = Intent(Intent.ACTION_SEND) intent.type = "vnd.android.cursor.item/email" diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt index c0327579..2de960ad 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt @@ -1,18 +1,18 @@ /* * MIT License - * + * * Copyright 2023-present, Shopify Inc. - * + * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: - * + * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. - * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -26,6 +26,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.net.Uri +import android.webkit.GeolocationPermissions import androidx.activity.ComponentActivity import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent import com.shopify.checkoutsheetkit.pixelevents.PixelEvent @@ -33,7 +34,9 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner @@ -46,6 +49,7 @@ class DefaultCheckoutEventProcessorTest { private lateinit var activity: ComponentActivity private lateinit var shadowActivity: ShadowActivity + private val mockCallback = mock() @Before fun setUp() { @@ -164,12 +168,36 @@ class DefaultCheckoutEventProcessorTest { assertThat(recoverable).isTrue() } + @Test + fun `onGeolocationPermissionsShowPrompt should invoke callback with correct args`() { + val origin = "http://shopify.com" + val processor = object: DefaultCheckoutEventProcessor(mock(), mock()) { + override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {} + override fun onCheckoutFailed(error: CheckoutException) {} + override fun onCheckoutCanceled() {} + } + processor.onGeolocationPermissionsShowPrompt(origin, mockCallback) + verify(mockCallback).invoke(origin, true, true) + } + + @Test + fun `onGeolocationPermissionsShowPrompt should not invoke callback if overridden with no-op`() { + val overriddenProcessor = object: DefaultCheckoutEventProcessor(mock(), mock()) { + override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {} + override fun onCheckoutFailed(error: CheckoutException) {} + override fun onCheckoutCanceled() {} + override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) {} + } + overriddenProcessor.onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback) + verify(mockCallback, times(0)).invoke(any(), any(), any()) + } + private fun processor(activity: ComponentActivity): DefaultCheckoutEventProcessor { return object: DefaultCheckoutEventProcessor(activity) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {/* not implemented */} - override fun onCheckoutFailed(error: CheckoutException) {/* not implemented */} - override fun onCheckoutCanceled() {/* not implemented */} - override fun onWebPixelEvent(event: PixelEvent) {/* not implemented */} + override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {} + override fun onCheckoutFailed(error: CheckoutException) {} + override fun onCheckoutCanceled() {} + override fun onWebPixelEvent(event: PixelEvent) {} } } } diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/MainActivity.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/MainActivity.kt index 9898c8e9..f2685a0f 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/MainActivity.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/MainActivity.kt @@ -25,6 +25,7 @@ package com.shopify.checkout_sdk_mobile_buy_integration_sample import android.Manifest import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.os.Bundle import android.webkit.GeolocationPermissions import android.webkit.ValueCallback @@ -42,15 +43,14 @@ class MainActivity : ComponentActivity() { // Launchers private lateinit var requestPermissionLauncher: ActivityResultLauncher private lateinit var showFileChooserLauncher: ActivityResultLauncher - private lateinit var geolocationLauncher: ActivityResultLauncher> // State related to file chooser requests (e.g. for using a file chooser/camera for proving identity) private var filePathCallback: ValueCallback>? = null private var fileChooserParams: FileChooserParams? = null - // State related to geolocation requests (e.g. for pickup points - use my location) - private var geolocationPermissionCallback: GeolocationPermissions.Callback? = null - private var geolocationOrigin: String? = null + companion object { + private const val LOCATION_PERMISSION_REQUEST_CODE = 1001 + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -84,15 +84,17 @@ class MainActivity : ComponentActivity() { filePathCallback = null fileChooserParams = null } + } - geolocationLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> - val isGranted = result.any { it.value } - // invoke the callback with the permission result - geolocationPermissionCallback?.invoke(geolocationOrigin, isGranted, false) + fun requestGeolocationPermission() { + val fineLocationGranted = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + val coarseLocationGranted = checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED - // reset geolocation state - geolocationPermissionCallback = null - geolocationOrigin = null + if (!fineLocationGranted && !coarseLocationGranted) { + requestPermissions( + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), + LOCATION_PERMISSION_REQUEST_CODE + ) } } @@ -111,19 +113,6 @@ class MainActivity : ComponentActivity() { return true } - // Deal with requests from Checkout to show the geolocation permissions prompt - fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) { - if (permissionAlreadyGranted(Manifest.permission.ACCESS_FINE_LOCATION) && permissionAlreadyGranted(Manifest.permission.ACCESS_COARSE_LOCATION)) { - // Permissions already granted, invoke callback immediately - callback(origin, true, true) - } else { - // Permissions not yet granted, request permissions before invoking callback - geolocationPermissionCallback = callback - geolocationOrigin = origin - geolocationLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) - } - } - private fun permissionAlreadyGranted(permission: String): Boolean { return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED } diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt index 7e0153b9..919dbfcb 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/MobileBuyEventProcessor.kt @@ -85,7 +85,8 @@ class MobileBuyEventProcessor( } override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) { - return (context as MainActivity).onGeolocationPermissionsShowPrompt(origin, callback) + (context as MainActivity).requestGeolocationPermission() + super.onGeolocationPermissionsShowPrompt(origin, callback) } override fun onShowFileChooser( From 8dd95b05bee3a972b2501d25cbc5daed6970c43c Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 17 Dec 2024 11:52:50 +0000 Subject: [PATCH 2/4] Update tests --- .../DefaultCheckoutEventProcessorTest.kt | 157 +++++++++++++----- 1 file changed, 119 insertions(+), 38 deletions(-) diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt index 2de960ad..78f1b95b 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt @@ -22,7 +22,10 @@ */ package com.shopify.checkoutsheetkit +import android.Manifest +import android.content.Context import android.content.Intent +import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.net.Uri @@ -34,10 +37,9 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.* import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment @@ -47,14 +49,22 @@ import org.robolectric.shadows.ShadowActivity @RunWith(RobolectricTestRunner::class) class DefaultCheckoutEventProcessorTest { + private lateinit var context: Context private lateinit var activity: ComponentActivity private lateinit var shadowActivity: ShadowActivity private val mockCallback = mock() + private lateinit var processor: TestCheckoutEventProcessor + private lateinit var packageManager: PackageManager @Before fun setUp() { + MockitoAnnotations.openMocks(this) activity = Robolectric.buildActivity(ComponentActivity::class.java).get() shadowActivity = shadowOf(activity) + context = mock() + packageManager = mock() + `when`(context.packageManager).thenReturn(packageManager) + processor = TestCheckoutEventProcessor(context) } @Test @@ -77,7 +87,8 @@ class DefaultCheckoutEventProcessorTest { processor.onCheckoutLinkClicked(uri) val intent = shadowActivity.peekNextStartedActivityForResult().intent - assertThat(intent.getStringArrayExtra(Intent.EXTRA_EMAIL)).isEqualTo(arrayOf("test.user@shopify.com")) + assertThat(intent.getStringArrayExtra(Intent.EXTRA_EMAIL)) + .isEqualTo(arrayOf("test.user@shopify.com")) assertThat(intent.action).isEqualTo("android.intent.action.SEND") } @@ -105,12 +116,23 @@ class DefaultCheckoutEventProcessorTest { val shadowPackageManager = shadowOf(pm) shadowPackageManager.addResolveInfoForIntent(expectedIntent, ResolveInfo()) - val processor = object: DefaultCheckoutEventProcessor(activity, log) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {/* not implemented */} - override fun onCheckoutFailed(error: CheckoutException) {/* not implemented */} - override fun onCheckoutCanceled() {/* not implemented */} - override fun onWebPixelEvent(event: PixelEvent) {/* not implemented */} - } + val processor = + object : DefaultCheckoutEventProcessor(activity, log) { + override fun onCheckoutCompleted( + checkoutCompletedEvent: CheckoutCompletedEvent + ) { + /* not implemented */ + } + override fun onCheckoutFailed(error: CheckoutException) { + /* not implemented */ + } + override fun onCheckoutCanceled() { + /* not implemented */ + } + override fun onWebPixelEvent(event: PixelEvent) { + /* not implemented */ + } + } processor.onCheckoutLinkClicked(uri) @@ -122,19 +144,34 @@ class DefaultCheckoutEventProcessorTest { @Test fun `onCheckoutLinkedClick with unhandled scheme logs warning`() { val log = mock() - val processor = object: DefaultCheckoutEventProcessor(activity, log) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {/* not implemented */} - override fun onCheckoutFailed(error: CheckoutException) {/* not implemented */} - override fun onCheckoutCanceled() {/* not implemented */} - override fun onWebPixelEvent(event: PixelEvent) {/* not implemented */} - } + val processor = + object : DefaultCheckoutEventProcessor(activity, log) { + override fun onCheckoutCompleted( + checkoutCompletedEvent: CheckoutCompletedEvent + ) { + /* not implemented */ + } + override fun onCheckoutFailed(error: CheckoutException) { + /* not implemented */ + } + override fun onCheckoutCanceled() { + /* not implemented */ + } + override fun onWebPixelEvent(event: PixelEvent) { + /* not implemented */ + } + } val uri = Uri.parse("ftp:random") processor.onCheckoutLinkClicked(uri) assertThat(shadowActivity.peekNextStartedActivityForResult()).isNull() - verify(log).w("DefaultCheckoutEventProcessor", "Unrecognized scheme for link clicked in checkout 'ftp:random'") + verify(log) + .w( + "DefaultCheckoutEventProcessor", + "Unrecognized scheme for link clicked in checkout 'ftp:random'" + ) } @Test @@ -144,7 +181,9 @@ class DefaultCheckoutEventProcessorTest { var recoverable: Boolean? = null val processor = object : DefaultCheckoutEventProcessor(activity, log) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { + override fun onCheckoutCompleted( + checkoutCompletedEvent: CheckoutCompletedEvent + ) { /* not implemented */ } override fun onCheckoutFailed(error: CheckoutException) { @@ -169,35 +208,77 @@ class DefaultCheckoutEventProcessorTest { } @Test - fun `onGeolocationPermissionsShowPrompt should invoke callback with correct args`() { - val origin = "http://shopify.com" - val processor = object: DefaultCheckoutEventProcessor(mock(), mock()) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {} - override fun onCheckoutFailed(error: CheckoutException) {} - override fun onCheckoutCanceled() {} - } - processor.onGeolocationPermissionsShowPrompt(origin, mockCallback) - verify(mockCallback).invoke(origin, true, true) + fun testOnGeolocationPermissionsShowPrompt_withNoManifestPermission() { + // Simulate no permissions declared in the manifest + whenever(packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)) + .thenThrow(PackageManager.NameNotFoundException()) + processor.onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback) + verify(mockCallback).invoke("http://shopify.com", false, false) } @Test - fun `onGeolocationPermissionsShowPrompt should not invoke callback if overridden with no-op`() { - val overriddenProcessor = object: DefaultCheckoutEventProcessor(mock(), mock()) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {} - override fun onCheckoutFailed(error: CheckoutException) {} - override fun onCheckoutCanceled() {} - override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) {} - } - overriddenProcessor.onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback) - verify(mockCallback, times(0)).invoke(any(), any(), any()) + fun testOnGeolocationPermissionsShowPrompt_withManifestPermissionGranted() { + // Simulate permissions declared in the manifest + val permissions = + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + whenever(packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)) + .thenReturn(mockPackageInfo(permissions)) + + // Simulate runtime permission granted + whenever(context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)) + .thenReturn(PackageManager.PERMISSION_GRANTED) + whenever(context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) + .thenReturn(PackageManager.PERMISSION_GRANTED) + + processor.onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback) + + verify(mockCallback).invoke("http://shopify.com", true, true) } + @Test + fun testOnGeolocationPermissionsShowPrompt_withManifestPermissionDenied() { + // Simulate permissions declared in the manifest + val permissions = + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + whenever(packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)) + .thenReturn(mockPackageInfo(permissions)) + + // Simulate runtime permission denied + whenever(context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)) + .thenReturn(PackageManager.PERMISSION_DENIED) + whenever(context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) + .thenReturn(PackageManager.PERMISSION_DENIED) + + processor.onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback) + + verify(mockCallback).invoke("http://shopify.com", false, false) + } + + // Private + private fun processor(activity: ComponentActivity): DefaultCheckoutEventProcessor { - return object: DefaultCheckoutEventProcessor(activity) { + return object : DefaultCheckoutEventProcessor(activity) { override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {} override fun onCheckoutFailed(error: CheckoutException) {} override fun onCheckoutCanceled() {} override fun onWebPixelEvent(event: PixelEvent) {} } } + + private fun mockPackageInfo(permissions: Array): PackageInfo { + return PackageInfo().apply { requestedPermissions = permissions } + } + + private class TestCheckoutEventProcessor(context: Context) : + DefaultCheckoutEventProcessor(context) { + override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {} + override fun onCheckoutFailed(error: CheckoutException) {} + override fun onCheckoutCanceled() {} + } } From c5bc2cca1c2c5b98b92c30e87f5308595bba1949 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 17 Dec 2024 12:50:55 +0000 Subject: [PATCH 3/4] permissionGranted helper --- .../MainActivity.kt | 72 ++++++++++--------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/MainActivity.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/MainActivity.kt index f2685a0f..58bcc029 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/MainActivity.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/MainActivity.kt @@ -1,18 +1,18 @@ /* * MIT License - * + * * Copyright 2023-present, Shopify Inc. - * + * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: - * + * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. - * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE @@ -25,9 +25,7 @@ package com.shopify.checkout_sdk_mobile_buy_integration_sample import android.Manifest import android.content.pm.PackageManager import android.net.Uri -import android.os.Build import android.os.Bundle -import android.webkit.GeolocationPermissions import android.webkit.ValueCallback import android.webkit.WebChromeClient.FileChooserParams import android.webkit.WebView.setWebContentsDebuggingEnabled @@ -35,7 +33,6 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat import timber.log.Timber import timber.log.Timber.DebugTree @@ -44,7 +41,8 @@ class MainActivity : ComponentActivity() { private lateinit var requestPermissionLauncher: ActivityResultLauncher private lateinit var showFileChooserLauncher: ActivityResultLauncher - // State related to file chooser requests (e.g. for using a file chooser/camera for proving identity) + // State related to file chooser requests (e.g. for using a file chooser/camera for proving + // identity) private var filePathCallback: ValueCallback>? = null private var fileChooserParams: FileChooserParams? = null @@ -63,45 +61,53 @@ class MainActivity : ComponentActivity() { Timber.plant(DebugTree()) } - setContent { - CheckoutSdkApp() - } + setContent { CheckoutSdkApp() } - requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> - val fileChooserParams = this.fileChooserParams - if (isGranted && fileChooserParams != null) { - this.showFileChooserLauncher.launch(fileChooserParams) - this.fileChooserParams = null - } - // N.B. a file chooser intent (without camera) could be launched here if the permission was denied - } + requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted + -> + val fileChooserParams = this.fileChooserParams + if (isGranted && fileChooserParams != null) { + this.showFileChooserLauncher.launch(fileChooserParams) + this.fileChooserParams = null + } + // N.B. a file chooser intent (without camera) could be launched here if the + // permission was denied + } - showFileChooserLauncher = registerForActivityResult(FileChooserResultContract()) { uri: Uri? -> - // invoke the callback with the selected file - filePathCallback?.onReceiveValue(if (uri != null) arrayOf(uri) else null) + showFileChooserLauncher = + registerForActivityResult(FileChooserResultContract()) { uri: Uri? -> + // invoke the callback with the selected file + filePathCallback?.onReceiveValue(if (uri != null) arrayOf(uri) else null) - // reset fileChooser state - filePathCallback = null - fileChooserParams = null - } + // reset fileChooser state + filePathCallback = null + fileChooserParams = null + } } fun requestGeolocationPermission() { - val fineLocationGranted = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED - val coarseLocationGranted = checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + val fineLocationGranted = permissionGranted(Manifest.permission.ACCESS_FINE_LOCATION) + val coarseLocationGranted = permissionGranted(Manifest.permission.ACCESS_COARSE_LOCATION) if (!fineLocationGranted && !coarseLocationGranted) { requestPermissions( - arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ), LOCATION_PERMISSION_REQUEST_CODE ) } } // Show a file chooser when prompted by the event processor - fun onShowFileChooser(filePathCallback: ValueCallback>, fileChooserParams: FileChooserParams): Boolean { + fun onShowFileChooser( + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams + ): Boolean { this.filePathCallback = filePathCallback - if (permissionAlreadyGranted(Manifest.permission.CAMERA)) { + if (permissionGranted(Manifest.permission.CAMERA)) { // Permissions already granted, launch chooser immediately showFileChooserLauncher.launch(fileChooserParams) this.fileChooserParams = null @@ -113,7 +119,7 @@ class MainActivity : ComponentActivity() { return true } - private fun permissionAlreadyGranted(permission: String): Boolean { - return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + private fun permissionGranted(permission: String): Boolean { + return checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED } } From 94bdbda9b0ddd6929de5fa7ef845b83640b3ac41 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 17 Dec 2024 13:51:19 +0000 Subject: [PATCH 4/4] Update tests --- .../CheckoutEventProcessor.kt | 154 ++++++++++-------- .../DefaultCheckoutEventProcessorTest.kt | 117 ++++--------- 2 files changed, 123 insertions(+), 148 deletions(-) diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutEventProcessor.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutEventProcessor.kt index 44d0dc09..6a0a44d9 100644 --- a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutEventProcessor.kt +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutEventProcessor.kt @@ -37,99 +37,111 @@ import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent import com.shopify.checkoutsheetkit.pixelevents.PixelEvent /** - * Interface to implement to allow responding to lifecycle events in checkout. - * We'd strongly recommend extending DefaultCheckoutEventProcessor where possible + * Interface to implement to allow responding to lifecycle events in checkout. We'd strongly + * recommend extending DefaultCheckoutEventProcessor where possible */ public interface CheckoutEventProcessor { - /** - * Event representing the successful completion of a checkout. - */ + /** Event representing the successful completion of a checkout. */ public fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) /** - * Event representing an error that occurred during checkout. This can be used to display - * error messages for example. + * Event representing an error that occurred during checkout. This can be used to display error + * messages for example. * - * @param error - the CheckoutErrorException that occurred + * @param error + * - the CheckoutErrorException that occurred * @see Exception */ public fun onCheckoutFailed(error: CheckoutException) - /** - * Event representing the cancellation/closing of checkout by the buyer - */ + /** Event representing the cancellation/closing of checkout by the buyer */ public fun onCheckoutCanceled() /** * Event indicating that a link has been clicked within checkout that should be opened outside - * of the WebView, e.g. in a system browser or email client. Protocols can be http/https/mailto/tel + * of the WebView, e.g. in a system browser or email client. Protocols can be + * http/https/mailto/tel */ public fun onCheckoutLinkClicked(uri: Uri) - /** - * A permission has been requested by the web chrome client, e.g. to access the camera - */ + /** A permission has been requested by the web chrome client, e.g. to access the camera */ public fun onPermissionRequest(permissionRequest: PermissionRequest) /** - * Web Pixel event emitted from checkout, that can be optionally transformed, enhanced (e.g. with user and session identifiers), - * and processed + * Web Pixel event emitted from checkout, that can be optionally transformed, enhanced (e.g. + * with user and session identifiers), and processed */ public fun onWebPixelEvent(event: PixelEvent) /** - * Called when the client should show a file chooser. This is called to handle HTML forms with 'file' input type, in response to the - * user pressing the "Select File" button. To cancel the request, call filePathCallback.onReceiveValue(null) and return true. + * Called when the client should show a file chooser. This is called to handle HTML forms with + * 'file' input type, in response to the user pressing the "Select File" button. To cancel the + * request, call filePathCallback.onReceiveValue(null) and return true. */ public fun onShowFileChooser( - webView: WebView, - filePathCallback: ValueCallback>, - fileChooserParams: WebChromeClient.FileChooserParams, + webView: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: WebChromeClient.FileChooserParams, ): Boolean /** - * Called when the client should show a location permissions prompt. For example when using 'Use my location' for - * pickup points in checkout + * Called when the client should show a location permissions prompt. For example when using 'Use + * my location' for pickup points in checkout */ - public fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) + public fun onGeolocationPermissionsShowPrompt( + origin: String, + callback: GeolocationPermissions.Callback + ) /** - * Called when the client should hide the location permissions prompt, e.g. if th request is cancelled + * Called when the client should hide the location permissions prompt, e.g. if th request is + * cancelled */ public fun onGeolocationPermissionsHidePrompt() } internal class NoopEventProcessor : CheckoutEventProcessor { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {/* noop */ + override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { + /* noop */ } - override fun onCheckoutFailed(error: CheckoutException) {/* noop */ + override fun onCheckoutFailed(error: CheckoutException) { + /* noop */ } - override fun onCheckoutCanceled() {/* noop */ + override fun onCheckoutCanceled() { + /* noop */ } - override fun onCheckoutLinkClicked(uri: Uri) {/* noop */ + override fun onCheckoutLinkClicked(uri: Uri) { + /* noop */ } - override fun onWebPixelEvent(event: PixelEvent) {/* noop */ + override fun onWebPixelEvent(event: PixelEvent) { + /* noop */ } override fun onShowFileChooser( - webView: WebView, - filePathCallback: ValueCallback>, - fileChooserParams: WebChromeClient.FileChooserParams, + webView: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: WebChromeClient.FileChooserParams, ): Boolean { return false } - override fun onPermissionRequest(permissionRequest: PermissionRequest) {/* noop */ + override fun onPermissionRequest(permissionRequest: PermissionRequest) { + /* noop */ } - override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) {/* noop */ + override fun onGeolocationPermissionsShowPrompt( + origin: String, + callback: GeolocationPermissions.Callback + ) { + /* noop */ } - override fun onGeolocationPermissionsHidePrompt() {/* noop */ + override fun onGeolocationPermissionsHidePrompt() { + /* noop */ } } @@ -138,15 +150,18 @@ internal class NoopEventProcessor : CheckoutEventProcessor { * for handling checkout events and interacting with the Android operating system. * @param context from which we will launch intents. */ -public abstract class DefaultCheckoutEventProcessor @JvmOverloads constructor( - private val context: Context, - private val log: LogWrapper = LogWrapper(), +public abstract class DefaultCheckoutEventProcessor +@JvmOverloads +constructor( + private val context: Context, + private val log: LogWrapper = LogWrapper(), ) : CheckoutEventProcessor { - private val LOCATION_PERMISSIONS: Array = arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - ) + private val locationPermissions: Array = + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) override fun onCheckoutLinkClicked(uri: Uri) { when (uri.scheme) { @@ -166,38 +181,42 @@ public abstract class DefaultCheckoutEventProcessor @JvmOverloads constructor( } override fun onShowFileChooser( - webView: WebView, - filePathCallback: ValueCallback>, - fileChooserParams: WebChromeClient.FileChooserParams, + webView: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: WebChromeClient.FileChooserParams, ): Boolean { return false } /** - * Called when the webview requests location permissions. For example when using 'Use my location' to locate pickup points. - * The default implementation here will check for both manifest and runtime permissions. If both have been granted, - * permission will be granted to present the location prompt to the user. + * Called when the webview requests location permissions. For example when using 'Use my + * location' to locate pickup points. The default implementation here will check for both + * manifest and runtime permissions. If both have been granted, permission will be granted to + * present the location prompt to the user. * - * Runtime permissions must be requested by your host app. The Checkout Sheet kit cannot request them on your behalf. + * Runtime permissions must be requested by your host app. The Checkout Sheet kit cannot request + * them on your behalf. */ override fun onGeolocationPermissionsShowPrompt( - origin: String, - callback: GeolocationPermissions.Callback + origin: String, + callback: GeolocationPermissions.Callback ) { // Check manifest permissions - val manifestPermissions = try { - context.packageManager - .getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) - .requestedPermissions - ?.toSet() ?: emptySet() - } catch (e: Exception) { - emptySet() - } + val manifestPermissions = + try { + context.packageManager + .getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + .requestedPermissions + ?.toSet() + ?: emptySet() + } catch (e: Exception) { + log.e(TAG, "Failed to get package permissions", e) + emptySet() + } // Check if either permission is declared in manifest - val hasManifestPermission = LOCATION_PERMISSIONS.any { permission -> - manifestPermissions.contains(permission) - } + val hasManifestPermission = + locationPermissions.any { permission -> manifestPermissions.contains(permission) } if (!hasManifestPermission) { callback.invoke(origin, false, false) @@ -205,9 +224,10 @@ public abstract class DefaultCheckoutEventProcessor @JvmOverloads constructor( } // Check runtime permissions - val hasRuntimePermission = LOCATION_PERMISSIONS.any { permission -> - context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED - } + val hasRuntimePermission = + locationPermissions.any { permission -> + context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED + } callback.invoke(origin, hasRuntimePermission, hasRuntimePermission) } diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt index 78f1b95b..d4b6dba9 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt @@ -32,14 +32,13 @@ import android.net.Uri import android.webkit.GeolocationPermissions import androidx.activity.ComponentActivity import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent -import com.shopify.checkoutsheetkit.pixelevents.PixelEvent import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito.`when` -import org.mockito.MockitoAnnotations -import org.mockito.kotlin.* +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.kotlin.whenever import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment @@ -53,18 +52,16 @@ class DefaultCheckoutEventProcessorTest { private lateinit var activity: ComponentActivity private lateinit var shadowActivity: ShadowActivity private val mockCallback = mock() - private lateinit var processor: TestCheckoutEventProcessor private lateinit var packageManager: PackageManager @Before fun setUp() { - MockitoAnnotations.openMocks(this) activity = Robolectric.buildActivity(ComponentActivity::class.java).get() shadowActivity = shadowOf(activity) context = mock() packageManager = mock() - `when`(context.packageManager).thenReturn(packageManager) - processor = TestCheckoutEventProcessor(context) + whenever(context.packageManager).thenReturn(packageManager) + whenever(context.packageName).thenReturn("com.test.package") } @Test @@ -81,10 +78,9 @@ class DefaultCheckoutEventProcessorTest { @Test fun `onCheckoutLinkClicked with mailto scheme launches email intent with to address`() { - val processor = processor(activity) val uri = Uri.parse("mailto:test.user@shopify.com") - processor.onCheckoutLinkClicked(uri) + processor(activity).onCheckoutLinkClicked(uri) val intent = shadowActivity.peekNextStartedActivityForResult().intent assertThat(intent.getStringArrayExtra(Intent.EXTRA_EMAIL)) @@ -94,10 +90,9 @@ class DefaultCheckoutEventProcessorTest { @Test fun `onCheckoutLinkClicked with tel scheme launches action dial intent with phone number`() { - val processor = processor(activity) val uri = Uri.parse("tel:0123456789") - processor.onCheckoutLinkClicked(uri) + processor(activity).onCheckoutLinkClicked(uri) val intent = shadowActivity.peekNextStartedActivityForResult().intent assertThat(intent.data).isEqualTo(uri) @@ -116,25 +111,7 @@ class DefaultCheckoutEventProcessorTest { val shadowPackageManager = shadowOf(pm) shadowPackageManager.addResolveInfoForIntent(expectedIntent, ResolveInfo()) - val processor = - object : DefaultCheckoutEventProcessor(activity, log) { - override fun onCheckoutCompleted( - checkoutCompletedEvent: CheckoutCompletedEvent - ) { - /* not implemented */ - } - override fun onCheckoutFailed(error: CheckoutException) { - /* not implemented */ - } - override fun onCheckoutCanceled() { - /* not implemented */ - } - override fun onWebPixelEvent(event: PixelEvent) { - /* not implemented */ - } - } - - processor.onCheckoutLinkClicked(uri) + processor(activity, log).onCheckoutLinkClicked(uri) val intent = shadowActivity.peekNextStartedActivityForResult().intent assertThat(intent.data).isEqualTo(uri) @@ -144,27 +121,9 @@ class DefaultCheckoutEventProcessorTest { @Test fun `onCheckoutLinkedClick with unhandled scheme logs warning`() { val log = mock() - val processor = - object : DefaultCheckoutEventProcessor(activity, log) { - override fun onCheckoutCompleted( - checkoutCompletedEvent: CheckoutCompletedEvent - ) { - /* not implemented */ - } - override fun onCheckoutFailed(error: CheckoutException) { - /* not implemented */ - } - override fun onCheckoutCanceled() { - /* not implemented */ - } - override fun onWebPixelEvent(event: PixelEvent) { - /* not implemented */ - } - } - val uri = Uri.parse("ftp:random") - processor.onCheckoutLinkClicked(uri) + processor(activity, log).onCheckoutLinkClicked(uri) assertThat(shadowActivity.peekNextStartedActivityForResult()).isNull() verify(log) @@ -180,23 +139,11 @@ class DefaultCheckoutEventProcessorTest { var description = "" var recoverable: Boolean? = null val processor = - object : DefaultCheckoutEventProcessor(activity, log) { - override fun onCheckoutCompleted( - checkoutCompletedEvent: CheckoutCompletedEvent - ) { - /* not implemented */ - } + object : TestCheckoutEventProcessor(activity, log) { override fun onCheckoutFailed(error: CheckoutException) { description = error.errorDescription recoverable = error.isRecoverable } - override fun onCheckoutCanceled() { - /* not implemented */ - } - - override fun onWebPixelEvent(event: PixelEvent) { - /* not implemented */ - } } val error = object : CheckoutUnavailableException("error description", "unknown", true) {} @@ -208,16 +155,16 @@ class DefaultCheckoutEventProcessorTest { } @Test - fun testOnGeolocationPermissionsShowPrompt_withNoManifestPermission() { + fun `invokes the callback with false for grant and retain arguments`() { // Simulate no permissions declared in the manifest whenever(packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)) .thenThrow(PackageManager.NameNotFoundException()) - processor.onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback) + processor(activity).onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback) verify(mockCallback).invoke("http://shopify.com", false, false) } @Test - fun testOnGeolocationPermissionsShowPrompt_withManifestPermissionGranted() { + fun `invokes the callback with true for grant and retain arguments when included in the manifest`() { // Simulate permissions declared in the manifest val permissions = arrayOf( @@ -233,13 +180,14 @@ class DefaultCheckoutEventProcessorTest { whenever(context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) .thenReturn(PackageManager.PERMISSION_GRANTED) - processor.onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback) + TestCheckoutEventProcessor(context) + .onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback) verify(mockCallback).invoke("http://shopify.com", true, true) } @Test - fun testOnGeolocationPermissionsShowPrompt_withManifestPermissionDenied() { + fun `invokes the callback with false for grant and retain arguments when not included in the manifest`() { // Simulate permissions declared in the manifest val permissions = arrayOf( @@ -255,30 +203,37 @@ class DefaultCheckoutEventProcessorTest { whenever(context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) .thenReturn(PackageManager.PERMISSION_DENIED) - processor.onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback) + TestCheckoutEventProcessor(context) + .onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback) verify(mockCallback).invoke("http://shopify.com", false, false) } // Private - private fun processor(activity: ComponentActivity): DefaultCheckoutEventProcessor { - return object : DefaultCheckoutEventProcessor(activity) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {} - override fun onCheckoutFailed(error: CheckoutException) {} - override fun onCheckoutCanceled() {} - override fun onWebPixelEvent(event: PixelEvent) {} - } + private fun processor( + activity: ComponentActivity, + log: LogWrapper = LogWrapper() + ): DefaultCheckoutEventProcessor { + return object : TestCheckoutEventProcessor(activity, log) {} } private fun mockPackageInfo(permissions: Array): PackageInfo { return PackageInfo().apply { requestedPermissions = permissions } } - private class TestCheckoutEventProcessor(context: Context) : - DefaultCheckoutEventProcessor(context) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {} - override fun onCheckoutFailed(error: CheckoutException) {} - override fun onCheckoutCanceled() {} + private open class TestCheckoutEventProcessor( + context: Context, + log: LogWrapper = LogWrapper() + ) : DefaultCheckoutEventProcessor(context, log) { + override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { + /*noop*/ + } + override fun onCheckoutFailed(error: CheckoutException) { + /*noop*/ + } + override fun onCheckoutCanceled() { + /*noop*/ + } } }