diff --git a/.gitignore b/.gitignore index 78afd8ab..aa656deb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ local.properties .env upload-keystore.jks +.claude/ diff --git a/README.md b/README.md index 2b13ef33..f484f461 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,64 @@ fun presentCheckout() { > To help optimize and deliver the best experience the SDK also provides a > [preloading API](#preloading) that can be used to initialize the checkout session ahead of time. +### Application Authentication + +To allow customizing checkout with app specific branding, and/or to receive PII in checkout lifecycle events, +you will need to pass an app authentication token to checkout via `CheckoutOptions` when calling `preload` or `present`. + +```kotlin +val checkoutOptions = CheckoutOptions( + authToken = jwtToken +) + +ShopifyCheckoutSheetKit.preload(checkoutUrl, context, checkoutOptions) + +ShopifyCheckoutSheetKit.present( + checkoutUrl = checkoutUrl, + context = context, + checkoutEventProcessor = checkoutEventProcessor, + options = checkoutOptions, +) +``` + +#### Fetching Authentication Tokens + +Authentication tokens should be fetched from your authentication endpoint using OAuth client credentials flow: + +```kotlin +suspend fun fetchAuthToken(): String { + // POST to your auth endpoint with client credentials + val response = httpClient.post(authEndpoint) { + body = { + "client_id": clientId, + "client_secret": clientSecret, + "grant_type": "client_credentials" + } + } + + // Parse and return the JWT access token + return response.json().accessToken +} +``` + +Fetch the token asynchronously before presenting checkout, with graceful fallback on error: + +```kotlin +suspend fun presentCheckout(url: String, activity: ComponentActivity) { + val token = try { + fetchAuthToken() + } catch (e: Exception) { + null // Fallback to unauthenticated checkout + } + + val options = token?.let { CheckoutOptions(authToken = it) } + ShopifyCheckoutSheetKit.present(url, activity, processor, options) +} +``` + +Note: Tokens are embedded in the checkout URL and should be treated as secrets. Avoid logging the URL or +persisting it beyond the lifetime of the session. + ## Configuration The SDK provides a way to customize the presented checkout experience via @@ -256,10 +314,10 @@ ShopifyCheckoutSheetKit.configure { ``` Once enabled, preloading a checkout is as simple as calling -`preload(checkoutUrl)` with a valid `checkoutUrl`. +`preload(checkoutUrl, activity)` with a valid `checkoutUrl`. To provide app authentication pass in `CheckoutOptions`, as with `present`. ```kotlin -ShopifyCheckoutSheetKit.preload(checkoutUrl) +ShopifyCheckoutSheetKit.preload(checkoutUrl, this, checkoutOptions) ``` Setting enabled to `false` will cause all calls to the `preload` function to be ignored. This allows the application to selectively toggle preloading behavior as a remote feature flag or dynamically in response to client conditions - e.g. when data saver functionality is enabled by the user. @@ -268,7 +326,7 @@ Setting enabled to `false` will cause all calls to the `preload` function to be ShopifyCheckoutSheetKit.configure { it.preloading = Preloading(enabled = false) } -ShopifyCheckoutSheetKit.preload(checkoutUrl) // no-op +ShopifyCheckoutSheetKit.preload(checkoutUrl, this) // no-op ``` ### Important considerations @@ -335,7 +393,7 @@ Extend the `DefaultCheckoutEventProcessor` abstract class to register callbacks ```kotlin val processor = object : DefaultCheckoutEventProcessor(activity) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { + override fun onCheckoutCompleted(checkoutCompleteEvent: CheckoutCompleteEvent) { // Called when the checkout was completed successfully by the buyer. // Use this to update UI, reset cart state, etc. } @@ -411,7 +469,7 @@ In the event of a checkout error occurring, the Checkout Kit _may_ attempt to re There are some caveats to note when this scenario occurs: 1. The checkout experience may look different to buyers. Though the sheet kit will attempt to load any checkoput customizations for the storefront, there is no guarantee they will show in recovery mode. -2. The `onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent)` will be emitted with partial data. Invocations will only received the order ID via `checkoutCompletedEvent.orderDetails.id`. +2. The `onCheckoutCompleted(checkoutCompleteEvent: CheckoutCompleteEvent)` will be emitted with partial data. Invocations will only receive the order ID via `checkoutCompleteEvent.orderConfirmation.order.id`. 3. `onWebPixelEvent(event: PixelEvent)` lifecycle methods will **not** be emitted. Should you wish to opt-out of this fallback experience entirely, you can do so by overriding `shouldRecoverFromError`. Errors given to the `onCheckoutFailed(error: CheckoutException)` lifecycle method will contain an `isRecoverable` property by default indicating whether the request should be retried or not. @@ -605,3 +663,22 @@ see [guidelines and instructions](.github/CONTRIBUTING.md). ## License Shopify's Checkout Kit is provided under an [MIT License](LICENSE). + + +---- + +## Migration + +### Currently Lost +#### Outbound +- presented +- instrumentation (deprecated) + +#### Inbound +- pixels +- blocking +- error + +#### Bugs and stuff +- evaling js... relying on cache - not safe +- not sending onError yet diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/AuthenticationTracker.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/AuthenticationTracker.kt new file mode 100644 index 00000000..a737f3ae --- /dev/null +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/AuthenticationTracker.kt @@ -0,0 +1,76 @@ +/* + * 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 + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkoutsheetkit + +internal class AuthenticationTracker { + private var sentToken: String? = null + private var pendingToken: String? = null + + /** + * Determines if the token should be sent in the URL and marks it as pending. + * Call this before loading a checkout to decide whether to include the token. + * + * @param token The auth token to potentially send + * @return true if the token should be included in the URL + */ + fun shouldSendToken(token: String?): Boolean { + if (token == null) { + reset() + return false + } + + val needsToSend = token != sentToken + pendingToken = if (needsToSend) token else null + return needsToSend + } + + /** + * Checks if the token should be retained in the URL during navigation without modifying state. + * Use this during subsequent navigations to determine if the token is still required. + * + * @param token The auth token to check + * @return true if the token should be retained in the URL + */ + fun shouldRetainToken(token: String?): Boolean { + if (token == null) { + return false + } + return token != sentToken + } + + /** + * Confirms that the pending token was successfully sent. + * Call this after the page finishes loading successfully. + */ + fun confirmTokenSent() { + pendingToken?.let { + sentToken = it + } + pendingToken = null + } + + fun reset() { + sentToken = null + pendingToken = null + } +} diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/BaseWebView.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/BaseWebView.kt index b269d1f2..7c2175fe 100644 --- a/lib/src/main/java/com/shopify/checkoutsheetkit/BaseWebView.kt +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/BaseWebView.kt @@ -47,20 +47,21 @@ import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewFeature import com.shopify.checkoutsheetkit.ShopifyCheckoutSheetKit.log import java.net.HttpURLConnection.HTTP_GONE -import java.net.HttpURLConnection.HTTP_NOT_FOUND @SuppressLint("SetJavaScriptEnabled") internal abstract class BaseWebView(context: Context, attributeSet: AttributeSet? = null) : WebView(context, attributeSet) { + internal val authenticationTracker = AuthenticationTracker() + init { configureWebView() } abstract fun getEventProcessor(): CheckoutWebViewEventProcessor abstract val recoverErrors: Boolean - abstract val variant: String - abstract val cspSchema: String + + open val checkoutOptions: CheckoutOptions? = null private fun configureWebView() { visibility = VISIBLE @@ -122,16 +123,6 @@ internal abstract class BaseWebView(context: Context, attributeSet: AttributeSet return super.onKeyDown(keyCode, event) } - internal fun userAgentSuffix(): String { - val theme = ShopifyCheckoutSheetKit.configuration.colorScheme.id - val version = ShopifyCheckoutSheetKit.version.split("-").first() - val platform = ShopifyCheckoutSheetKit.configuration.platform - val platformSuffix = if (platform != null) " ${platform.displayName}" else "" - val suffix = "ShopifyCheckoutSDK/${version} ($cspSchema;$theme;$variant)$platformSuffix" - log.d(LOG_TAG, "Setting User-Agent suffix $suffix") - return suffix - } - open inner class BaseWebViewClient : WebViewClient() { init { if (BuildConfig.DEBUG) { @@ -140,6 +131,26 @@ internal abstract class BaseWebView(context: Context, attributeSet: AttributeSet } } + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + val requestUri = request?.url + if ( + request?.isForMainFrame == true && + requestUri?.isWebLink() == true && + requestUri.needsEmbedParam(options = checkoutOptions) + ) { + val headers = request.requestHeaders ?: mutableMapOf() + view?.loadUrl( + requestUri.withEmbedParam( + options = checkoutOptions, + includeAuthentication = authenticationTracker.shouldRetainToken(checkoutOptions?.authToken) + ), + headers + ) + return true + } + return false + } + override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !detail.didCrash()) { // Renderer was killed because system ran out of memory. diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutAddressChangeRequestedEvent.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutAddressChangeRequestedEvent.kt new file mode 100644 index 00000000..ff83fe1c --- /dev/null +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutAddressChangeRequestedEvent.kt @@ -0,0 +1,126 @@ +/* + * 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 + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkoutsheetkit + +import com.shopify.checkoutsheetkit.CheckoutMessageContract.VERSION +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.util.UUID + +@Serializable +public data class CheckoutAddressChangeRequestedEventData( + public val addressType: String, + public val selectedAddress: CartDeliveryAddressInput? = null, +) + +/** + * Event triggered when checkout requests that the buyer change their delivery address. + * + * Call [respondWith] to provide an updated address, or do nothing to cancel the request. + */ +public class CheckoutAddressChangeRequestedEvent internal constructor( + internal val message: CheckoutMessageParser.JSONRPCMessage.AddressChangeRequested, +) { + public val addressType: String get() = message.addressType + public val selectedAddress: CartDeliveryAddressInput? get() = message.selectedAddress + + /** + * Respond to the address change request with the provided delivery address(es). + * + * This method can only be called once per event. Subsequent calls will be ignored. + * + * @param result The delivery address payload containing the updated address(es) + */ + public fun respondWith(result: DeliveryAddressChangePayload) { + message.respondWith(result) + } + + /** + * Convenience method to respond with a JSON string payload. + * Useful for language bindings (e.g., React Native). + * + * This method can only be called once per event. Subsequent calls will be ignored. + * + * @param json A JSON string representing a DeliveryAddressChangePayload + * @throws kotlinx.serialization.SerializationException if the JSON is invalid + */ + public fun respondWith(json: String) { + val jsonParser = Json { ignoreUnknownKeys = true } + val payload = jsonParser.decodeFromString(json) + respondWith(payload) + } +} + +/** + * Formats JSONRPC response messages for delivery address changes + */ +internal class DeliveryAddressChangeResponse( + private val json: Json = Json { ignoreUnknownKeys = true }, +) { + + fun encodeSetDeliveryAddress(payload: DeliveryAddressChangePayload, requestId: String?): String { + val response = DeliveryAddressChangeResponseEnvelope( + jsonrpc = VERSION, + id = requestId ?: UUID.randomUUID().toString(), + result = payload, + ) + return json.encodeToString(response) + } + + @Serializable + private data class DeliveryAddressChangeResponseEnvelope( + val jsonrpc: String, + val id: String?, + val result: DeliveryAddressChangePayload, + ) +} + +@Serializable +public data class DeliveryAddressChangePayload( + val delivery: CartDelivery, +) + +@Serializable +public class CartDelivery( + public val addresses: List, +) + +@Serializable +public data class CartSelectableAddressInput( + public val address: CartDeliveryAddressInput, +) + +@Serializable +public data class CartDeliveryAddressInput( + public val firstName: String? = null, + public val lastName: String? = null, + public val company: String? = null, + public val address1: String? = null, + public val address2: String? = null, + public val city: String? = null, + public val countryCode: String? = null, + public val phone: String? = null, + public val provinceCode: String? = null, + public val zip: String? = null, +) diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutBridge.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutBridge.kt index 17ad0443..4c2ea9eb 100644 --- a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutBridge.kt +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutBridge.kt @@ -22,50 +22,30 @@ */ package com.shopify.checkoutsheetkit +import android.os.Handler +import android.os.Looper import android.webkit.JavascriptInterface import android.webkit.WebView -import com.shopify.checkoutsheetkit.CheckoutBridge.CheckoutWebOperation.COMPLETED -import com.shopify.checkoutsheetkit.CheckoutBridge.CheckoutWebOperation.ERROR -import com.shopify.checkoutsheetkit.CheckoutBridge.CheckoutWebOperation.MODAL -import com.shopify.checkoutsheetkit.CheckoutBridge.CheckoutWebOperation.WEB_PIXELS import com.shopify.checkoutsheetkit.ShopifyCheckoutSheetKit.log -import com.shopify.checkoutsheetkit.errorevents.CheckoutErrorDecoder -import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEventDecoder -import com.shopify.checkoutsheetkit.pixelevents.PixelEventDecoder import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import java.lang.ref.WeakReference internal class CheckoutBridge( private var eventProcessor: CheckoutWebViewEventProcessor, - private val decoder: Json = Json { ignoreUnknownKeys = true }, - private val pixelEventDecoder: PixelEventDecoder = PixelEventDecoder(decoder, log), - private val checkoutCompletedEventDecoder: CheckoutCompletedEventDecoder = CheckoutCompletedEventDecoder(decoder, log), - private val checkoutErrorDecoder: CheckoutErrorDecoder = CheckoutErrorDecoder(decoder, log), + private val messageParser: CheckoutMessageParser = CheckoutMessageParser(Json { ignoreUnknownKeys = true }, log), ) { + private var webViewRef: WeakReference? = null + fun setEventProcessor(eventProcessor: CheckoutWebViewEventProcessor) { this.eventProcessor = eventProcessor } fun getEventProcessor(): CheckoutWebViewEventProcessor = this.eventProcessor - enum class CheckoutWebOperation(val key: String) { - COMPLETED("completed"), - MODAL("checkoutBlockingEvent"), - WEB_PIXELS("webPixels"), - ERROR("error"); - - companion object { - fun fromKey(key: String): CheckoutWebOperation? { - return entries.find { it.key == key } - } - } - } - - sealed class SDKOperation(val key: String) { - data object Presented : SDKOperation("presented") - class Instrumentation(val payload: InstrumentationPayload) : SDKOperation("instrumentation") + fun setWebView(webView: WebView?) { + this.webViewRef = if (webView != null) WeakReference(webView) else null } // Allows Web to postMessages back to the SDK @@ -74,51 +54,28 @@ internal class CheckoutBridge( fun postMessage(message: String) { try { log.d(LOG_TAG, "Received message from checkout.") - val decodedMsg = decoder.decodeFromString(message) - - when (CheckoutWebOperation.fromKey(decodedMsg.name)) { - COMPLETED -> { - log.d(LOG_TAG, "Received Completed message. Attempting to decode.") - checkoutCompletedEventDecoder.decode(decodedMsg).let { event -> - log.d(LOG_TAG, "Decoded message $event.") - onMainThread { - eventProcessor.onCheckoutViewComplete(event) - } + when (val checkoutMessage = messageParser.parse(message)) { + is CheckoutMessageParser.JSONRPCMessage.AddressChangeRequested -> { + log.d(LOG_TAG, "Received checkout.addressChangeStart message.") + // Set the WebView reference on the message so it can respond directly + webViewRef?.get()?.let { webView -> + checkoutMessage.setWebView(webView) } - } - - MODAL -> { - log.d(LOG_TAG, "Received Modal message.") - val modalVisible = decodedMsg.body.toBooleanStrictOrNull() - modalVisible?.let { - log.d(LOG_TAG, "Modal visible $it") - onMainThread { - eventProcessor.onCheckoutViewModalToggled(modalVisible) - } + onMainThread { + eventProcessor.onAddressChangeRequested(checkoutMessage.toEvent()) } } - WEB_PIXELS -> { - log.d(LOG_TAG, "Received WebPixel message. Attempting to decode.") - pixelEventDecoder.decode(decodedMsg)?.let { event -> - log.d(LOG_TAG, "Decoded message $event.") - onMainThread { - eventProcessor.onWebPixelEvent(event) - } + is CheckoutMessageParser.JSONRPCMessage.Completed -> { + log.d(LOG_TAG, "Received checkout.complete message. Dispatching decoded event.") + onMainThread { + eventProcessor.onCheckoutViewComplete(checkoutMessage.event) } } - ERROR -> { - log.d(LOG_TAG, "Received Error message. Attempting to decode.") - checkoutErrorDecoder.decode(decodedMsg)?.let { exception -> - log.d(LOG_TAG, "Decoded message $exception.") - onMainThread { - eventProcessor.onCheckoutViewFailedWithError(exception) - } - } + null -> { + log.d(LOG_TAG, "Unsupported message received. Ignoring.") } - - else -> {} } } catch (e: Exception) { log.d(LOG_TAG, "Failed to decode message with error: $e. Calling onCheckoutFailedWithError") @@ -134,70 +91,47 @@ internal class CheckoutBridge( } } - // Send messages from SDK to Web - @Suppress("SwallowedException") - fun sendMessage(view: WebView, operation: SDKOperation) { - val script = when (operation) { - is SDKOperation.Presented -> { - log.d(LOG_TAG, "Sending presented message to checkout, informing it that the sheet is now visible.") - dispatchMessageTemplate("'${operation.key}'") - } - - is SDKOperation.Instrumentation -> { - log.d(LOG_TAG, "Sending instrumentation message to checkout.") - val body = Json.encodeToString(SdkToWebEvent(operation.payload)) - dispatchMessageTemplate("'${operation.key}', $body") - } - } - try { - view.evaluateJavascript(script, null) - } catch (e: Exception) { - log.d(LOG_TAG, "Failed to send message to checkout, invoking onCheckoutViewFailedWithError") - onMainThread { - eventProcessor.onCheckoutViewFailedWithError( - CheckoutSheetKitException( - errorDescription = "Failed to send '${operation.key}' message to checkout, some features may not work.", - errorCode = CheckoutSheetKitException.ERROR_SENDING_MESSAGE_TO_CHECKOUT, - isRecoverable = true, + companion object { + private const val LOG_TAG = "CheckoutBridge" + const val SCHEMA_VERSION: String = "2025-10" + + /** + * Static method to send a JSONRPC response via WebView.evaluateJavascript + * + * @param webView The WebView to send the response through + * @param responseJson The stringified JSONRPC response envelope + */ + fun sendResponse(webView: WebView, responseJson: String) { + Handler(Looper.getMainLooper()).post { + runCatching { + webView.evaluateJavascript( + """| + |(function() { + | try { + | if (window && typeof window.postMessage === 'function') { + | window.postMessage($responseJson, '*'); + | } else if (window && window.console && window.console.error) { + | window.console.error('window.postMessage is not available.'); + | } + | } catch (error) { + | if (window && window.console && window.console.error) { + | window.console.error('Failed to post message to checkout', error); + | } + | } + |})(); + |""".trimMargin(), + null, ) - ) + } + .onFailure { error -> + log.e( + LOG_TAG, + "Failed to post response to checkout: ${error.message}", + ) + } } } } - - companion object { - private const val LOG_TAG = "CheckoutBridge" - const val SCHEMA_VERSION_NUMBER: String = "8.1" - - private fun dispatchMessageTemplate(body: String) = """| - |if (window.MobileCheckoutSdk && window.MobileCheckoutSdk.dispatchMessage) { - | window.MobileCheckoutSdk.dispatchMessage($body); - |} else { - | window.addEventListener('mobileCheckoutBridgeReady', function () { - | window.MobileCheckoutSdk.dispatchMessage($body); - | }, {passive: true, once: true}); - |} - |""".trimMargin() - } -} - -@Serializable -internal data class SdkToWebEvent( - val detail: T -) - -@Serializable -internal data class InstrumentationPayload( - val name: String, - val value: Long, - val type: InstrumentationType, - val tags: Map -) - -@Suppress("EnumEntryName", "EnumNaming") -@Serializable -internal enum class InstrumentationType { - histogram } @Serializable diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutDialog.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutDialog.kt index 0d438e63..268940f9 100644 --- a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutDialog.kt +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutDialog.kt @@ -54,6 +54,7 @@ internal class CheckoutDialog( private val checkoutUrl: String, private val checkoutEventProcessor: CheckoutEventProcessor, context: Context, + private val options: CheckoutOptions?, ) : Dialog(context) { fun start(context: ComponentActivity) { @@ -72,6 +73,7 @@ internal class CheckoutDialog( val checkoutWebView = CheckoutWebView.cacheableCheckoutView( checkoutUrl, context, + options = options, ) checkoutWebView.onResume() @@ -111,7 +113,7 @@ internal class CheckoutDialog( setOnShowListener { log.d(LOG_TAG, "On show listener invoked, calling WebView notifyPresented.") - checkoutWebView.notifyPresented() +// checkoutWebView?.notifyPresented() } log.d(LOG_TAG, "Showing dialog.") @@ -215,7 +217,7 @@ internal class CheckoutDialog( ShopifyCheckoutSheetKit.configuration.colorScheme, FallbackWebView(context).apply { setEventProcessor(eventProcessor()) - loadUrl(checkoutUrl) + loadCheckout(checkoutUrl, options) } ) return true diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutEventProcessor.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutEventProcessor.kt index 7954a669..c4703648 100644 --- a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutEventProcessor.kt +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutEventProcessor.kt @@ -31,7 +31,7 @@ import android.webkit.PermissionRequest import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebView -import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent import com.shopify.checkoutsheetkit.pixelevents.PixelEvent /** @@ -42,7 +42,7 @@ public interface CheckoutEventProcessor { /** * Event representing the successful completion of a checkout. */ - public fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) + public fun onCheckoutCompleted(checkoutCompleteEvent: CheckoutCompleteEvent) /** * Event representing an error that occurred during checkout. This can be used to display @@ -95,10 +95,18 @@ public interface CheckoutEventProcessor { * Called when the client should hide the location permissions prompt, e.g. if th request is cancelled */ public fun onGeolocationPermissionsHidePrompt() + + /** + * Called when checkout requests that the buyer change their delivery address. + * + * By default the request is cancelled. Override to present custom UI and provide a response + * via [CheckoutAddressChangeRequestedEvent.respondWith] (or cancel explicitly). + */ + public fun onAddressChangeRequested(event: CheckoutAddressChangeRequestedEvent) } internal class NoopEventProcessor : CheckoutEventProcessor { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {/* noop */ + override fun onCheckoutCompleted(checkoutCompleteEvent: CheckoutCompleteEvent) {/* noop */ } override fun onCheckoutFailed(error: CheckoutException) {/* noop */ @@ -129,6 +137,9 @@ internal class NoopEventProcessor : CheckoutEventProcessor { override fun onGeolocationPermissionsHidePrompt() {/* noop */ } + + override fun onAddressChangeRequested(event: CheckoutAddressChangeRequestedEvent) {/* noop */ + } } /** diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutMessageContract.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutMessageContract.kt new file mode 100644 index 00000000..55c439b4 --- /dev/null +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutMessageContract.kt @@ -0,0 +1,35 @@ +/* + * 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 + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkoutsheetkit + +internal object CheckoutMessageContract { + const val VERSION_FIELD = "jsonrpc" + const val METHOD_FIELD = "method" + const val PARAMS_FIELD = "params" + const val ID_FIELD = "id" + + const val VERSION = "2.0" + + const val METHOD_ADDRESS_CHANGE_REQUESTED = "checkout.addressChangeRequested" + const val METHOD_COMPLETE = "checkout.complete" +} diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutMessageParser.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutMessageParser.kt new file mode 100644 index 00000000..d93d4f76 --- /dev/null +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutMessageParser.kt @@ -0,0 +1,143 @@ +/* + * 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 + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkoutsheetkit + +import android.webkit.WebView +import com.shopify.checkoutsheetkit.CheckoutMessageContract.METHOD_ADDRESS_CHANGE_REQUESTED +import com.shopify.checkoutsheetkit.CheckoutMessageContract.METHOD_COMPLETE +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonPrimitive +import java.lang.ref.WeakReference + +internal class CheckoutMessageParser( + internal val json: Json, + private val log: LogWrapper = ShopifyCheckoutSheetKit.log, +) { + + fun parse(rawMessage: String): JSONRPCMessage? { + val envelope = json.runCatching { decodeFromString(rawMessage) }.getOrNull() + ?: return null + + val id = envelope.id?.jsonPrimitive?.contentOrNull + + return when (envelope.method) { + METHOD_ADDRESS_CHANGE_REQUESTED -> envelope.params + .decodeOrNull { + log.d(LOG_TAG, "Failed to decode address change requested params: ${it.message}") + } + ?.let { JSONRPCMessage.AddressChangeRequested(id, it) } + + METHOD_COMPLETE -> envelope.params + .decodeOrNull { + log.d(LOG_TAG, "Failed to decode checkout completed params: ${it.message}") + } + ?.let { JSONRPCMessage.Completed(it) } + + else -> { + log.d(LOG_TAG, "Received unsupported message method: ${envelope.method}") + null + } + } + } + + sealed class JSONRPCMessage { + /** + * Base class for JSONRPC notifications (one-way messages with no ID and no response expected) + */ + sealed class JSONRPCNotification : JSONRPCMessage() + + /** + * Base class for JSONRPC requests (messages with ID that expect a response) + */ + sealed class JSONRPCRequest : JSONRPCMessage() { + abstract val id: String? + internal var webViewRef: WeakReference? = null + + internal fun setWebView(webView: android.webkit.WebView) { + webViewRef = WeakReference(webView) + } + } + + data class AddressChangeRequested internal constructor( + override val id: String?, + internal val params: CheckoutAddressChangeRequestedEventData, + ) : JSONRPCRequest() { + private var hasResponded = false + internal val addressType: String get() = params.addressType + internal val selectedAddress: CartDeliveryAddressInput? get() = params.selectedAddress + + /** + * Send a response to this JSONRPC request + * @param payload The payload to send back to the WebView + */ + internal fun respondWith(payload: DeliveryAddressChangePayload) { + if (hasResponded) return + hasResponded = true + + webViewRef?.get()?.let { webView -> + val response = DeliveryAddressChangeResponse() + val responseJson = response.encodeSetDeliveryAddress(payload, id) + CheckoutBridge.sendResponse(webView, responseJson) + } + } + + /** + * Convert to public-facing event + */ + internal fun toEvent(): CheckoutAddressChangeRequestedEvent { + return CheckoutAddressChangeRequestedEvent(this) + } + } + + data class Completed( + val event: CheckoutCompleteEvent, + ) : JSONRPCNotification() + } + + @Serializable + private data class JsonRpcEnvelope( + @SerialName(CheckoutMessageContract.VERSION_FIELD) + val version: String? = null, + val method: String? = null, + val params: JsonElement? = null, + val id: JsonElement? = null, + ) + + companion object { + private const val LOG_TAG = "CheckoutMessageParser" + } + + private inline fun JsonElement?.decodeOrNull(onFailure: (Throwable) -> Unit): T? { + return this?.let { + runCatching { json.decodeFromJsonElement(it) } + .onFailure(onFailure) + .getOrNull() + } + } +} diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutOptions.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutOptions.kt new file mode 100644 index 00000000..560d6dd3 --- /dev/null +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutOptions.kt @@ -0,0 +1,33 @@ +/* + * 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 + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkoutsheetkit + +/** + * Options for configuring an individual checkout session. + */ +public data class CheckoutOptions( + /** + * JWT token for authenticated checkout sessions. + */ + val authToken: String? = null, +) diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutWebView.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutWebView.kt index 8904b468..b3934366 100644 --- a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutWebView.kt +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutWebView.kt @@ -31,7 +31,7 @@ import android.util.AttributeSet import android.webkit.WebResourceRequest import android.webkit.WebView import androidx.activity.ComponentActivity -import com.shopify.checkoutsheetkit.InstrumentationType.histogram +import androidx.core.net.toUri import com.shopify.checkoutsheetkit.ShopifyCheckoutSheetKit.log import java.util.concurrent.CountDownLatch import kotlin.math.abs @@ -41,36 +41,25 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n BaseWebView(context, attributeSet) { override val recoverErrors = true - override val variant = "standard" - override val cspSchema = CheckoutBridge.SCHEMA_VERSION_NUMBER var isPreload = false + override var checkoutOptions: CheckoutOptions? = null private val checkoutBridge = CheckoutBridge(CheckoutWebViewEventProcessor(NoopEventProcessor())) private var loadComplete = false set(value) { log.d(LOG_TAG, "Setting loadComplete to $value.") field = value - dispatchWhenPresentedAndLoaded(value, presented) } private var presented = false set(value) { log.d(LOG_TAG, "Setting presented to $value.") field = value - dispatchWhenPresentedAndLoaded(loadComplete, value) } - private fun dispatchWhenPresentedAndLoaded(loadComplete: Boolean, hasBeenPresented: Boolean) { - if (loadComplete && hasBeenPresented) { - checkoutBridge.sendMessage(this, CheckoutBridge.SDKOperation.Presented) - } - } - - private var initLoadTime: Long = -1 - init { webViewClient = CheckoutWebViewClient() addJavascriptInterface(checkoutBridge, JAVASCRIPT_INTERFACE_NAME) - settings.userAgentString = "${settings.userAgentString} ${userAgentSuffix()}" + checkoutBridge.setWebView(this) } fun hasFinishedLoading() = loadComplete @@ -93,21 +82,31 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n super.onAttachedToWindow() log.d(LOG_TAG, "Attached to window. Adding JavaScript interface with name $JAVASCRIPT_INTERFACE_NAME.") addJavascriptInterface(checkoutBridge, JAVASCRIPT_INTERFACE_NAME) + checkoutBridge.setWebView(this) } override fun onDetachedFromWindow() { super.onDetachedFromWindow() log.d(LOG_TAG, "Detached from window. Removing JavaScript interface with name $JAVASCRIPT_INTERFACE_NAME.") removeJavascriptInterface(JAVASCRIPT_INTERFACE_NAME) + checkoutBridge.setWebView(null) } - fun loadCheckout(url: String, isPreload: Boolean) { + fun loadCheckout(url: String, isPreload: Boolean, options: CheckoutOptions? = null) { log.d(LOG_TAG, "Loading checkout with url $url. IsPreload: $isPreload.") - initLoadTime = System.currentTimeMillis() this.isPreload = isPreload + this.checkoutOptions = options Handler(Looper.getMainLooper()).post { val headers = if (isPreload) mutableMapOf("Shopify-Purpose" to "prefetch") else mutableMapOf() - loadUrl(url, headers) + val includeAuthentication = authenticationTracker.shouldSendToken(checkoutOptions?.authToken) + + loadUrl( + url.toUri().withEmbedParam( + options = checkoutOptions, + includeAuthentication = includeAuthentication + ), + headers + ) } } @@ -121,19 +120,9 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n override fun onPageFinished(view: WebView, url: String) { super.onPageFinished(view, url) - log.d(LOG_TAG, "onPageFinished called $url, emitting instrumentation message.") + log.d(LOG_TAG, "onPageFinished called $url.") + authenticationTracker.confirmTokenSent() loadComplete = true - val timeToLoad = System.currentTimeMillis() - initLoadTime - checkoutBridge.sendMessage( - view, CheckoutBridge.SDKOperation.Instrumentation( - InstrumentationPayload( - name = "checkout_finished_loading", - value = timeToLoad, - type = histogram, - tags = mapOf("preloading" to isPreload.toString()), - ) - ) - ) getEventProcessor().onCheckoutViewLoadComplete() } @@ -150,7 +139,8 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n checkoutBridge.getEventProcessor().onCheckoutViewLinkClicked(request.trimmedUri()) return true } - return false + + return super.shouldOverrideUrlLoading(view, request) } private fun WebResourceRequest.hasExternalAnnotation(): Boolean { @@ -186,7 +176,7 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n companion object { private const val LOG_TAG = "CheckoutWebView" private const val OPEN_EXTERNALLY_PARAM = "open_externally" - private const val JAVASCRIPT_INTERFACE_NAME = "android" + private const val JAVASCRIPT_INTERFACE_NAME = "EmbeddedCheckoutProtocolConsumer" internal var cacheEntry: CheckoutWebViewCacheEntry? = null internal var cacheClock = CheckoutWebViewCacheClock() @@ -206,12 +196,13 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n url: String, activity: ComponentActivity, isPreload: Boolean = false, + options: CheckoutOptions? = null, ): CheckoutWebView { var view: CheckoutWebView? = null val countDownLatch = CountDownLatch(1) activity.runOnUiThread { - view = fetchView(url, activity, isPreload) + view = fetchView(url, activity, isPreload, options) countDownLatch.countDown() } @@ -224,13 +215,14 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n url: String, activity: ComponentActivity, isPreload: Boolean, + options: CheckoutOptions?, ): CheckoutWebView { val preloadingEnabled = ShopifyCheckoutSheetKit.configuration.preloading.enabled log.d(LOG_TAG, "Fetch view called for url $url. Is preload: $isPreload. Preloading enabled: $preloadingEnabled.") if (!preloadingEnabled || cacheEntry?.isValid(url) != true) { log.d(LOG_TAG, "Constructing new CheckoutWebView and calling loadCheckout.") val view = CheckoutWebView(activity as Context).apply { - loadCheckout(url, isPreload) + loadCheckout(url, isPreload, options) if (isPreload) { // Pauses processing that can be paused safely (e.g. geolocation, animations), but not JavaScript / network requests // https://developer.android.com/reference/android/webkit/WebView#onPause() @@ -278,7 +270,9 @@ internal class CheckoutWebView(context: Context, attributeSet: AttributeSet? = n private val timestamp = clock.currentTimeMillis() fun isValid(key: String): Boolean { - return key == cacheEntry!!.key && !cacheEntry!!.isStale + val valid = this.key == key && !isStale + log.d(LOG_TAG, "Checking cache entry validity. Is valid: $valid.") + return valid } internal val isStale: Boolean diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutWebViewEventProcessor.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutWebViewEventProcessor.kt index b9b6cb83..2feb2376 100644 --- a/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutWebViewEventProcessor.kt +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/CheckoutWebViewEventProcessor.kt @@ -31,7 +31,7 @@ import android.webkit.ValueCallback import android.webkit.WebChromeClient.FileChooserParams import android.webkit.WebView import com.shopify.checkoutsheetkit.ShopifyCheckoutSheetKit.log -import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent import com.shopify.checkoutsheetkit.pixelevents.PixelEvent /** @@ -45,12 +45,12 @@ internal class CheckoutWebViewEventProcessor( private val setProgressBarVisibility: (Int) -> Unit = {}, private val updateProgressBarPercentage: (Int) -> Unit = {}, ) { - fun onCheckoutViewComplete(checkoutCompletedEvent: CheckoutCompletedEvent) { + fun onCheckoutViewComplete(checkoutCompleteEvent: CheckoutCompleteEvent) { log.d(LOG_TAG, "Clearing WebView cache after checkout completion.") CheckoutWebView.markCacheEntryStale() - log.d(LOG_TAG, "Calling onCheckoutCompleted $checkoutCompletedEvent.") - eventProcessor.onCheckoutCompleted(checkoutCompletedEvent) + log.d(LOG_TAG, "Calling onCheckoutCompleted $checkoutCompleteEvent.") + eventProcessor.onCheckoutCompleted(checkoutCompleteEvent) } fun onCheckoutViewModalToggled(modalVisible: Boolean) { @@ -115,6 +115,12 @@ internal class CheckoutWebViewEventProcessor( eventProcessor.onWebPixelEvent(event) } + fun onAddressChangeRequested(event: CheckoutAddressChangeRequestedEvent) { + onMainThread { + eventProcessor.onAddressChangeRequested(event) + } + } + companion object { private const val LOG_TAG = "CheckoutWebViewEventProcessor" } diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/Configuration.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/Configuration.kt index e3d9376f..e6560d05 100644 --- a/lib/src/main/java/com/shopify/checkoutsheetkit/Configuration.kt +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/Configuration.kt @@ -33,7 +33,7 @@ public data class Configuration internal constructor( var colorScheme: ColorScheme = ColorScheme.Automatic(), var preloading: Preloading = Preloading(), var errorRecovery: ErrorRecovery = object : ErrorRecovery {}, - var platform: Platform? = null, + var platform: Platform = Platform.ANDROID, var logLevel: LogLevel = LogLevel.WARN, ) @@ -61,5 +61,6 @@ public interface ErrorRecovery { } public enum class Platform(public val displayName: String) { - REACT_NATIVE("ReactNative") + ANDROID("android"), + REACT_NATIVE("react-native-android"), } diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/EmbedParams.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/EmbedParams.kt new file mode 100644 index 00000000..5dfe2abf --- /dev/null +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/EmbedParams.kt @@ -0,0 +1,172 @@ +/* + * 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 + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkoutsheetkit + +import android.net.Uri + +internal object QueryParamKey { + internal const val EMBED = "embed" +} + +internal object EmbedFieldKey { + internal const val PROTOCOL = "protocol" + internal const val BRANDING = "branding" + internal const val LIBRARY = "library" + internal const val SDK = "sdk" + internal const val PLATFORM = "platform" + internal const val ENTRY = "entry" + internal const val COLOR_SCHEME = "colorscheme" + internal const val RECOVERY = "recovery" + internal const val AUTHENTICATION = "authentication" +} + +internal object EmbedFieldValue { + internal const val BRANDING_APP = "app" + internal const val BRANDING_SHOP = "shop" + internal const val ENTRY_SHEET = "sheet" +} + +private object EmbedSeparators { + const val FIELDS = "," // Separates embed fields: "key1=val1,key2=val2" + const val PARAMS = "&" // Separates query parameters + const val KEY_VALUE = "=" // Separates keys from values +} + +internal object EmbedParamBuilder { + fun build( + isRecovery: Boolean = false, + options: CheckoutOptions? = null, + includeAuthentication: Boolean = true, + ): String { + val configuredColorScheme = ShopifyCheckoutSheetKit.configuration.colorScheme + + val brandingValue = when (configuredColorScheme) { + is ColorScheme.Web -> EmbedFieldValue.BRANDING_SHOP + else -> EmbedFieldValue.BRANDING_APP + } + val colorScheme = when (configuredColorScheme) { + is ColorScheme.Web -> null + is ColorScheme.Automatic -> null + else -> configuredColorScheme.id + } + + val fields = mutableMapOf( + EmbedFieldKey.PROTOCOL to CheckoutBridge.SCHEMA_VERSION, + EmbedFieldKey.BRANDING to brandingValue, + EmbedFieldKey.LIBRARY to "CheckoutKit/${BuildConfig.SDK_VERSION}", + EmbedFieldKey.SDK to ShopifyCheckoutSheetKit.version.split("-").first(), + EmbedFieldKey.PLATFORM to ShopifyCheckoutSheetKit.configuration.platform.displayName, + EmbedFieldKey.ENTRY to EmbedFieldValue.ENTRY_SHEET, + EmbedFieldKey.COLOR_SCHEME to colorScheme, + EmbedFieldKey.RECOVERY to if (isRecovery) "true" else null, + ) + + if (includeAuthentication) { + options?.authToken?.let { + fields[EmbedFieldKey.AUTHENTICATION] = it + } + } + + return fields.entries + .filter { (_, value) -> !value.isNullOrEmpty() } + .joinToString(EmbedSeparators.FIELDS) { (key, value) -> + "$key${EmbedSeparators.KEY_VALUE}$value" + } + } +} + +/** + * Adds or updates the embed query parameter on this URI with the provided options. + * + * @param isRecovery Whether this is a recovery/fallback checkout load + * @param options Checkout configuration options including authentication token + * @param includeAuthentication Whether to include the authentication token in the embed parameter + * @return The complete URL string with the embed parameter added/updated + */ +internal fun Uri.withEmbedParam( + isRecovery: Boolean = false, + options: CheckoutOptions? = null, + includeAuthentication: Boolean = true, +): String { + val embedValue = EmbedParamBuilder.build( + isRecovery = isRecovery, + options = options, + includeAuthentication = includeAuthentication, + ) + + val encodedEmbed = Uri.encode(embedValue) + val existingParams = encodedQuery + ?.split(EmbedSeparators.PARAMS) + ?.filter { segment -> + val paramName = segment.substringBefore(EmbedSeparators.KEY_VALUE) + Uri.decode(paramName) != QueryParamKey.EMBED + } + ?.filter { it.isNotEmpty() } + ?: emptyList() + + val updatedQuery = buildList { + addAll(existingParams) + add("${QueryParamKey.EMBED}${EmbedSeparators.KEY_VALUE}$encodedEmbed") + }.joinToString(separator = EmbedSeparators.PARAMS) + + return buildUpon() + .encodedQuery(updatedQuery) + .build() + .toString() +} + +/** + * Checks whether the URL needs the embed parameter to be added or updated. + * + * This function compares the existing embed parameter (if present) with what the current + * SDK configuration would generate. It returns true if: + * - No embed parameter exists + * - The embed parameter differs from current configuration (excluding authentication token) + * + * The authentication token is excluded from comparison because it's sent only once per + * unique value and then omitted from subsequent navigations. + * + * @param isRecovery Whether this is a recovery/fallback checkout load + * @param options Checkout configuration options + * @return true if the embed parameter needs to be added or updated + */ +internal fun Uri.needsEmbedParam( + isRecovery: Boolean = false, + options: CheckoutOptions? = null, +): Boolean { + val currentEmbedValue = getQueryParameter(QueryParamKey.EMBED) ?: return true + val expectedEmbedValue = EmbedParamBuilder.build( + isRecovery = isRecovery, + options = options, + includeAuthentication = false, + ) + + // Remove authentication field from current value for comparison + val currentWithoutAuth = currentEmbedValue + .split(EmbedSeparators.FIELDS) + .map { it.trim() } + .filter { field -> !field.startsWith("${EmbedFieldKey.AUTHENTICATION}${EmbedSeparators.KEY_VALUE}") } + .joinToString(EmbedSeparators.FIELDS) + + return currentWithoutAuth != expectedEmbedValue +} diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/FallbackWebView.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/FallbackWebView.kt index 32149f81..e10b0bfe 100644 --- a/lib/src/main/java/com/shopify/checkoutsheetkit/FallbackWebView.kt +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/FallbackWebView.kt @@ -28,19 +28,17 @@ import android.util.AttributeSet import android.webkit.WebView import androidx.core.net.toUri import com.shopify.checkoutsheetkit.ShopifyCheckoutSheetKit.log -import com.shopify.checkoutsheetkit.lifecycleevents.emptyCompletedEvent +import com.shopify.checkoutsheetkit.lifecycleevents.emptyCompleteEvent internal class FallbackWebView(context: Context, attributeSet: AttributeSet? = null) : BaseWebView(context, attributeSet) { override val recoverErrors = false - override val variant = "standard_recovery" - override val cspSchema = "noconnect" + override var checkoutOptions: CheckoutOptions? = null init { log.d(LOG_TAG, "Initializing fallback web view.") webViewClient = FallbackWebViewClient() - settings.userAgentString = "${settings.userAgentString} ${userAgentSuffix()}" } private var checkoutEventProcessor = CheckoutWebViewEventProcessor(NoopEventProcessor()) @@ -50,6 +48,20 @@ internal class FallbackWebView(context: Context, attributeSet: AttributeSet? = n this.checkoutEventProcessor = processor } + fun loadCheckout(url: String, options: CheckoutOptions? = checkoutOptions) { + checkoutOptions = options + + val includeAuthentication = authenticationTracker.shouldSendToken(checkoutOptions?.authToken) + + loadUrl( + url.toUri().withEmbedParam( + isRecovery = true, + options = checkoutOptions, + includeAuthentication = includeAuthentication + ) + ) + } + override fun getEventProcessor(): CheckoutWebViewEventProcessor { return checkoutEventProcessor } @@ -68,13 +80,14 @@ internal class FallbackWebView(context: Context, attributeSet: AttributeSet? = n override fun onPageFinished(view: WebView, url: String) { super.onPageFinished(view, url) log.d(LOG_TAG, "onPageFinished called.") + authenticationTracker.confirmTokenSent() getEventProcessor().onCheckoutViewLoadComplete() val uri = url.toUri() if (isConfirmation(uri)) { - log.d(LOG_TAG, "Finished page has confirmationUrl. Emitting minimal checkout completed event.") + log.d(LOG_TAG, "Finished page has confirmationUrl. Emitting minimal checkout.complete event.") getEventProcessor().onCheckoutViewComplete( - emptyCompletedEvent(id = getOrderIdFromQueryString(uri)) + emptyCompleteEvent(id = getOrderIdFromQueryString(uri)) ) } } diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/ShopifyCheckoutSheetKit.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/ShopifyCheckoutSheetKit.kt index 258e1c98..8dec242f 100644 --- a/lib/src/main/java/com/shopify/checkoutsheetkit/ShopifyCheckoutSheetKit.kt +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/ShopifyCheckoutSheetKit.kt @@ -91,7 +91,12 @@ public object ShopifyCheckoutSheetKit { * @param context The context the checkout is being presented from */ @JvmStatic - public fun preload(checkoutUrl: String, context: ComponentActivity) { + @JvmOverloads + public fun preload( + checkoutUrl: String, + context: ComponentActivity, + options: CheckoutOptions? = null, + ) { log.d("ShopifyCheckoutSheetKit", "Preload called. Preloading enabled ${configuration.preloading.enabled}.") if (!configuration.preloading.enabled) return @@ -103,7 +108,7 @@ public object ShopifyCheckoutSheetKit { } log.d("ShopifyCheckoutSheetKit", "Calling loadCheckout on existing view with url $checkoutUrl.") - cacheEntry.view.loadCheckout(checkoutUrl, false) + cacheEntry.view.loadCheckout(checkoutUrl, false, options) } else { log.d("ShopifyCheckoutSheetKit", "Fetching cacheable WebView.") CheckoutWebView.markCacheEntryStale() @@ -111,6 +116,7 @@ public object ShopifyCheckoutSheetKit { url = checkoutUrl, activity = context, isPreload = true, + options = options, ) } } @@ -125,10 +131,12 @@ public object ShopifyCheckoutSheetKit { * @return An instance of [CheckoutSheetKitDialog] if the dialog was successfully created and displayed. */ @JvmStatic + @JvmOverloads public fun present( checkoutUrl: String, context: ComponentActivity, - checkoutEventProcessor: T + checkoutEventProcessor: T, + options: CheckoutOptions? = null, ): CheckoutSheetKitDialog? { log.d("ShopifyCheckoutSheetKit", "Present called with checkoutUrl $checkoutUrl.") if (context.isDestroyed || context.isFinishing) { @@ -136,7 +144,7 @@ public object ShopifyCheckoutSheetKit { return null } log.d("ShopifyCheckoutSheetKit", "Constructing Dialog") - val dialog = CheckoutDialog(checkoutUrl, checkoutEventProcessor, context) + val dialog = CheckoutDialog(checkoutUrl, checkoutEventProcessor, context, options) context.lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onDestroy(owner: LifecycleOwner) { log.d("ShopifyCheckoutSheetKit", "Context is being destroyed, dismissing dialog.") diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/lifecycleevents/CheckoutCompleteEvent.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/lifecycleevents/CheckoutCompleteEvent.kt new file mode 100644 index 00000000..8b41a4e6 --- /dev/null +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/lifecycleevents/CheckoutCompleteEvent.kt @@ -0,0 +1,378 @@ +/* + * 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 + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkoutsheetkit.lifecycleevents + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.jsonObject + +@Serializable +public data class CheckoutCompleteEvent( + public val orderConfirmation: OrderConfirmation, + public val cart: Cart +) { + @Serializable + public data class OrderConfirmation( + public val url: String? = null, + public val order: Order, + public val number: String? = null, + public val isFirstOrder: Boolean + ) { + @Serializable + public data class Order( + public val id: String + ) + } + + @Serializable + public data class Cart( + public val id: String, + public val lines: List = emptyList(), + public val cost: CartCost, + public val buyerIdentity: CartBuyerIdentity, + public val deliveryGroups: List = emptyList(), + public val discountCodes: List = emptyList(), + public val appliedGiftCards: List = emptyList(), + public val discountAllocations: List = emptyList(), + public val delivery: CartDelivery + ) + + @Serializable + public data class CartLine( + public val id: String, + public val quantity: Int, + public val merchandise: CartLineMerchandise, + public val cost: CartLineCost, + public val discountAllocations: List = emptyList() + ) + + @Serializable + public data class CartLineCost( + public val amountPerQuantity: Money, + public val subtotalAmount: Money, + public val totalAmount: Money + ) + + @Serializable + public data class CartLineMerchandise( + public val id: String, + public val title: String, + public val product: Product, + public val image: MerchandiseImage? = null, + public val selectedOptions: List = emptyList() + ) { + @Serializable + public data class Product( + public val id: String, + public val title: String + ) + } + + @Serializable + public data class MerchandiseImage( + public val url: String, + public val altText: String? = null + ) + + @Serializable + public data class SelectedOption( + public val name: String, + public val value: String + ) + + @Serializable + public data class CartDiscountAllocation( + public val discountedAmount: Money, + public val discountApplication: DiscountApplication, + public val targetType: DiscountApplicationTargetType + ) + + @Serializable + public data class DiscountApplication( + public val allocationMethod: AllocationMethod, + public val targetSelection: TargetSelection, + public val targetType: DiscountApplicationTargetType, + public val value: DiscountValue + ) { + @Serializable + public enum class AllocationMethod { + @SerialName("ACROSS") + ACROSS, + + @SerialName("EACH") + EACH + } + + @Serializable + public enum class TargetSelection { + @SerialName("ALL") + ALL, + + @SerialName("ENTITLED") + ENTITLED, + + @SerialName("EXPLICIT") + EXPLICIT + } + } + + @Serializable + public enum class DiscountApplicationTargetType { + @SerialName("LINE_ITEM") + LINE_ITEM, + + @SerialName("SHIPPING_LINE") + SHIPPING_LINE + } + + @Serializable + public data class CartCost( + public val subtotalAmount: Money, + public val totalAmount: Money + ) + + @Serializable + public data class CartBuyerIdentity( + public val email: String? = null, + public val phone: String? = null, + public val customer: Customer? = null, + public val countryCode: String? = null + ) + + @Serializable + public data class Customer( + public val id: String? = null, + public val firstName: String? = null, + public val lastName: String? = null, + public val email: String? = null, + public val phone: String? = null + ) + + @Serializable + public data class CartDeliveryGroup( + public val deliveryAddress: MailingAddress, + public val deliveryOptions: List = emptyList(), + public val selectedDeliveryOption: CartDeliveryOption? = null, + public val groupType: CartDeliveryGroupType + ) + + @Serializable + public data class MailingAddress( + public val address1: String? = null, + public val address2: String? = null, + public val city: String? = null, + public val province: String? = null, + public val country: String? = null, + public val countryCodeV2: String? = null, + public val zip: String? = null, + public val firstName: String? = null, + public val lastName: String? = null, + public val phone: String? = null, + public val company: String? = null + ) + + @Serializable + public data class CartDeliveryOption( + public val code: String? = null, + public val title: String? = null, + public val description: String? = null, + public val handle: String, + public val estimatedCost: Money, + public val deliveryMethodType: CartDeliveryMethodType + ) + + @Serializable + public enum class CartDeliveryMethodType { + @SerialName("SHIPPING") + SHIPPING, + + @SerialName("PICKUP") + PICKUP, + + @SerialName("PICKUP_POINT") + PICKUP_POINT, + + @SerialName("LOCAL") + LOCAL, + + @SerialName("NONE") + NONE + } + + @Serializable + public enum class CartDeliveryGroupType { + @SerialName("SUBSCRIPTION") + SUBSCRIPTION, + + @SerialName("ONE_TIME_PURCHASE") + ONE_TIME_PURCHASE + } + + @Serializable + public data class CartDelivery( + public val addresses: List = emptyList() + ) + + @Serializable + public data class CartSelectableAddress( + public val address: CartDeliveryAddress + ) + + @Serializable + public data class CartDeliveryAddress( + public val address1: String? = null, + public val address2: String? = null, + public val city: String? = null, + public val company: String? = null, + public val countryCode: String? = null, + public val firstName: String? = null, + public val lastName: String? = null, + public val phone: String? = null, + public val provinceCode: String? = null, + public val zip: String? = null + ) + + @Serializable + public data class CartDiscountCode( + public val code: String, + public val applicable: Boolean + ) + + @Serializable + public data class AppliedGiftCard( + public val amountUsed: Money, + public val balance: Money, + public val lastCharacters: String, + public val presentmentAmountUsed: Money + ) + + @Serializable + public data class Money( + public val amount: String, + public val currencyCode: String + ) { + init { + require(amount.toBigDecimalOrNull() != null) { + "Invalid money amount: '$amount' (must be a valid decimal number)" + } + require(currencyCode.isNotBlank()) { + "Currency code cannot be blank" + } + } + } + + @Serializable + public data class PricingPercentageValue( + public val percentage: Double + ) + + @Serializable(with = DiscountValueSerializer::class) + public sealed interface DiscountValue { + public data class MoneyValue(val money: Money) : DiscountValue + public data class PercentageValue(val percentage: PricingPercentageValue) : DiscountValue + } + + private object DiscountValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("DiscountValue") + + override fun deserialize(decoder: Decoder): DiscountValue { + val jsonDecoder = decoder as? JsonDecoder + ?: error("DiscountValueSerializer only supports JSON decoding") + val element = jsonDecoder.decodeJsonElement() + + val jsonObject = runCatching { element.jsonObject }.getOrElse { + throw SerializationException("DiscountValue must be a JSON object, but was: ${element::class.simpleName}") + } + + return when { + jsonObject.containsKey("amount") && jsonObject.containsKey("currencyCode") -> { + DiscountValue.MoneyValue( + jsonDecoder.json.decodeFromJsonElement(Money.serializer(), element) + ) + } + jsonObject.containsKey("percentage") -> { + DiscountValue.PercentageValue( + jsonDecoder.json.decodeFromJsonElement(PricingPercentageValue.serializer(), element) + ) + } + else -> throw SerializationException( + "Unable to decode DiscountValue: missing 'amount'/'currencyCode' or 'percentage' field" + ) + } + } + + override fun serialize(encoder: Encoder, value: DiscountValue) { + val jsonEncoder = encoder as? JsonEncoder + ?: error("DiscountValueSerializer only supports JSON encoding") + + when (value) { + is DiscountValue.MoneyValue -> jsonEncoder.encodeSerializableValue( + Money.serializer(), + value.money + ) + is DiscountValue.PercentageValue -> jsonEncoder.encodeSerializableValue( + PricingPercentageValue.serializer(), + value.percentage + ) + } + } + } +} + +internal fun emptyCompleteEvent(id: String? = null): CheckoutCompleteEvent { + return CheckoutCompleteEvent( + orderConfirmation = CheckoutCompleteEvent.OrderConfirmation( + url = null, + order = CheckoutCompleteEvent.OrderConfirmation.Order(id = id ?: ""), + number = null, + isFirstOrder = false + ), + cart = CheckoutCompleteEvent.Cart( + id = "", + lines = emptyList(), + cost = CheckoutCompleteEvent.CartCost( + subtotalAmount = CheckoutCompleteEvent.Money(amount = "0.00", currencyCode = "USD"), + totalAmount = CheckoutCompleteEvent.Money(amount = "0.00", currencyCode = "USD") + ), + buyerIdentity = CheckoutCompleteEvent.CartBuyerIdentity( + email = null, + phone = null, + customer = null, + countryCode = null + ), + deliveryGroups = emptyList(), + discountCodes = emptyList(), + appliedGiftCards = emptyList(), + discountAllocations = emptyList(), + delivery = CheckoutCompleteEvent.CartDelivery(addresses = emptyList()) + ) + ) +} diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/lifecycleevents/CheckoutCompletedEventDecoder.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/lifecycleevents/CheckoutCompleteEventDecoder.kt similarity index 63% rename from lib/src/main/java/com/shopify/checkoutsheetkit/lifecycleevents/CheckoutCompletedEventDecoder.kt rename to lib/src/main/java/com/shopify/checkoutsheetkit/lifecycleevents/CheckoutCompleteEventDecoder.kt index 03b67942..c93b2cca 100644 --- a/lib/src/main/java/com/shopify/checkoutsheetkit/lifecycleevents/CheckoutCompletedEventDecoder.kt +++ b/lib/src/main/java/com/shopify/checkoutsheetkit/lifecycleevents/CheckoutCompleteEventDecoder.kt @@ -24,24 +24,32 @@ package com.shopify.checkoutsheetkit.lifecycleevents import com.shopify.checkoutsheetkit.LogWrapper import com.shopify.checkoutsheetkit.WebToSdkEvent -import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -@Serializable -public data class CheckoutCompletedEvent( - public val orderDetails: OrderDetails -) - -internal class CheckoutCompletedEventDecoder @JvmOverloads constructor( +internal class CheckoutCompleteEventDecoder @JvmOverloads constructor( private val decoder: Json, private val log: LogWrapper = LogWrapper() ) { - fun decode(decodedMsg: WebToSdkEvent): CheckoutCompletedEvent { + fun decode(decodedMsg: WebToSdkEvent): CheckoutCompleteEvent { return try { - decoder.decodeFromString(decodedMsg.body) + decoder.decodeFromString(decodedMsg.body) } catch (e: Exception) { - log.e("CheckoutBridge", "Failed to decode CheckoutCompleted event", e) - emptyCompletedEvent() + log.e( + "CheckoutBridge", + "Failed to decode checkout.complete event. Body: ${decodedMsg.body.take(MAX_LOG_BODY_LENGTH)}", + e + ) + emptyCompleteEvent() } } + + private companion object { + /** + * Maximum characters to include from the request body in error logs. + * + * Captures sufficient context for debugging (full order confirmation + cart ID + 2-3 line items) + * while preventing excessive log output from large carts (10+ line items can exceed 10KB). + */ + const val MAX_LOG_BODY_LENGTH = 1500 + } } diff --git a/lib/src/main/java/com/shopify/checkoutsheetkit/lifecycleevents/CompletedEvent.kt b/lib/src/main/java/com/shopify/checkoutsheetkit/lifecycleevents/CompletedEvent.kt deleted file mode 100644 index d31f098a..00000000 --- a/lib/src/main/java/com/shopify/checkoutsheetkit/lifecycleevents/CompletedEvent.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * 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 - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.shopify.checkoutsheetkit.lifecycleevents - -import com.shopify.checkoutsheetkit.pixelevents.MoneyV2 -import kotlinx.serialization.Serializable - -@Serializable -public data class Address( - public val address1: String? = null, - public val address2: String? = null, - public val city: String? = null, - public val countryCode: String? = null, - public val firstName: String? = null, - public val lastName: String? = null, - public val name: String? = null, - public val phone: String? = null, - public val postalCode: String? = null, - public val referenceId: String? = null, - public val zoneCode: String? = null, -) - -@Serializable -public data class CartInfo( - public val lines: List, - public val price: Price, - public val token: String, -) - -@Serializable -public data class CartLineImage( - public val altText: String? = null, - public val lg: String, - public val md: String, - public val sm: String, -) - -@Serializable -public data class CartLine( - public val discounts: List? = emptyList(), - public val image: CartLineImage? = null, - public val merchandiseId: String? = null, - public val price: MoneyV2, - public val productId: String? = null, - public val quantity: Int, - public val title: String, -) - -@Serializable -public data class DeliveryDetails( - public val additionalInfo: String? = null, - public val location: Address? = null, - public val name: String? = null, -) - -/** - * Current possible methods: - * SHIPPING, PICK_UP, RETAIL, LOCAL, PICKUP_POINT, NONE - */ -@Serializable -public data class DeliveryInfo( - public val details: DeliveryDetails, - public val method: String, -) - -@Serializable -public data class Discount( - public val amount: MoneyV2? = null, - public val applicationType: String? = null, - public val title: String? = null, - public val value: Double? = null, - public val valueType: String? = null, -) - -@Serializable -public data class OrderDetails( - public val billingAddress: Address? = null, - public val cart: CartInfo, - public val deliveries: List = emptyList(), - public val email: String? = null, - public val id: String, - public val paymentMethods: List = emptyList(), - public val phone: String? = null, -) - -@Serializable -public data class PaymentMethod( - public val details: Map? = emptyMap(), - public val type: String, -) - -@Serializable -public data class Price( - public val discounts: List? = emptyList(), - public val shipping: MoneyV2? = null, - public val subtotal: MoneyV2? = null, - public val taxes: MoneyV2? = null, - public val total: MoneyV2? = null, -) - -internal fun emptyCompletedEvent(id: String? = null): CheckoutCompletedEvent { - return CheckoutCompletedEvent( - orderDetails = OrderDetails( - cart = CartInfo( - token = "", - lines = emptyList(), - price = Price() - ), - id = id ?: "", - ) - ) -} diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutAddressChangeRequestedEventTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutAddressChangeRequestedEventTest.kt new file mode 100644 index 00000000..45d4e858 --- /dev/null +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutAddressChangeRequestedEventTest.kt @@ -0,0 +1,120 @@ +/* + * 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 + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkoutsheetkit + +import com.shopify.checkoutsheetkit.CheckoutAssertions.assertThat +import org.junit.Test + +class CheckoutAddressChangeRequestedEventTest { + + @Test + fun `respondWith payload invokes respondWith on message`() { + val payload = samplePayload() + val eventData = CheckoutAddressChangeRequestedEventData( + addressType = "shipping", + selectedAddress = null, + ) + val message = CheckoutMessageParser.JSONRPCMessage.AddressChangeRequested( + id = "test-id", + params = eventData, + ) + val event = CheckoutAddressChangeRequestedEvent(message) + + // This will fail to send since no WebView is attached, but we're testing the flow + event.respondWith(payload) + + assertThat(event.addressType).isEqualTo("shipping") + assertThat(event.selectedAddress).isNull() + } + + @Test + fun `respondWith JSON string parses and invokes respondWith on message`() { + val json = """ + { + "delivery": { + "addresses": [ + { + "address": { + "firstName": "Ada", + "lastName": "Lovelace" + } + } + ] + } + } + """.trimIndent() + + val eventData = CheckoutAddressChangeRequestedEventData( + addressType = "shipping", + selectedAddress = null, + ) + val message = CheckoutMessageParser.JSONRPCMessage.AddressChangeRequested( + id = "test-id", + params = eventData, + ) + val event = CheckoutAddressChangeRequestedEvent(message) + + // This will fail to send since no WebView is attached, but we're testing the parsing + event.respondWith(json) + + assertThat(event.addressType).isEqualTo("shipping") + } + + @Test + fun `exposes selectedAddress from params`() { + val selectedAddress = CartDeliveryAddressInput( + firstName = "Ada", + lastName = "Lovelace", + city = "London", + ) + val eventData = CheckoutAddressChangeRequestedEventData( + addressType = "billing", + selectedAddress = selectedAddress, + ) + val message = CheckoutMessageParser.JSONRPCMessage.AddressChangeRequested( + id = "test-id", + params = eventData, + ) + val event = CheckoutAddressChangeRequestedEvent(message) + + assertThat(event.selectedAddress).isEqualTo(selectedAddress) + assertThat(event.selectedAddress?.firstName).isEqualTo("Ada") + assertThat(event.selectedAddress?.lastName).isEqualTo("Lovelace") + assertThat(event.selectedAddress?.city).isEqualTo("London") + } + + private fun samplePayload(): DeliveryAddressChangePayload { + return DeliveryAddressChangePayload( + delivery = CartDelivery( + addresses = listOf( + CartSelectableAddressInput( + address = CartDeliveryAddressInput( + firstName = "Ada", + lastName = "Lovelace" + ) + ) + ) + ) + ) + } +} diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutAssertions.java b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutAssertions.java new file mode 100644 index 00000000..6ef4c6eb --- /dev/null +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutAssertions.java @@ -0,0 +1,15 @@ +package com.shopify.checkoutsheetkit; + +import android.net.Uri; + +import org.assertj.core.api.Assertions; + +public class CheckoutAssertions extends Assertions { + public static CheckoutExceptionAssert assertThat(CheckoutException actual) { + return new CheckoutExceptionAssert(actual); + } + + public static EmbedParamAssert assertThat(Uri actual) { + return new EmbedParamAssert(actual); + } +} diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutBridgeTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutBridgeTest.kt index c74f9703..42a586be 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutBridgeTest.kt +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutBridgeTest.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 @@ -22,331 +22,213 @@ */ package com.shopify.checkoutsheetkit -import android.webkit.WebView -import com.shopify.checkoutsheetkit.CheckoutBridge.CheckoutWebOperation.COMPLETED -import com.shopify.checkoutsheetkit.CheckoutBridge.CheckoutWebOperation.MODAL -import com.shopify.checkoutsheetkit.pixelevents.PixelEvent -import com.shopify.checkoutsheetkit.pixelevents.StandardPixelEvent +import com.shopify.checkoutsheetkit.CheckoutAssertions.assertThat +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import org.assertj.core.api.Assertions.assertThat +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.eq import org.mockito.kotlin.mock -import org.mockito.kotlin.timeout +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner +import org.robolectric.shadows.ShadowLooper +import java.util.concurrent.atomic.AtomicInteger @RunWith(RobolectricTestRunner::class) class CheckoutBridgeTest { - private var mockEventProcessor = mock() + private val mockEventProcessor = mock() private lateinit var checkoutBridge: CheckoutBridge @Before - fun init() { + fun setUp() { + CheckoutWebView.cacheEntry = null checkoutBridge = CheckoutBridge(mockEventProcessor) } - @Test - fun `postMessage calls web event processor onCheckoutViewComplete when completed message received`() { - checkoutBridge.postMessage(Json.encodeToString(WebToSdkEvent(COMPLETED.key))) - verify(mockEventProcessor).onCheckoutViewComplete(any()) + @After + fun tearDown() { + CheckoutWebView.cacheEntry = null } @Test - fun `postMessage calls web event processor onCheckoutModalToggled when modal message received - false`() { - checkoutBridge.postMessage( - Json.encodeToString( - WebToSdkEvent( - MODAL.key, - "false" - ) - ) - ) - verify(mockEventProcessor).onCheckoutViewModalToggled(false) + fun `postMessage handles JSON-RPC address change requested message`() { + val jsonRpcMessage = """{ + "jsonrpc":"2.0", + "id":"request-id-1", + "method":"checkout.addressChangeRequested", + "params":{ + "addressType":"shipping", + "selectedAddress":{ + "firstName":"Ada", + "lastName":"Lovelace" + } + } + }""".trimIndent() + + checkoutBridge.postMessage(jsonRpcMessage) + + val eventCaptor = argumentCaptor() + verify(mockEventProcessor).onAddressChangeRequested(eventCaptor.capture()) + + val event = eventCaptor.firstValue + assertThat(event.addressType).isEqualTo("shipping") + assertThat(event.selectedAddress?.firstName).isEqualTo("Ada") + assertThat(event.selectedAddress?.lastName).isEqualTo("Lovelace") } @Test - fun `postMessage calls web event processor onCheckoutModalToggled when modal message received - true`() { - checkoutBridge.postMessage( - Json.encodeToString( - WebToSdkEvent( - MODAL.key, - "true" - ) - ) - ) - verify(mockEventProcessor).onCheckoutViewModalToggled(true) - } + fun `postMessage ignores invalid JSON`() { + val invalidJson = "{ this is not valid json }" - @Test - fun `postMessage does not issue a msg to the event processor when unsupported message received`() { - checkoutBridge.postMessage(Json.encodeToString(WebToSdkEvent("boom"))) - verifyNoInteractions(mockEventProcessor) - } + checkoutBridge.postMessage(invalidJson) - @Test - fun `sendMessage evaluates javascript on the provided WebView`() { - val webView = mock() - checkoutBridge.sendMessage(webView, CheckoutBridge.SDKOperation.Presented) - - verify(webView).evaluateJavascript("""| - |if (window.MobileCheckoutSdk && window.MobileCheckoutSdk.dispatchMessage) { - | window.MobileCheckoutSdk.dispatchMessage('presented'); - |} else { - | window.addEventListener('mobileCheckoutBridgeReady', function () { - | window.MobileCheckoutSdk.dispatchMessage('presented'); - | }, {passive: true, once: true}); - |} - |""".trimMargin(), null) + verifyNoInteractions(mockEventProcessor) } @Test - fun `sendMessage returns error if evaluating javascript fails`() { - val webView = mock() - whenever(webView.evaluateJavascript(any(), eq(null))).thenThrow(RuntimeException("something went wrong")) - - checkoutBridge.sendMessage(webView, CheckoutBridge.SDKOperation.Presented) + fun `postMessage ignores unsupported JSON-RPC methods`() { + val unsupportedMethod = """{ + "jsonrpc":"2.0", + "method":"checkout.unsupported", + "params":{} + }""".trimIndent() - val errorCaptor = argumentCaptor() - verify(mockEventProcessor).onCheckoutViewFailedWithError(errorCaptor.capture()) + checkoutBridge.postMessage(unsupportedMethod) - val error = errorCaptor.firstValue - assertThat(error.message).isEqualTo( - "Failed to send 'presented' message to checkout, some features may not work." - ) - assertThat(error.isRecoverable).isTrue() - assertThat(error.errorCode).isEqualTo(CheckoutSheetKitException.ERROR_SENDING_MESSAGE_TO_CHECKOUT) + verifyNoInteractions(mockEventProcessor) } @Test - fun `instrumentation sends message to the bridge`() { - val webView = mock() - val payload = InstrumentationPayload( - name = "Test", - value = 123L, - type = InstrumentationType.histogram, - tags = mapOf("tag1" to "value1", "tag2" to "value2") + fun `postMessage handles checkout complete JSON-RPC message`() { + val params = CheckoutCompleteEvent( + orderConfirmation = CheckoutCompleteEvent.OrderConfirmation( + url = null, + order = CheckoutCompleteEvent.OrderConfirmation.Order(id = "order-id-123"), + number = null, + isFirstOrder = false + ), + cart = CheckoutCompleteEvent.Cart( + id = "cart-id-123", + lines = emptyList(), + cost = CheckoutCompleteEvent.CartCost( + subtotalAmount = CheckoutCompleteEvent.Money(amount = "0.00", currencyCode = "USD"), + totalAmount = CheckoutCompleteEvent.Money(amount = "0.00", currencyCode = "USD") + ), + buyerIdentity = CheckoutCompleteEvent.CartBuyerIdentity(), + deliveryGroups = emptyList(), + discountCodes = emptyList(), + appliedGiftCards = emptyList(), + discountAllocations = emptyList(), + delivery = CheckoutCompleteEvent.CartDelivery(addresses = emptyList()) + ) ) - val expectedPayload = """{"detail":{"name":"Test","value":123,"type":"histogram","tags":{"tag1":"value1","tag2":"value2"}}}""" - val expectedJavascript = """| - |if (window.MobileCheckoutSdk && window.MobileCheckoutSdk.dispatchMessage) { - | window.MobileCheckoutSdk.dispatchMessage('instrumentation', $expectedPayload); - |} else { - | window.addEventListener('mobileCheckoutBridgeReady', function () { - | window.MobileCheckoutSdk.dispatchMessage('instrumentation', $expectedPayload); - | }, {passive: true, once: true}); - |} - |""".trimMargin() - - checkoutBridge.sendMessage(webView, CheckoutBridge.SDKOperation.Instrumentation(payload)) - - Mockito.verify(webView).evaluateJavascript(expectedJavascript, null) - } - @Test - fun `calls onPixelEvent when valid webPixels event received`() { - val eventString = """| - |{ - | "name":"webPixels", - | "body": "{ - | \"name\": \"checkout_started\", - | \"event\": { - | \"type\": \"standard\", - | \"id\": \"sh-88153c5a-8F2D-4CCA-3231-EF5C032A4C3B\", - | \"name\": \"checkout_started\", - | \"timestamp\": \"2023-12-20T16:39:23+0000\", - | \"data\": { - | \"checkout\": { - | \"order\": { - | \"id\": \"123\" - | } - | } - | } - | } - | }" - |} - |""".trimMargin() - - - checkoutBridge.postMessage(eventString) - - val captor = argumentCaptor() - verify(mockEventProcessor, timeout(2000).times(1)).onWebPixelEvent(captor.capture()) - - assertThat(captor.firstValue).isInstanceOf(StandardPixelEvent::class.java) - } + val jsonRpcMessage = """{ + "jsonrpc":"2.0", + "method":"checkout.complete", + "params":${Json.encodeToString(params)} + }""".trimIndent() - @Test - fun `should decode a checkout expired error payload and call processor#onCheckoutViewFailedWithError - invalid`() { - val eventString = """| - |{ - | "name":"error", - | "body": "[{ - | \"group\": \"expired\", - | \"reason\": \"Cart is invalid\", - | \"flowType\": \"regular\", - | \"code\": \"invalid_cart\" - | }]" - |} - |""".trimMargin() - - checkoutBridge.postMessage(eventString) - - val captor = argumentCaptor() - verify(mockEventProcessor, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) - - val error = captor.firstValue - assertThat(error).isInstanceOf(CheckoutExpiredException::class.java) - assertThat(error.message).isEqualTo("Cart is invalid") - assertThat(error.isRecoverable).isFalse() - assertThat(error.errorCode).isEqualTo(CheckoutExpiredException.INVALID_CART) - } + checkoutBridge.postMessage(jsonRpcMessage) - @Test - fun `should decode a checkout expired error payload and call processor#onCheckoutViewFailedWithError - completed`() { - val eventString = """| - |{ - | "name":"error", - | "body": "[{ - | \"group\": \"expired\", - | \"reason\": \"Checkout has been completed\", - | \"flowType\": \"regular\", - | \"code\": \"cart_completed\" - | }]" - |} - |""".trimMargin() - - checkoutBridge.postMessage(eventString) - - val captor = argumentCaptor() - verify(mockEventProcessor, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) - - val error = captor.firstValue - assertThat(error).isInstanceOf(CheckoutExpiredException::class.java) - assertThat(error.message).isEqualTo("Checkout has been completed") - assertThat(error.isRecoverable).isFalse() - assertThat(error.errorCode).isEqualTo(CheckoutExpiredException.CART_COMPLETED) + verify(mockEventProcessor).onCheckoutViewComplete(any()) } - @Test - fun `should decode a barebones expired error payload and call processor#onCheckoutViewFailedWithError`() { - val eventString = """| - |{ - | "name": "error", - | "body": "[{ - | \"group\": \"expired\" - | }]" - |} - |""".trimMargin() - - checkoutBridge.postMessage(eventString) - - val captor = argumentCaptor() - verify(mockEventProcessor, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) - - val error = captor.firstValue - assertThat(error).isInstanceOf(CheckoutExpiredException::class.java) - assertThat(error.message).isEqualTo( - "Checkout is no longer available with the provided token. Please generate a new checkout URL" + fun `respondWith posts JSON-RPC payload to window`() { + val jsonRpcMessage = """{ + "jsonrpc":"2.0", + "id":"request-id-response", + "method":"checkout.addressChangeRequested", + "params":{ + "addressType":"shipping" + } + }""".trimIndent() + + val mockWebView = mock() + checkoutBridge.setWebView(mockWebView) + checkoutBridge.postMessage(jsonRpcMessage) + + val eventCaptor = argumentCaptor() + verify(mockEventProcessor).onAddressChangeRequested(eventCaptor.capture()) + val event = eventCaptor.firstValue + + val payload = DeliveryAddressChangePayload( + delivery = CartDelivery( + addresses = listOf( + CartSelectableAddressInput( + CartDeliveryAddressInput(firstName = "Ada"), + ), + ), + ), ) - assertThat(error.isRecoverable).isFalse() - assertThat(error.errorCode).isEqualTo(CheckoutExpiredException.CART_EXPIRED) - } - @Test - fun `should decode an unrecoverable error payload and call processor#onCheckoutViewFailedWithError`() { - val eventString = """| - |{ - | "name":"error", - | "body": "[{ - | \"group\": \"unrecoverable\", - | \"reason\": \"Checkout crashed\", - | \"code\": \"sdk_not_enabled\" - | }]" - |} - |""".trimMargin() - - checkoutBridge.postMessage(eventString) - - val captor = argumentCaptor() - verify(mockEventProcessor, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) - - val error = captor.firstValue - assertThat(error).isInstanceOf(CheckoutUnavailableException::class.java) - assertThat(error.message).isEqualTo("Checkout crashed") - assertThat(error.isRecoverable).isTrue() - assertThat(error.errorCode).isEqualTo(CheckoutUnavailableException.CLIENT_ERROR) - } - @Test - fun `should decode a configuration error payload and call processor#onCheckoutViewFailedWithError - storefront pw required`() { - val eventString = """| - |{ - | "name":"error", - | "body": "[{ - | \"group\": \"configuration\", - | \"reason\": \"Storefront password required\", - | \"code\": \"storefront_password_required\" - | }]" - |} - |""".trimMargin() - - checkoutBridge.postMessage(eventString) - - val captor = argumentCaptor() - verify(mockEventProcessor, timeout(2000).times(1)).onCheckoutViewFailedWithError(captor.capture()) - - val error = captor.firstValue - assertThat(error).isInstanceOf(ConfigurationException::class.java) - assertThat(error.message).isEqualTo("Storefront password required") - assertThat(error.isRecoverable).isFalse() - assertThat(error.errorCode).isEqualTo(ConfigurationException.STOREFRONT_PASSWORD_REQUIRED) + event.respondWith(payload) + ShadowLooper.runUiThreadTasks() + + val scriptCaptor = argumentCaptor() + verify(mockWebView).evaluateJavascript(scriptCaptor.capture(), anyOrNull()) + assertThat(scriptCaptor.firstValue) + .contains("window.postMessage") + .contains("\"id\":\"request-id-response\"") + .contains("\"result\":") + .contains("\"firstName\":\"Ada\"") } @Test - fun `should ignore unsupported error payloads`() { - val eventString = """| - |{ - | "name":"error", - | "body": "[{ - | \"group\": \"authentication\", - | \"reason\": \"invalid signature\", - | \"code\": \"invalid_signature\" - | }]" - |} - |""".trimMargin() - - checkoutBridge.postMessage(eventString) + fun `respondWith handles evaluateJavascript failure gracefully without calling onCheckoutViewFailedWithError`() { + val jsonRpcMessage = """{ + "jsonrpc":"2.0", + "id":"request-id-error", + "method":"checkout.addressChangeRequested", + "params":{ + "addressType":"shipping" + } + }""".trimIndent() + + val invocationCount = AtomicInteger(0) + val mockWebView = mock() + whenever(mockWebView.evaluateJavascript(any(), anyOrNull())).thenAnswer { _ -> + if (invocationCount.getAndIncrement() == 0) { + error("Failed to evaluate JavaScript") + } + null + } + + checkoutBridge.setWebView(mockWebView) + checkoutBridge.postMessage(jsonRpcMessage) + + val eventCaptor = argumentCaptor() + verify(mockEventProcessor).onAddressChangeRequested(eventCaptor.capture()) + val event = eventCaptor.firstValue + + val payload = DeliveryAddressChangePayload( + delivery = CartDelivery( + addresses = listOf( + CartSelectableAddressInput( + CartDeliveryAddressInput(firstName = "Ada"), + ), + ), + ), + ) - verifyNoInteractions(mockEventProcessor) - } + event.respondWith(payload) + ShadowLooper.runUiThreadTasks() - @Test - fun `should call onCheckoutViewFailedWithError if message cannot be decoded`() { - val eventString = """| - |{ - | "name":"error - |} - |""".trimMargin() - - checkoutBridge.postMessage(eventString) - - val captor = argumentCaptor() - verify(mockEventProcessor).onCheckoutViewFailedWithError(captor.capture()) - - val error = captor.firstValue - assertThat(error).isInstanceOf(CheckoutSheetKitException::class.java) - assertThat(error.message).isEqualTo("Error decoding message from checkout.") - assertThat(error.isRecoverable).isTrue() - assertThat(error.errorCode).isEqualTo(CheckoutSheetKitException.ERROR_RECEIVING_MESSAGE_FROM_CHECKOUT) + val scriptCaptor = argumentCaptor() + verify(mockWebView).evaluateJavascript(scriptCaptor.capture(), anyOrNull()) + assertThat(scriptCaptor.firstValue).contains("window.postMessage") + + // Verify that evaluateJavascript failure doesn't trigger onCheckoutViewFailedWithError + verify(mockEventProcessor, never()).onCheckoutViewFailedWithError(any()) } } diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutCompleteEventDecoderTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutCompleteEventDecoderTest.kt new file mode 100644 index 00000000..d2e4f576 --- /dev/null +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutCompleteEventDecoderTest.kt @@ -0,0 +1,423 @@ +/* + * 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 + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.shopify.checkoutsheetkit + +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent.DiscountValue.MoneyValue +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent.DiscountValue.PercentageValue +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent.Money +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent.PricingPercentageValue +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEventDecoder +import kotlinx.serialization.json.Json +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +class CheckoutCompleteEventDecoderTest { + + private val mockLogWrapper = mock() + + private val decoder = CheckoutCompleteEventDecoder( + decoder = Json { ignoreUnknownKeys = true }, + log = mockLogWrapper + ) + + @Test + fun `should decode order confirmation details`() { + val result = decoder.decode(EXAMPLE_EVENT.toWebToSdkEvent()) + val orderConfirmation = result.orderConfirmation + + assertThat(orderConfirmation.order.id).isEqualTo("gid://shopify/Order/9697125302294") + assertThat(orderConfirmation.number).isEqualTo("1001") + assertThat(orderConfirmation.isFirstOrder).isTrue() + assertThat(orderConfirmation.url).isEqualTo("https://shopify.com/order-confirmation/9697125302294") + } + + @Test + fun `should decode cart line details`() { + val result = decoder.decode(EXAMPLE_EVENT.toWebToSdkEvent()) + val line = result.cart.lines.single() + + assertThat(line.id).isEqualTo("gid://shopify/CartLine/1") + assertThat(line.quantity).isEqualTo(1) + assertThat(line.merchandise.title).isEqualTo( + "The Box: How the Shipping Container Made the World Smaller and the World Economy Bigger" + ) + assertThat(line.merchandise.product.title).isEqualTo("The Box") + assertThat(line.merchandise.image?.url).isEqualTo( + "https://cdn.shopify.com/s/files/1/0692/3996/3670/files/product-image_256x256.jpg" + ) + assertThat(line.merchandise.selectedOptions).containsExactly( + CheckoutCompleteEvent.SelectedOption(name = "Format", value = "Hardcover") + ) + + val discountAllocation = line.discountAllocations.single() + assertThat(discountAllocation.discountedAmount).isEqualTo(Money(amount = "1.00", currencyCode = "GBP")) + assertThat(discountAllocation.discountApplication.value).isInstanceOf(PercentageValue::class.java) + assertThat((discountAllocation.discountApplication.value as PercentageValue).percentage) + .isEqualTo(PricingPercentageValue(percentage = 10.0)) + } + + @Test + fun `should decode cart level cost and discounts`() { + val result = decoder.decode(EXAMPLE_EVENT.toWebToSdkEvent()) + val cart = result.cart + + assertThat(cart.cost.subtotalAmount).isEqualTo(Money(amount = "8.00", currencyCode = "GBP")) + assertThat(cart.cost.totalAmount).isEqualTo(Money(amount = "13.99", currencyCode = "GBP")) + assertThat(cart.discountCodes).containsExactly( + CheckoutCompleteEvent.CartDiscountCode(code = "SUMMER", applicable = true) + ) + val giftCard = cart.appliedGiftCards.single() + assertThat(giftCard.amountUsed).isEqualTo(Money(amount = "10.00", currencyCode = "GBP")) + assertThat(giftCard.balance).isEqualTo(Money(amount = "15.00", currencyCode = "GBP")) + assertThat(giftCard.lastCharacters).isEqualTo("ABCD") + + val allocation = cart.discountAllocations.single() + assertThat(allocation.discountApplication.value).isInstanceOf(MoneyValue::class.java) + assertThat((allocation.discountApplication.value as MoneyValue).money) + .isEqualTo(Money(amount = "2.00", currencyCode = "GBP")) + } + + @Test + fun `should decode cart buyer identity and delivery details`() { + val result = decoder.decode(EXAMPLE_EVENT.toWebToSdkEvent()) + val cart = result.cart + + assertThat(cart.buyerIdentity.email).isEqualTo("a.user@shopify.com") + assertThat(cart.buyerIdentity.customer?.firstName).isEqualTo("Andrew") + assertThat(cart.buyerIdentity.countryCode).isEqualTo("GB") + + val deliveryGroup = cart.deliveryGroups.single() + assertThat(deliveryGroup.groupType).isEqualTo(CheckoutCompleteEvent.CartDeliveryGroupType.ONE_TIME_PURCHASE) + assertThat(deliveryGroup.deliveryAddress.city).isEqualTo("Swansea") + assertThat(deliveryGroup.deliveryOptions.single().deliveryMethodType) + .isEqualTo(CheckoutCompleteEvent.CartDeliveryMethodType.SHIPPING) + assertThat(deliveryGroup.selectedDeliveryOption?.handle).isEqualTo("standard-shipping") + + val deliveryAddress = cart.delivery.addresses.single().address + assertThat(deliveryAddress.address1).isEqualTo("100 Street Avenue") + assertThat(deliveryAddress.provinceCode).isEqualTo("WLS") + assertThat(deliveryAddress.zip).isEqualTo("SA1 1AB") + } + + @Test + fun `should fall back to empty event on decode failure`() { + val result = decoder.decode("not-json".toWebToSdkEvent()) + + assertThat(result.orderConfirmation.order.id).isEmpty() + assertThat(result.cart.lines).isEmpty() + } + + @Test + fun `should accept valid money amounts`() { + // Valid decimal amounts should not throw + Money(amount = "10.00", currencyCode = "USD") + Money(amount = "0", currencyCode = "GBP") + Money(amount = "99.99", currencyCode = "CAD") + Money(amount = "1234.567", currencyCode = "EUR") + } + + @Test(expected = IllegalArgumentException::class) + fun `should reject invalid money amount`() { + Money(amount = "not-a-number", currencyCode = "USD") + } + + @Test(expected = IllegalArgumentException::class) + fun `should reject blank currency code`() { + Money(amount = "10.00", currencyCode = "") + } + + @Test + fun `should fall back to empty event when money validation fails`() { + val invalidMoneyJson = """ + { + "orderConfirmation": { + "order": { "id": "gid://shopify/Order/123" }, + "isFirstOrder": true + }, + "cart": { + "id": "gid://shopify/Cart/123", + "cost": { + "subtotalAmount": { "amount": "invalid", "currencyCode": "USD" }, + "totalAmount": { "amount": "10.00", "currencyCode": "USD" } + }, + "buyerIdentity": {}, + "deliveryGroups": [], + "delivery": { "addresses": [] } + } + } + """.trimIndent() + + val result = decoder.decode(invalidMoneyJson.toWebToSdkEvent()) + + assertThat(result.orderConfirmation.order.id).isEmpty() + assertThat(result.cart.lines).isEmpty() + } + + @Test + fun `should fall back to empty event when discount value is not an object`() { + val invalidDiscountJson = """ + { + "orderConfirmation": { + "order": { "id": "gid://shopify/Order/123" }, + "isFirstOrder": true + }, + "cart": { + "id": "gid://shopify/Cart/123", + "cost": { + "subtotalAmount": { "amount": "10.00", "currencyCode": "USD" }, + "totalAmount": { "amount": "10.00", "currencyCode": "USD" } + }, + "buyerIdentity": {}, + "deliveryGroups": [], + "discountAllocations": [ + { + "discountedAmount": { "amount": "2.00", "currencyCode": "USD" }, + "discountApplication": { + "allocationMethod": "ACROSS", + "targetSelection": "ALL", + "targetType": "LINE_ITEM", + "value": "not-an-object" + }, + "targetType": "LINE_ITEM" + } + ], + "delivery": { "addresses": [] } + } + } + """.trimIndent() + + val result = decoder.decode(invalidDiscountJson.toWebToSdkEvent()) + + assertThat(result.orderConfirmation.order.id).isEmpty() + assertThat(result.cart.lines).isEmpty() + } + + private fun String.toWebToSdkEvent(): WebToSdkEvent { + return WebToSdkEvent( + name = CheckoutMessageContract.METHOD_COMPLETE, + body = this, + ) + } + + companion object { + private val EXAMPLE_EVENT = """ + { + "orderConfirmation": { + "url": "https://shopify.com/order-confirmation/9697125302294", + "order": { + "id": "gid://shopify/Order/9697125302294" + }, + "number": "1001", + "isFirstOrder": true + }, + "cart": { + "id": "gid://shopify/Cart/123", + "lines": [ + { + "id": "gid://shopify/CartLine/1", + "quantity": 1, + "merchandise": { + "id": "gid://shopify/ProductVariant/43835075002390", + "title": "The Box: How the Shipping Container Made the World Smaller and the World Economy Bigger", + "product": { + "id": "gid://shopify/Product/8013997834262", + "title": "The Box" + }, + "image": { + "url": "https://cdn.shopify.com/s/files/1/0692/3996/3670/files/product-image_256x256.jpg", + "altText": "Front cover" + }, + "selectedOptions": [ + { + "name": "Format", + "value": "Hardcover" + } + ] + }, + "cost": { + "amountPerQuantity": { + "amount": "8.00", + "currencyCode": "GBP" + }, + "subtotalAmount": { + "amount": "8.00", + "currencyCode": "GBP" + }, + "totalAmount": { + "amount": "8.00", + "currencyCode": "GBP" + } + }, + "discountAllocations": [ + { + "discountedAmount": { + "amount": "1.00", + "currencyCode": "GBP" + }, + "discountApplication": { + "allocationMethod": "ACROSS", + "targetSelection": "ALL", + "targetType": "LINE_ITEM", + "value": { + "percentage": 10.0 + } + }, + "targetType": "LINE_ITEM" + } + ] + } + ], + "cost": { + "subtotalAmount": { + "amount": "8.00", + "currencyCode": "GBP" + }, + "totalAmount": { + "amount": "13.99", + "currencyCode": "GBP" + } + }, + "buyerIdentity": { + "email": "a.user@shopify.com", + "phone": "+447915123456", + "customer": { + "id": "gid://shopify/Customer/12345", + "firstName": "Andrew", + "lastName": "Person", + "email": "a.user@shopify.com", + "phone": "+447915123456" + }, + "countryCode": "GB" + }, + "deliveryGroups": [ + { + "deliveryAddress": { + "address1": "100 Street Avenue", + "address2": "Unit 5", + "city": "Swansea", + "province": "Wales", + "country": "United Kingdom", + "countryCodeV2": "GB", + "zip": "SA1 1AB", + "firstName": "Andrew", + "lastName": "Person", + "phone": "+447915123456", + "company": "Shopify" + }, + "deliveryOptions": [ + { + "code": "standard", + "title": "Standard Shipping", + "description": "Arrives in 3-5 business days", + "handle": "standard-shipping", + "estimatedCost": { + "amount": "5.99", + "currencyCode": "GBP" + }, + "deliveryMethodType": "SHIPPING" + } + ], + "selectedDeliveryOption": { + "code": "standard", + "title": "Standard Shipping", + "description": "Arrives in 3-5 business days", + "handle": "standard-shipping", + "estimatedCost": { + "amount": "5.99", + "currencyCode": "GBP" + }, + "deliveryMethodType": "SHIPPING" + }, + "groupType": "ONE_TIME_PURCHASE" + } + ], + "discountCodes": [ + { + "code": "SUMMER", + "applicable": true + } + ], + "appliedGiftCards": [ + { + "amountUsed": { + "amount": "10.00", + "currencyCode": "GBP" + }, + "balance": { + "amount": "15.00", + "currencyCode": "GBP" + }, + "lastCharacters": "ABCD", + "presentmentAmountUsed": { + "amount": "10.00", + "currencyCode": "GBP" + } + } + ], + "discountAllocations": [ + { + "discountedAmount": { + "amount": "2.00", + "currencyCode": "GBP" + }, + "discountApplication": { + "allocationMethod": "ACROSS", + "targetSelection": "ALL", + "targetType": "SHIPPING_LINE", + "value": { + "amount": "2.00", + "currencyCode": "GBP" + } + }, + "targetType": "SHIPPING_LINE" + } + ], + "delivery": { + "addresses": [ + { + "address": { + "address1": "100 Street Avenue", + "address2": "Unit 5", + "city": "Swansea", + "company": "Shopify", + "countryCode": "GB", + "firstName": "Andrew", + "lastName": "Person", + "phone": "+447915123456", + "provinceCode": "WLS", + "zip": "SA1 1AB" + } + } + ] + } + } + } + """.trimIndent() + } +} diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutCompletedEventDecoderTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutCompletedEventDecoderTest.kt deleted file mode 100644 index 9def0f7a..00000000 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutCompletedEventDecoderTest.kt +++ /dev/null @@ -1,339 +0,0 @@ -/* - * 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 - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package com.shopify.checkoutsheetkit - -import com.shopify.checkoutsheetkit.lifecycleevents.Address -import com.shopify.checkoutsheetkit.lifecycleevents.CartLine -import com.shopify.checkoutsheetkit.lifecycleevents.CartLineImage -import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEventDecoder -import com.shopify.checkoutsheetkit.lifecycleevents.DeliveryDetails -import com.shopify.checkoutsheetkit.lifecycleevents.DeliveryInfo -import com.shopify.checkoutsheetkit.lifecycleevents.PaymentMethod -import com.shopify.checkoutsheetkit.lifecycleevents.Price -import com.shopify.checkoutsheetkit.pixelevents.MoneyV2 -import kotlinx.serialization.json.Json -import org.assertj.core.api.Assertions.assertThat -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mockito.mock -import org.mockito.junit.MockitoJUnitRunner - -@RunWith(MockitoJUnitRunner::class) -class CheckoutCompletedEventDecoderTest { - - private val mockLogWrapper = mock() - - private val decoder = CheckoutCompletedEventDecoder( - decoder = Json { ignoreUnknownKeys = true}, - log = mockLogWrapper - ) - - @Test - fun `should decode completion event order id`() { - val result = decoder.decode(EXAMPLE_EVENT.toWebToSdkEvent()) - val orderDetails = result.orderDetails - - assertThat(orderDetails.id).isEqualTo("gid://shopify/OrderIdentity/9697125302294") - } - - @Test - fun `should decode completion event order cart lines`() { - val result = decoder.decode(EXAMPLE_EVENT.toWebToSdkEvent()) - val orderDetails = result.orderDetails - - assertThat(orderDetails.cart.lines).isEqualTo( - listOf( - CartLine( - image = CartLineImage( - sm = "https://cdn.shopify.com/s/files/1/0692/3996/3670/files/41bc5767-d56f-432c-ac5f-6b9eeee3ba0e.truncated...", - md = "https://cdn.shopify.com/s/files/1/0692/3996/3670/files/41bc5767-d56f-432c-ac5f-6b9eeee3ba0e.truncated...", - lg = "https://cdn.shopify.com/s/files/1/0692/3996/3670/files/41bc5767-d56f-432c-ac5f-6b9eeee3ba0e.truncated...", - altText = null, - ), - quantity = 1, - title = "The Box: How the Shipping Container Made the World Smaller and the World Economy Bigger", - price = MoneyV2( - amount = 8.0, - currencyCode = "GBP", - ), - merchandiseId = "gid://shopify/ProductVariant/43835075002390", - productId = "gid://shopify/Product/8013997834262" - ) - ) - ) - } - - @Test - fun `should decode completion event order price`() { - val result = decoder.decode(EXAMPLE_EVENT.toWebToSdkEvent()) - val orderDetails = result.orderDetails - - assertThat(orderDetails.cart.price).isEqualTo( - Price( - total = MoneyV2( - amount = 13.99, - currencyCode = "GBP", - ), - subtotal = MoneyV2( - amount = 8.0, - currencyCode = "GBP", - ), - taxes = MoneyV2( - amount = 0.0, - currencyCode = "GBP", - ), - shipping = MoneyV2( - amount = 5.99, - currencyCode = "GBP", - ), - discounts = emptyList(), - ) - ) - } - - @Test - fun `should decode completion event email`() { - val result = decoder.decode(EXAMPLE_EVENT.toWebToSdkEvent()) - val orderDetails = result.orderDetails - - assertThat(orderDetails.email).isEqualTo("a.user@shopify.com") - } - - @Test - fun `should decode completion event billing address`() { - val result = decoder.decode(EXAMPLE_EVENT.toWebToSdkEvent()) - val orderDetails = result.orderDetails - - assertThat(orderDetails.billingAddress).isEqualTo( - Address( - city = "Swansea", - countryCode = "GB", - postalCode = "SA1 1AB", - address1 = "100 Street Avenue", - firstName = "Andrew", - lastName = "Person", - zoneCode = "WLS", - phone = "+447915123456", - ) - ) - } - - @Test - fun `should decode completion event payment methods`() { - val result = decoder.decode(EXAMPLE_EVENT.toWebToSdkEvent()) - val orderDetails = result.orderDetails - - assertThat(orderDetails.paymentMethods).isEqualTo( - listOf( - PaymentMethod( - type = "wallet", - details = mapOf( - "amount" to "13.99", - "currency" to "GBP", - "name" to "SHOP_PAY", - ) - ) - ) - ) - } - - @Test - fun `should decode completion event deliveries`() { - val result = decoder.decode(EXAMPLE_EVENT.toWebToSdkEvent()) - val orderDetails = result.orderDetails - - assertThat(orderDetails.deliveries).isEqualTo( - listOf( - DeliveryInfo( - method = "SHIPPING", - details = DeliveryDetails( - location = Address( - city = "Swansea", - countryCode = "GB", - postalCode = "SA1 1AB", - address1 = "100 Street Avenue", - name = "Andrew", - firstName = "Andrew", - lastName = "Person", - zoneCode = "WLS", - phone = "+447915123456", - ) - ) - ) - ) - ) - } - - private fun String.toWebToSdkEvent(): WebToSdkEvent { - return WebToSdkEvent( - name = "completed", - body = this, - ) - } - - companion object { - private val EXAMPLE_EVENT = """ - { - "flowType": "regular", - "orderDetails": { - "id": "gid://shopify/OrderIdentity/9697125302294", - "cart": { - "token": "123", - "lines": [ - { - "image": { - "sm": "https://cdn.shopify.com/s/files/1/0692/3996/3670/files/41bc5767-d56f-432c-ac5f-6b9eeee3ba0e.truncated...", - "md": "https://cdn.shopify.com/s/files/1/0692/3996/3670/files/41bc5767-d56f-432c-ac5f-6b9eeee3ba0e.truncated...", - "lg": "https://cdn.shopify.com/s/files/1/0692/3996/3670/files/41bc5767-d56f-432c-ac5f-6b9eeee3ba0e.truncated..." - }, - "quantity": 1, - "title": "The Box: How the Shipping Container Made the World Smaller and the World Economy Bigger", - "price": { - "amount": 8, - "currencyCode": "GBP" - }, - "merchandiseId": "gid://shopify/ProductVariant/43835075002390", - "productId": "gid://shopify/Product/8013997834262" - } - ], - "price": { - "total": { - "amount": 13.99, - "currencyCode": "GBP" - }, - "subtotal": { - "amount": 8, - "currencyCode": "GBP" - }, - "taxes": { - "amount": 0, - "currencyCode": "GBP" - }, - "shipping": { - "amount": 5.99, - "currencyCode": "GBP" - } - } - }, - "email": "a.user@shopify.com", - "shippingAddress": { - "city": "Swansea", - "countryCode": "GB", - "postalCode": "SA1 1AB", - "address1": "100 Street Avenue", - "firstName": "Andrew", - "lastName": "Person", - "name": "Andrew", - "zoneCode": "WLS", - "phone": "+447915123456", - "coordinates": { - "latitude": 54.5936785, - "longitude": -3.013167399999999 - } - }, - "billingAddress": { - "city": "Swansea", - "countryCode": "GB", - "postalCode": "SA1 1AB", - "address1": "100 Street Avenue", - "firstName": "Andrew", - "lastName": "Person", - "zoneCode": "WLS", - "phone": "+447915123456" - }, - "paymentMethods": [ - { - "type": "wallet", - "details": { - "amount": "13.99", - "currency": "GBP", - "name": "SHOP_PAY" - } - } - ], - "deliveries": [ - { - "method": "SHIPPING", - "details": { - "location": { - "city": "Swansea", - "countryCode": "GB", - "postalCode": "SA1 1AB", - "address1": "100 Street Avenue", - "firstName": "Andrew", - "lastName": "Person", - "name": "Andrew", - "zoneCode": "WLS", - "phone": "+447915123456", - "coordinates": { - "latitude": 54.5936785, - "longitude": -3.013167399999999 - } - } - } - } - ] - }, - "orderId": "gid://shopify/OrderIdentity/9697125302294", - "cart": { - "lines": [ - { - "image": { - "sm": "https://cdn.shopify.com/s/files/1/0692/3996/3670/files/41bc5767-d56f-432c-ac5f-6b9eeee3ba0e.__CR0_0_300_300_PT0_SX300_V1_64x64.jpg?v=1689689735", - "md": "https://cdn.shopify.com/s/files/1/0692/3996/3670/files/41bc5767-d56f-432c-ac5f-6b9eeee3ba0e.__CR0_0_300_300_PT0_SX300_V1_128x128.jpg?v=1689689735", - "lg": "https://cdn.shopify.com/s/files/1/0692/3996/3670/files/41bc5767-d56f-432c-ac5f-6b9eeee3ba0e.__CR0_0_300_300_PT0_SX300_V1_256x256.jpg?v=1689689735" - }, - "quantity": 1, - "title": "The Box: How the Shipping Container Made the World Smaller and the World Economy Bigger", - "price": { - "amount": 8, - "currencyCode": "GBP" - }, - "merchandiseId": "gid://shopify/ProductVariant/43835075002390", - "productId": "gid://shopify/Product/8013997834262" - } - ], - "price": { - "total": { - "amount": 13.99, - "currencyCode": "GBP" - }, - "subtotal": { - "amount": 8, - "currencyCode": "GBP" - }, - "taxes": { - "amount": 0, - "currencyCode": "CAD" - }, - "shipping": { - "amount": 5.99, - "currencyCode": "GBP" - } - } - } - } - """.trimIndent() - } -} diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutDialogTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutDialogTest.kt index 3aebd252..7ecf7b99 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutDialogTest.kt +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutDialogTest.kt @@ -31,8 +31,8 @@ import android.widget.RelativeLayout import androidx.activity.ComponentActivity import androidx.appcompat.widget.Toolbar import androidx.core.view.children -import com.shopify.checkoutsheetkit.lifecycleevents.emptyCompletedEvent -import org.assertj.core.api.Assertions.assertThat +import com.shopify.checkoutsheetkit.CheckoutAssertions.assertThat +import com.shopify.checkoutsheetkit.lifecycleevents.emptyCompleteEvent import org.assertj.core.api.Assertions.fail import org.awaitility.Awaitility.await import org.junit.After @@ -360,7 +360,11 @@ class CheckoutDialogTest { val layout = dialog.findViewById(R.id.checkoutSdkContainer) val fallbackView = layout.children.first { it is FallbackWebView } as FallbackWebView - assertThat(shadowOf(fallbackView).lastLoadedUrl).isEqualTo(checkoutUrl) + val loadedUrl = shadowOf(fallbackView).lastLoadedUrl + + assertThat(android.net.Uri.parse(loadedUrl)) + .hasBaseUrl("https://shopify.com") + .withEmbedParameters(EmbedFieldKey.RECOVERY to "true") } @Test @@ -376,10 +380,10 @@ class CheckoutDialogTest { val layout = dialog.findViewById(R.id.checkoutSdkContainer) val fallbackView = layout.children.first { it is FallbackWebView } as FallbackWebView - val completedEvent = emptyCompletedEvent() + val completeEvent = emptyCompleteEvent() - fallbackView.getEventProcessor().onCheckoutViewComplete(completedEvent) - verify(mockProcessor).onCheckoutCompleted(completedEvent) + fallbackView.getEventProcessor().onCheckoutViewComplete(completeEvent) + verify(mockProcessor).onCheckoutCompleted(completeEvent) } @Test @@ -515,6 +519,29 @@ class CheckoutDialogTest { ) } + @Test + fun `present with CheckoutOptions includes authentication in embed parameter`() { + val options = CheckoutOptions(authToken = "test-token-123") + + ShopifyCheckoutSheetKit.present("https://shopify.com", activity, processor, options) + + val dialog = ShadowDialog.getLatestDialog() + ShadowLooper.runUiThreadTasks() + + await().atMost(2, TimeUnit.SECONDS).until { + dialog.containsChildOfType(CheckoutWebView::class.java) + } + + val webView = dialog.findViewById(R.id.checkoutSdkContainer) + .children.firstOrNull { it is CheckoutWebView } as CheckoutWebView? ?: fail("No CheckoutWebView found") + + val shadowWebView = shadowOf(webView) + val lastUrl = shadowWebView.lastLoadedUrl + + assertThat(lastUrl).contains("embed=") + assertThat(lastUrl).contains("authentication%3Dtest-token-123") + } + private fun Dialog.containsChildOfType(clazz: Class): Boolean { val layout = this.findViewById(R.id.checkoutSdkContainer) return layout.children.any { clazz.isInstance(it) } diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewCacheTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewCacheTest.kt index 3516b1b9..62b5b1d7 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewCacheTest.kt +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewCacheTest.kt @@ -24,7 +24,8 @@ package com.shopify.checkoutsheetkit import android.os.Looper import androidx.activity.ComponentActivity -import org.assertj.core.api.Assertions.assertThat +import androidx.core.net.toUri +import com.shopify.checkoutsheetkit.CheckoutAssertions.assertThat import org.assertj.core.api.Assertions.assertThatNoException import org.junit.Before import org.junit.Test @@ -46,6 +47,9 @@ class CheckoutWebViewCacheTest { fun setUp() { CheckoutWebView.clearCache() shadowOf(Looper.getMainLooper()).runToEndOfTasks() + ShopifyCheckoutSheetKit.configure { + it.colorScheme = ColorScheme.Automatic() + } activity = Robolectric.buildActivity(ComponentActivity::class.java).get() eventProcessor = eventProcessor() @@ -57,7 +61,9 @@ class CheckoutWebViewCacheTest { val view = CheckoutWebView.cacheableCheckoutView(URL, activity) assertThat(view).isNotNull shadowOf(Looper.getMainLooper()).runToEndOfTasks() - assertThat(shadowOf(view).lastLoadedUrl).isEqualTo(URL) + assertThat(shadowOf(view).lastLoadedUrl?.toUri()) + .hasBaseUrl(URL) + .withEmbedParameters() } } @@ -69,8 +75,12 @@ class CheckoutWebViewCacheTest { shadowOf(Looper.getMainLooper()).runToEndOfTasks() assertThat(viewOne).isEqualTo(viewTwo) - assertThat(shadowOf(viewOne).lastLoadedUrl).isEqualTo(URL) - assertThat(shadowOf(viewTwo).lastLoadedUrl).isEqualTo(URL) + assertThat(shadowOf(viewOne).lastLoadedUrl?.toUri()) + .hasBaseUrl(URL) + .withEmbedParameters() + assertThat(shadowOf(viewTwo).lastLoadedUrl?.toUri()) + .hasBaseUrl(URL) + .withEmbedParameters() } } @@ -103,13 +113,42 @@ class CheckoutWebViewCacheTest { shadowOf(Looper.getMainLooper()).runToEndOfTasks() assertThat(viewOne).isNotEqualTo(viewTwo) - assertThat(shadowOf(viewOne).lastLoadedUrl).isEqualTo(URL) - assertThat(shadowOf(viewTwo).lastLoadedUrl).isEqualTo(newUrl) + assertThat(shadowOf(viewOne).lastLoadedUrl?.toUri()) + .hasBaseUrl(URL) + .withEmbedParameters() + assertThat(shadowOf(viewTwo).lastLoadedUrl?.toUri()) + .hasBaseUrl(newUrl) + .withEmbedParameters() assertThat(shadowOf(viewOne).wasDestroyCalled()).isTrue assertThat(shadowOf(viewTwo).wasDestroyCalled()).isFalse } } + @Test + fun `cache reuses view when only authentication changes`() { + withPreloadingEnabled { + val authOne = CheckoutOptions(authToken = "token-1") + val authTwo = CheckoutOptions(authToken = "token-2") + + val view = CheckoutWebView.cacheableCheckoutView(URL, activity, true, authOne) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + assertThat(shadowOf(view).lastLoadedUrl?.toUri()) + .hasBaseUrl(URL) + .withEmbedParameters(EmbedFieldKey.AUTHENTICATION to "token-1") + + val reusedView = CheckoutWebView.cacheableCheckoutView(URL, activity, true, authTwo) + assertThat(reusedView).isEqualTo(view) + + view.loadCheckout(URL, true, authTwo) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + assertThat(shadowOf(view).lastLoadedUrl?.toUri()) + .hasBaseUrl(URL) + .withEmbedParameters(EmbedFieldKey.AUTHENTICATION to "token-2") + } + } + @Test fun `cacheableCheckoutView returns the a new view for each call if preloading disabled`() { val viewOne = CheckoutWebView.cacheableCheckoutView(URL, activity, true) @@ -117,8 +156,12 @@ class CheckoutWebViewCacheTest { shadowOf(Looper.getMainLooper()).runToEndOfTasks() assertThat(viewOne).isNotEqualTo(viewTwo) - assertThat(shadowOf(viewOne).lastLoadedUrl).isEqualTo(URL) - assertThat(shadowOf(viewTwo).lastLoadedUrl).isEqualTo(URL) + assertThat(shadowOf(viewOne).lastLoadedUrl?.toUri()) + .hasBaseUrl(URL) + .withEmbedParameters() + assertThat(shadowOf(viewTwo).lastLoadedUrl?.toUri()) + .hasBaseUrl(URL) + .withEmbedParameters() assertThat(shadowOf(viewOne).wasDestroyCalled()).isTrue assertThat(shadowOf(viewTwo).wasDestroyCalled()).isFalse @@ -161,8 +204,12 @@ class CheckoutWebViewCacheTest { shadowOf(Looper.getMainLooper()).runToEndOfTasks() assertThat(viewOne).isNotEqualTo(viewTwo) - assertThat(shadowOf(viewOne).lastLoadedUrl).isEqualTo(URL) - assertThat(shadowOf(viewTwo).lastLoadedUrl).isEqualTo(URL) + assertThat(shadowOf(viewOne).lastLoadedUrl?.toUri()) + .hasBaseUrl(URL) + .withEmbedParameters() + assertThat(shadowOf(viewTwo).lastLoadedUrl?.toUri()) + .hasBaseUrl(URL) + .withEmbedParameters() } } diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewClientTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewClientTest.kt index 6bee1a03..e49a29ac 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewClientTest.kt +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewClientTest.kt @@ -29,8 +29,7 @@ import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebViewClient.ERROR_BAD_URL import androidx.activity.ComponentActivity -import com.shopify.checkoutsheetkit.CheckoutExceptionAssert.Companion.assertThat -import org.assertj.core.api.Assertions.assertThat +import com.shopify.checkoutsheetkit.CheckoutAssertions.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewTest.kt index 4f64b758..31408d07 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewTest.kt +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewTest.kt @@ -32,22 +32,19 @@ import android.webkit.PermissionRequest import android.webkit.ValueCallback import android.webkit.WebChromeClient.FileChooserParams import androidx.activity.ComponentActivity -import org.assertj.core.api.Assertions.assertThat +import androidx.core.net.toUri +import com.shopify.checkoutsheetkit.CheckoutAssertions.assertThat import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers.contains -import org.mockito.ArgumentMatchers.eq import org.mockito.Mockito.mock -import org.mockito.Mockito.spy import org.mockito.Mockito.verify import org.mockito.kotlin.whenever import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.shadows.ShadowLooper -import java.util.regex.Pattern @RunWith(RobolectricTestRunner::class) class CheckoutWebViewTest { @@ -64,7 +61,7 @@ class CheckoutWebViewTest { @After fun tearDown() { - ShopifyCheckoutSheetKit.configuration.platform = null + ShopifyCheckoutSheetKit.configuration.platform = Platform.ANDROID } @Test @@ -79,59 +76,11 @@ class CheckoutWebViewTest { assertThat(view.id).isNotNull assertThat(shadowOf(view).webViewClient.javaClass).isEqualTo(CheckoutWebView.CheckoutWebViewClient::class.java) assertThat(shadowOf(view).backgroundColor).isEqualTo(Color.TRANSPARENT) - assertThat(shadowOf(view).getJavascriptInterface("android").javaClass) + val shadowView = shadowOf(view) + assertThat(shadowView.getJavascriptInterface("EmbeddedCheckoutProtocolConsumer").javaClass) .isEqualTo(CheckoutBridge::class.java) } - @Test - fun `user agent suffix includes ShopifyCheckoutSDK and version number`() { - ShopifyCheckoutSheetKit.configuration.colorScheme = ColorScheme.Dark() - val view = CheckoutWebView.cacheableCheckoutView(URL, activity) - - assertThat(view.settings.userAgentString).contains("ShopifyCheckoutSDK/${BuildConfig.SDK_VERSION} ") - } - - @Test - fun `user agent suffix includes metadata for the schema version, theme, and variant - dark`() { - ShopifyCheckoutSheetKit.configuration.colorScheme = ColorScheme.Dark() - val view = CheckoutWebView.cacheableCheckoutView(URL, activity) - - assertThat(view.settings.userAgentString).endsWith("(8.1;dark;standard)") - } - - @Test - fun `user agent suffix includes metadata for the schema version, theme, and variant - light`() { - ShopifyCheckoutSheetKit.configuration.colorScheme = ColorScheme.Light() - val view = CheckoutWebView.cacheableCheckoutView(URL, activity) - - assertThat(view.settings.userAgentString).endsWith("(8.1;light;standard)") - } - - @Test - fun `user agent suffix includes metadata for the schema version, theme, and variant - web`() { - ShopifyCheckoutSheetKit.configuration.colorScheme = ColorScheme.Web() - val view = CheckoutWebView.cacheableCheckoutView(URL, activity) - - assertThat(view.settings.userAgentString).endsWith("(8.1;web_default;standard)") - } - - @Test - fun `user agent suffix includes metadata for the schema version, theme, and variant - automatic`() { - ShopifyCheckoutSheetKit.configuration.colorScheme = ColorScheme.Automatic() - val view = CheckoutWebView.cacheableCheckoutView(URL, activity) - - assertThat(view.settings.userAgentString).endsWith("(8.1;automatic;standard)") - } - - @Test - fun `user agent suffix includes platform if specified`() { - ShopifyCheckoutSheetKit.configuration.colorScheme = ColorScheme.Automatic() - ShopifyCheckoutSheetKit.configuration.platform = Platform.REACT_NATIVE - val view = CheckoutWebView.cacheableCheckoutView(URL, activity) - - assertThat(view.settings.userAgentString).endsWith("(8.1;automatic;standard) ReactNative") - } - @Test fun `sends prefetch header for preloads`() { withPreloadingEnabled { @@ -145,40 +94,6 @@ class CheckoutWebViewTest { } } - @Test - fun `records checkout_finished_loading instrumentation event on page finished - preloading`() { - withPreloadingEnabled { - val isPreload = true - val view = CheckoutWebView.cacheableCheckoutView(URL, activity, isPreload) - val shadow = shadowOf(view) - shadow.webViewClient.onPageFinished(view, URL) - - val regex = Pattern.compile( - @Suppress("MaxLineLength") - """.*\.dispatchMessage\('instrumentation', \{"detail":\{"name":"checkout_finished_loading","value":\d*,"type":"histogram","tags":\{"preloading":"true"}}}\).*""", - Pattern.DOTALL - ) - assertThat(shadow.lastEvaluatedJavascript).matches(regex) - } - } - - @Test - fun `records checkout_finished_loading instrumentation event on page finished - presenting`() { - withPreloadingEnabled { - val isPreload = false - val view = CheckoutWebView.cacheableCheckoutView(URL, activity, isPreload) - val shadow = shadowOf(view) - shadow.webViewClient.onPageFinished(view, URL) - - val regex = Pattern.compile( - @Suppress("MaxLineLength") - """.*\.dispatchMessage\('instrumentation', \{"detail":\{"name":"checkout_finished_loading","value":\d*,"type":"histogram","tags":\{"preloading":"false"}}}\).*""", - Pattern.DOTALL - ) - assertThat(shadow.lastEvaluatedJavascript).matches(regex) - } - } - @Test fun `does not send prefetch header for preloads`() { val isPreload = false @@ -197,7 +112,7 @@ class CheckoutWebViewTest { val shadow = shadowOf(view) shadow.callOnAttachedToWindow() - assertThat(shadow.getJavascriptInterface("android").javaClass) + assertThat(shadow.getJavascriptInterface("EmbeddedCheckoutProtocolConsumer").javaClass) .isEqualTo(CheckoutBridge::class.java) } @@ -208,23 +123,7 @@ class CheckoutWebViewTest { val shadow = shadowOf(view) shadow.callOnDetachedFromWindow() - assertThat(shadow.getJavascriptInterface("android")).isNull() - } - - @Test - fun `sends presented message each time when view is loaded if it has been presented`() { - val view = CheckoutWebView.cacheableCheckoutView(URL, activity) - - val shadow = shadowOf(view) - shadow.webViewClient.onPageFinished(view, "https://anything") - - val spy = spy(view) - spy.notifyPresented() - - verify(spy).evaluateJavascript( - contains("window.MobileCheckoutSdk.dispatchMessage('presented');"), - eq(null) - ) + assertThat(shadow.getJavascriptInterface("EmbeddedCheckoutProtocolConsumer")).isNull() } @Test @@ -289,6 +188,31 @@ class CheckoutWebViewTest { verify(webViewEventProcessor).onGeolocationPermissionsShowPrompt(origin, callback) } + @Test + fun `loadCheckout sends authentication once per token`() { + withPreloadingEnabled { + val options = CheckoutOptions(authToken = "token-1") + val view = CheckoutWebView.cacheableCheckoutView(URL, activity, true, options) + val shadow = shadowOf(view) + + ShadowLooper.shadowMainLooper().runToEndOfTasks() + + assertThat(shadow.lastLoadedUrl?.toUri()) + .hasBaseUrl(URL) + .withEmbedParameters(EmbedFieldKey.AUTHENTICATION to "token-1") + + view.CheckoutWebViewClient().onPageFinished(view, URL) + + view.loadCheckout(URL, false, options) + + ShadowLooper.shadowMainLooper().runToEndOfTasks() + + assertThat(shadow.lastLoadedUrl?.toUri()) + .hasBaseUrl(URL) + .withoutEmbedParameters(EmbedFieldKey.AUTHENTICATION) + } + } + @Test fun `should recover from errors`() { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt index baf36fcd..77a1d7e5 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/DefaultCheckoutEventProcessorTest.kt @@ -27,7 +27,7 @@ import android.content.IntentFilter import android.content.pm.PackageManager import android.net.Uri import androidx.activity.ComponentActivity -import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent import com.shopify.checkoutsheetkit.pixelevents.PixelEvent import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -129,7 +129,7 @@ class DefaultCheckoutEventProcessorTest { var recoverable: Boolean? = null val processor = object : DefaultCheckoutEventProcessor(activity, log) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { + override fun onCheckoutCompleted(checkoutCompleteEvent: CheckoutCompleteEvent) { /* not implemented */ } @@ -145,6 +145,10 @@ class DefaultCheckoutEventProcessorTest { override fun onWebPixelEvent(event: PixelEvent) { /* not implemented */ } + + override fun onAddressChangeRequested(event: CheckoutAddressChangeRequestedEvent) { + /* not implemented */ + } } val error = object : CheckoutUnavailableException("error description", "unknown", true) {} diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/EmbedParamsTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/EmbedParamsTest.kt new file mode 100644 index 00000000..b52e3321 --- /dev/null +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/EmbedParamsTest.kt @@ -0,0 +1,305 @@ +/* + * 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 + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkoutsheetkit + +import android.net.Uri +import androidx.core.net.toUri +import com.shopify.checkoutsheetkit.CheckoutAssertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class EmbedParamsTest { + + private lateinit var initialConfiguration: Configuration + + @Before + fun setUp() { + initialConfiguration = ShopifyCheckoutSheetKit.configuration.copy() + } + + @After + fun tearDown() { + ShopifyCheckoutSheetKit.configure { + it.colorScheme = initialConfiguration.colorScheme + it.preloading = initialConfiguration.preloading + it.errorRecovery = initialConfiguration.errorRecovery + it.platform = initialConfiguration.platform + } + } + + @Test + fun `withEmbedParam adds embed parameter with branding and color scheme for non-web color schemes`() { + ShopifyCheckoutSheetKit.configure { + it.colorScheme = ColorScheme.Dark() + } + + assertThat("https://example.com".toUri().withEmbedParam().toUri()) + .hasBaseUrl("https://example.com") + .withEmbedParameters(EmbedFieldKey.COLOR_SCHEME to "dark") + } + + @Test + fun `withEmbedParam adds embed parameter with only branding for web color scheme`() { + ShopifyCheckoutSheetKit.configure { + it.colorScheme = ColorScheme.Web() + } + + assertThat("https://example.com".toUri().withEmbedParam().toUri()) + .hasBaseUrl("https://example.com") + .withEmbedParameters(EmbedFieldKey.BRANDING to EmbedFieldValue.BRANDING_SHOP) + .withoutEmbedParameters(EmbedFieldKey.COLOR_SCHEME) + } + + @Test + fun `withEmbedParam preserves existing query parameters`() { + ShopifyCheckoutSheetKit.configure { + it.colorScheme = ColorScheme.Dark() + } + + assertThat("https://example.com?existing=value".toUri().withEmbedParam().toUri()) + .hasBaseUrl("https://example.com") + .hasQueryParameter("existing", "value") + .withEmbedParameters(EmbedFieldKey.COLOR_SCHEME to "dark") + } + + @Test + fun `withEmbedParam adds embed parameter with only branding for automatic color scheme`() { + ShopifyCheckoutSheetKit.configure { + it.colorScheme = ColorScheme.Automatic() + } + + assertThat("https://example.com".toUri().withEmbedParam().toUri()) + .hasBaseUrl("https://example.com") + .withEmbedParameters(EmbedFieldKey.BRANDING to EmbedFieldValue.BRANDING_APP) + .withoutEmbedParameters(EmbedFieldKey.COLOR_SCHEME) + } + + @Test + fun `withEmbedParam includes configured platform`() { + ShopifyCheckoutSheetKit.configure { + it.colorScheme = ColorScheme.Automatic() + it.platform = Platform.REACT_NATIVE + } + + assertThat("https://example.com".toUri().withEmbedParam().toUri()) + .hasBaseUrl("https://example.com") + .withEmbedParameters(EmbedFieldKey.PLATFORM to Platform.REACT_NATIVE.displayName) + } + + @Test + fun `withEmbedParam sets platform to react-native-android when using React Native`() { + ShopifyCheckoutSheetKit.configure { + it.platform = Platform.REACT_NATIVE + } + + assertThat("https://example.com".toUri().withEmbedParam().toUri()) + .withEmbedParameters(EmbedFieldKey.PLATFORM to "react-native-android") + } + + @Test + fun `withEmbedParam includes recovery flag when requested`() { + ShopifyCheckoutSheetKit.configure { + it.colorScheme = ColorScheme.Light() + } + + assertThat("https://example.com".toUri().withEmbedParam(isRecovery = true).toUri()) + .hasBaseUrl("https://example.com") + .withEmbedParameters( + EmbedFieldKey.COLOR_SCHEME to "light", + EmbedFieldKey.RECOVERY to "true", + ) + } + + @Test + fun `needsEmbedParam returns true when embed parameter is missing`() { + val uri = "https://example.com".toUri() + + val needsEmbed = uri.needsEmbedParam() + + assertThat(needsEmbed).isTrue + } + + @Test + fun `needsEmbedParam returns false when embed parameter matches current configuration`() { + ShopifyCheckoutSheetKit.configure { + it.colorScheme = ColorScheme.Dark() + } + + val embedValue = Uri.encode( + "protocol=${CheckoutBridge.SCHEMA_VERSION}," + + "branding=${EmbedFieldValue.BRANDING_APP}," + + "library=CheckoutKit/${BuildConfig.SDK_VERSION}," + + "sdk=${ShopifyCheckoutSheetKit.version.split("-").first()}," + + "platform=${Platform.ANDROID.displayName}," + + "entry=${EmbedFieldValue.ENTRY_SHEET}," + + "${EmbedFieldKey.COLOR_SCHEME}=dark" + ) + val uri = "https://example.com?${QueryParamKey.EMBED}=$embedValue".toUri() + + val needsEmbed = uri.needsEmbedParam() + + assertThat(needsEmbed).isFalse + } + + @Test + fun `needsEmbedParam returns true when embed parameter does not match current configuration`() { + ShopifyCheckoutSheetKit.configure { + it.colorScheme = ColorScheme.Light() // Configured as Light + } + + val embedValue = Uri.encode( + "protocol=${CheckoutBridge.SCHEMA_VERSION}," + + "branding=${EmbedFieldValue.BRANDING_APP}," + + "library=CheckoutKit/${BuildConfig.SDK_VERSION}," + + "sdk=${ShopifyCheckoutSheetKit.version.split("-").first()}," + + "platform=${Platform.ANDROID.displayName}," + + "entry=${EmbedFieldValue.ENTRY_SHEET}," + + "${EmbedFieldKey.COLOR_SCHEME}=dark" // But URL has Dark + ) + val uri = "https://example.com?${QueryParamKey.EMBED}=$embedValue".toUri() + + val needsEmbed = uri.needsEmbedParam() + + assertThat(needsEmbed).isTrue // Needs update because of mismatch + } + + @Test + fun `needsEmbedParam returns false when embed parameter exists alongside other query parameters`() { + ShopifyCheckoutSheetKit.configure { + it.colorScheme = ColorScheme.Dark() + } + + val embedValue = Uri.encode( + "protocol=${CheckoutBridge.SCHEMA_VERSION}," + + "branding=${EmbedFieldValue.BRANDING_APP}," + + "library=CheckoutKit/${BuildConfig.SDK_VERSION}," + + "sdk=${ShopifyCheckoutSheetKit.version.split("-").first()}," + + "platform=${Platform.ANDROID.displayName}," + + "entry=${EmbedFieldValue.ENTRY_SHEET}," + + "${EmbedFieldKey.COLOR_SCHEME}=dark" + ) + val uri = "https://example.com?other=value&${QueryParamKey.EMBED}=$embedValue".toUri() + + val needsEmbed = uri.needsEmbedParam() + + assertThat(needsEmbed).isFalse + } + + @Test + fun `Uri withEmbedParam is idempotent`() { + ShopifyCheckoutSheetKit.configure { + it.colorScheme = ColorScheme.Light() + } + + val url = "https://example.com".toUri() + val first = url.withEmbedParam() + val second = first.toUri().withEmbedParam() + + assertThat(second).isEqualTo(first) + } + + @Test + fun `needsEmbedParam returns true for URL without embed parameter`() { + val uri = "https://example.com".toUri() + + val needsEmbed = uri.needsEmbedParam() + + assertThat(needsEmbed).isTrue + } + + @Test + fun `needsEmbedParam returns false for URL with current embed parameter`() { + val withEmbed = "https://example.com".toUri().withEmbedParam() + val uri = withEmbed.toUri() + + val needsEmbed = uri.needsEmbedParam() + + assertThat(needsEmbed).isFalse + } + + @Test + fun `withEmbedParam adds authentication when provided`() { + val options = CheckoutOptions( + authToken = "token-123", + ) + val uri = "https://example.com".toUri() + + val result = uri.withEmbedParam(options = options) + + assertThat(result.toUri()) + .withEmbedParameters(EmbedFieldKey.AUTHENTICATION to "token-123") + } + + @Test + fun `needsEmbedParam ignores authentication field when comparing embed parameters`() { + val options = CheckoutOptions( + authToken = "token-abc", + ) + val withEmbed = "https://example.com".toUri().withEmbedParam(options = options) + val uri = withEmbed.toUri() + + val needsEmbed = uri.needsEmbedParam(options = options) + + assertThat(needsEmbed).isFalse + } + + @Test + fun `withEmbedParam excludes authentication when includeAuthentication is false`() { + val options = CheckoutOptions(authToken = "token-123") + val uri = "https://example.com".toUri() + + val result = uri.withEmbedParam(options = options, includeAuthentication = false) + + assertThat(result.toUri()) + .withoutEmbedParameters(EmbedFieldKey.AUTHENTICATION) + } + + @Test + fun `withEmbedParam replaces existing authentication token in embed parameter`() { + val oldOptions = CheckoutOptions(authToken = "old-token") + val newOptions = CheckoutOptions(authToken = "new-token") + val withOldToken = "https://example.com".toUri() + .withEmbedParam(options = oldOptions) + val uri = withOldToken.toUri() + + val result = uri.withEmbedParam(options = newOptions) + + assertThat(result.toUri()) + .withEmbedParameters(EmbedFieldKey.AUTHENTICATION to "new-token") + } + + @Test + fun `withEmbedParam excludes authentication when options authToken is null`() { + val options = CheckoutOptions(authToken = null) + val uri = "https://example.com".toUri() + + val result = uri.withEmbedParam(options = options) + + assertThat(result.toUri()) + .withoutEmbedParameters(EmbedFieldKey.AUTHENTICATION) + } +} diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/FallbackWebViewClientTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/FallbackWebViewClientTest.kt index c2a6e017..23b8252c 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/FallbackWebViewClientTest.kt +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/FallbackWebViewClientTest.kt @@ -23,7 +23,7 @@ package com.shopify.checkoutsheetkit import androidx.activity.ComponentActivity -import com.shopify.checkoutsheetkit.lifecycleevents.emptyCompletedEvent +import com.shopify.checkoutsheetkit.lifecycleevents.emptyCompleteEvent import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.mock @@ -46,7 +46,7 @@ class FallbackWebViewClientTest { val client = view.FallbackWebViewClient() client.onPageFinished(view, "https://abc.com/cn-12345678/thank-you?a=b") - verify(mockProcessor).onCheckoutViewComplete(emptyCompletedEvent()) + verify(mockProcessor).onCheckoutViewComplete(emptyCompleteEvent()) } } @@ -60,7 +60,7 @@ class FallbackWebViewClientTest { val client = view.FallbackWebViewClient() client.onPageFinished(view, "https://abc.com/cn-12345678/thank_you") - verify(mockProcessor).onCheckoutViewComplete(emptyCompletedEvent()) + verify(mockProcessor).onCheckoutViewComplete(emptyCompleteEvent()) } } @@ -74,7 +74,7 @@ class FallbackWebViewClientTest { val client = view.FallbackWebViewClient() client.onPageFinished(view, "https://abc.com/cn-12345678/tHAnk_you") - verify(mockProcessor).onCheckoutViewComplete(emptyCompletedEvent()) + verify(mockProcessor).onCheckoutViewComplete(emptyCompleteEvent()) } } @@ -88,7 +88,7 @@ class FallbackWebViewClientTest { val client = view.FallbackWebViewClient() client.onPageFinished(view, "https://abc.com/cn-12345678/thank-you?order_id=123") - verify(mockProcessor).onCheckoutViewComplete(emptyCompletedEvent(id = "123")) + verify(mockProcessor).onCheckoutViewComplete(emptyCompleteEvent(id = "123")) } } diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/FallbackWebViewTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/FallbackWebViewTest.kt index cb411f67..b193ff57 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/FallbackWebViewTest.kt +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/FallbackWebViewTest.kt @@ -26,7 +26,8 @@ import android.graphics.Color import android.view.View.VISIBLE import android.view.ViewGroup.LayoutParams.MATCH_PARENT import androidx.activity.ComponentActivity -import org.assertj.core.api.Assertions.assertThat +import androidx.core.net.toUri +import com.shopify.checkoutsheetkit.CheckoutAssertions.assertThat import org.junit.After import org.junit.Test import org.junit.runner.RunWith @@ -41,7 +42,7 @@ class FallbackWebViewTest { @After fun tearDown() { - ShopifyCheckoutSheetKit.configuration.platform = null + ShopifyCheckoutSheetKit.configuration.platform = Platform.ANDROID } @Test @@ -62,82 +63,69 @@ class FallbackWebViewTest { } @Test - fun `user agent suffix includes ShopifyCheckoutSDK and version number`() { - ShopifyCheckoutSheetKit.configuration.colorScheme = ColorScheme.Dark() + fun `calls update progress when new progress is reported by WebChromeClient`() { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> val view = FallbackWebView(activityController.get()) - assertThat(view.settings.userAgentString).contains("ShopifyCheckoutSDK/${BuildConfig.SDK_VERSION} ") - } - } + val webViewEventProcessor = mock() + view.setEventProcessor(webViewEventProcessor) - @Test - fun `user agent suffix includes metadata for the schema version, theme, and variant - dark`() { - ShopifyCheckoutSheetKit.configuration.colorScheme = ColorScheme.Dark() - Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> - val view = FallbackWebView(activityController.get()) - assertThat(view.settings.userAgentString).endsWith("(noconnect;dark;standard_recovery)") - } - } + val shadow = shadowOf(view) - @Test - fun `user agent suffix includes metadata for the schema version, theme, and variant - light`() { - ShopifyCheckoutSheetKit.configuration.colorScheme = ColorScheme.Light() - Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> - val view = FallbackWebView(activityController.get()) - assertThat(view.settings.userAgentString).endsWith("(noconnect;light;standard_recovery)") - } - } + shadow.webChromeClient?.onProgressChanged(view, 20) + verify(webViewEventProcessor).updateProgressBar(20) - @Test - fun `user agent suffix includes metadata for the schema version, theme, and variant - web`() { - ShopifyCheckoutSheetKit.configuration.colorScheme = ColorScheme.Web() - Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> - val view = FallbackWebView(activityController.get()) - assertThat(view.settings.userAgentString).endsWith("(noconnect;web_default;standard_recovery)") + shadow.webChromeClient?.onProgressChanged(view, 50) + verify(webViewEventProcessor).updateProgressBar(50) } } @Test - fun `user agent suffix includes metadata for the schema version, theme, and variant - automatic`() { - ShopifyCheckoutSheetKit.configuration.colorScheme = ColorScheme.Automatic() + fun `should not recover from errors`() { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> val view = FallbackWebView(activityController.get()) - assertThat(view.settings.userAgentString).endsWith("(noconnect;automatic;standard_recovery)") + assertThat(view.recoverErrors).isFalse() } } @Test - fun `user agent suffix includes platform if specified`() { - ShopifyCheckoutSheetKit.configuration.colorScheme = ColorScheme.Automatic() - ShopifyCheckoutSheetKit.configuration.platform = Platform.REACT_NATIVE + fun `loadUrl adds recovery flag to embed parameter`() { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> val view = FallbackWebView(activityController.get()) - assertThat(view.settings.userAgentString).endsWith("(noconnect;automatic;standard_recovery) ReactNative") + + val url = "https://checkout.shopify.com".toUri().withEmbedParam(isRecovery = true) + view.loadUrl(url) + + assertThat(shadowOf(view).lastLoadedUrl.toUri()) + .hasBaseUrl("https://checkout.shopify.com") + .withEmbedParameters(EmbedFieldKey.RECOVERY to "true") } } @Test - fun `calls update progress when new progress is reported by WebChromeClient`() { + fun `loadCheckout adds authentication once for fallback view`() { Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> val view = FallbackWebView(activityController.get()) - val webViewEventProcessor = mock() - view.setEventProcessor(webViewEventProcessor) - val shadow = shadowOf(view) + view.loadCheckout( + url = "https://checkout.shopify.com", + options = CheckoutOptions(authToken = "token-1") + ) - shadow.webChromeClient?.onProgressChanged(view, 20) - verify(webViewEventProcessor).updateProgressBar(20) + view.FallbackWebViewClient().onPageFinished(view, "https://checkout.shopify.com") - shadow.webChromeClient?.onProgressChanged(view, 50) - verify(webViewEventProcessor).updateProgressBar(50) - } - } + assertThat(shadowOf(view).lastLoadedUrl.toUri()) + .hasBaseUrl("https://checkout.shopify.com") + .withEmbedParameters( + EmbedFieldKey.RECOVERY to "true", + EmbedFieldKey.AUTHENTICATION to "token-1", + ) - @Test - fun `should not recover from errors`() { - Robolectric.buildActivity(ComponentActivity::class.java).use { activityController -> - val view = FallbackWebView(activityController.get()) - assertThat(view.recoverErrors).isFalse() + view.loadCheckout("https://checkout.shopify.com") + + assertThat(shadowOf(view).lastLoadedUrl.toUri()) + .hasBaseUrl("https://checkout.shopify.com") + .withEmbedParameters(EmbedFieldKey.RECOVERY to "true") + .withoutEmbedParameters(EmbedFieldKey.AUTHENTICATION) } } } diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/Helpers.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/Helpers.kt index 56ce6b53..c7762bd5 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/Helpers.kt +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/Helpers.kt @@ -23,7 +23,8 @@ package com.shopify.checkoutsheetkit import android.app.Activity -import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent +import android.net.Uri +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent import org.assertj.core.api.AbstractAssert fun withPreloadingEnabled(block: () -> Unit) { @@ -37,11 +38,6 @@ fun withPreloadingEnabled(block: () -> Unit) { class CheckoutExceptionAssert(actual: CheckoutException) : AbstractAssert(actual, CheckoutExceptionAssert::class.java) { - companion object { - fun assertThat(actual: CheckoutException): CheckoutExceptionAssert { - return CheckoutExceptionAssert(actual) - } - } fun isRecoverable(): CheckoutExceptionAssert { isNotNull() @@ -101,7 +97,7 @@ class CheckoutExceptionAssert(actual: CheckoutException) : fun noopDefaultCheckoutEventProcessor(activity: Activity, log: LogWrapper = LogWrapper()): DefaultCheckoutEventProcessor { return object : DefaultCheckoutEventProcessor(activity, log) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { + override fun onCheckoutCompleted(checkoutCompleteEvent: CheckoutCompleteEvent) { // no-op } @@ -112,5 +108,108 @@ fun noopDefaultCheckoutEventProcessor(activity: Activity, log: LogWrapper = LogW override fun onCheckoutCanceled() { // no-op } + + override fun onAddressChangeRequested(event: CheckoutAddressChangeRequestedEvent) { + // no-op + } + } +} + +class EmbedParamAssert(actual: Uri?) : + AbstractAssert(actual, EmbedParamAssert::class.java) { + + fun hasBaseUrl(expected: String): EmbedParamAssert { + val uri = actualUri() + val scheme = uri.scheme?.let { "$it://" } ?: "" + val authority = uri.encodedAuthority.orEmpty() + val path = uri.encodedPath.takeUnless { it.isNullOrEmpty() } ?: "" + val base = "$scheme$authority$path" + if (base != expected) { + failWithMessage("Expected base URL to be <%s> but was <%s>", expected, base) + } + return this + } + + fun hasQueryParameter(key: String, expectedValue: String): EmbedParamAssert { + val uri = actualUri() + val actualValue = uri.getQueryParameter(key) + if (actualValue != expectedValue) { + failWithMessage( + "Expected query parameter <%s> to have value <%s> but was <%s>", + key, + expectedValue, + actualValue, + ) + } + return this + } + + fun hasEmbedParamExactly(expected: Map): EmbedParamAssert { + val actualMap = embedMap() + if (actualMap != expected) { + failWithMessage("Expected embed param <%s> but was <%s>", expected, actualMap) + } + return this + } + + fun withEmbedParameters(vararg expectedEntries: Pair): EmbedParamAssert { + val includeRecovery = expectedEntries.any { (key, value) -> + key == EmbedFieldKey.RECOVERY && value.equals("true", ignoreCase = true) + } + + val expected = defaultEmbed(includeRecovery).toMutableMap() + expectedEntries.forEach { (key, value) -> + expected[key] = value + } + + return hasEmbedParamExactly(expected) + } + + fun withoutEmbedParameters(vararg keys: String): EmbedParamAssert { + val actualMap = embedMap() + keys.forEach { key -> + if (actualMap.containsKey(key)) { + failWithMessage("Expected embed parameter <%s> to be absent", key) + } + } + return this + } + + private fun actualUri(): Uri { + isNotNull + return actual!! + } + + private fun embedMap(): Map { + val uri = actualUri() + val encoded = uri.getQueryParameter(QueryParamKey.EMBED) + if (encoded == null) { + failWithMessage("Expected query parameter '%s' to be present", QueryParamKey.EMBED) + return emptyMap() + } + + val decoded = Uri.decode(encoded).trim() + return parseEmbed(decoded) + } + + private fun defaultEmbed(includeRecovery: Boolean): Map { + val embedValue = EmbedParamBuilder.build(isRecovery = includeRecovery) + return parseEmbed(embedValue) + } + + private fun parseEmbed(value: String): Map { + if (value.isEmpty()) { + return emptyMap() + } + + return value.split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } + .associate { entry -> + val parts = entry.split("=", limit = 2) + val key = parts[0] + val entryValue = parts.getOrElse(1) { "" } + key to entryValue + } } } diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/InteropTest.java b/lib/src/test/java/com/shopify/checkoutsheetkit/InteropTest.java index a9f14fd1..6c9d3c10 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/InteropTest.java +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/InteropTest.java @@ -6,8 +6,8 @@ import androidx.annotation.NonNull; import com.shopify.checkoutsheetkit.errorevents.CheckoutErrorDecoder; -import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent; -import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEventDecoder; +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent; +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEventDecoder; import com.shopify.checkoutsheetkit.pixelevents.PixelEvent; import com.shopify.checkoutsheetkit.pixelevents.PixelEventDecoder; import com.shopify.checkoutsheetkit.pixelevents.StandardPixelEvent; @@ -31,106 +31,67 @@ @RunWith(RobolectricTestRunner.class) public class InteropTest { private final String EXAMPLE_EVENT = "{\n" + - " \"orderDetails\": {\n" + - " \"id\": \"gid://shopify/OrderIdentity/9697125302294\",\n" + - " \"cart\": {\n" + - " \"token\": \"123abc\",\n" + - " \"lines\": [\n" + - " {\n" + - " \"image\": {\n" + - " \"sm\": \"https://cdn.shopify.com/s/files/1/0692/3996/3670/files/41bc5767-d56f-432c-ac5f-6b9eeee3ba0e.truncated...\",\n" + - " \"md\": \"https://cdn.shopify.com/s/files/1/0692/3996/3670/files/41bc5767-d56f-432c-ac5f-6b9eeee3ba0e.truncated...\",\n" + - " \"lg\": \"https://cdn.shopify.com/s/files/1/0692/3996/3670/files/41bc5767-d56f-432c-ac5f-6b9eeee3ba0e.truncated...\"\n" + - " },\n" + - " \"quantity\": 1,\n" + - " \"title\": \"The Box: How the Shipping Container Made the World Smaller and the World Economy Bigger\",\n" + - " \"price\": {\n" + - " \"amount\": 8,\n" + - " \"currencyCode\": \"GBP\"\n" + - " },\n" + - " \"merchandiseId\": \"gid://shopify/ProductVariant/43835075002390\",\n" + - " \"productId\": \"gid://shopify/Product/8013997834262\"\n" + - " }\n" + - " ],\n" + - " \"price\": {\n" + - " \"total\": {\n" + - " \"amount\": 13.99,\n" + - " \"currencyCode\": \"GBP\"\n" + - " },\n" + - " \"subtotal\": {\n" + - " \"amount\": 8,\n" + - " \"currencyCode\": \"GBP\"\n" + - " },\n" + - " \"taxes\": {\n" + - " \"amount\": 0,\n" + - " \"currencyCode\": \"GBP\"\n" + - " },\n" + - " \"shipping\": {\n" + - " \"amount\": 5.99,\n" + - " \"currencyCode\": \"GBP\"\n" + - " }\n" + + " \"orderConfirmation\": {\n" + + " \"url\": \"https://shopify.com/order-confirmation/9697125302294\",\n" + + " \"order\": {\n" + + " \"id\": \"gid://shopify/Order/9697125302294\"\n" + + " },\n" + + " \"number\": \"1001\",\n" + + " \"isFirstOrder\": true\n" + + " },\n" + + " \"cart\": {\n" + + " \"id\": \"gid://shopify/Cart/123\",\n" + + " \"lines\": [\n" + + " {\n" + + " \"id\": \"gid://shopify/CartLine/1\",\n" + + " \"quantity\": 1,\n" + + " \"merchandise\": {\n" + + " \"id\": \"gid://shopify/ProductVariant/43835075002390\",\n" + + " \"title\": \"The Box: How the Shipping Container Made the World Smaller and the World Economy Bigger\",\n" + + " \"product\": {\n" + + " \"id\": \"gid://shopify/Product/8013997834262\",\n" + + " \"title\": \"The Box\"\n" + " }\n" + " },\n" + - " \"email\": \"a.user@shopify.com\",\n" + - " \"shippingAddress\": {\n" + - " \"city\": \"Swansea\",\n" + - " \"countryCode\": \"GB\",\n" + - " \"postalCode\": \"SA1 1AB\",\n" + - " \"address1\": \"100 Street Avenue\",\n" + - " \"firstName\": \"Andrew\",\n" + - " \"lastName\": \"Person\",\n" + - " \"name\": \"Andrew\",\n" + - " \"zoneCode\": \"WLS\",\n" + - " \"phone\": \"+447915123456\",\n" + - " \"coordinates\": {\n" + - " \"latitude\": 54.5936785,\n" + - " \"longitude\": -3.013167399999999\n" + + " \"cost\": {\n" + + " \"amountPerQuantity\": {\n" + + " \"amount\": \"8.00\",\n" + + " \"currencyCode\": \"GBP\"\n" + + " },\n" + + " \"subtotalAmount\": {\n" + + " \"amount\": \"8.00\",\n" + + " \"currencyCode\": \"GBP\"\n" + + " },\n" + + " \"totalAmount\": {\n" + + " \"amount\": \"8.00\",\n" + + " \"currencyCode\": \"GBP\"\n" + " }\n" + " },\n" + - " \"billingAddress\": {\n" + - " \"city\": \"Swansea\",\n" + - " \"countryCode\": \"GB\",\n" + - " \"postalCode\": \"SA1 1AB\",\n" + - " \"address1\": \"100 Street Avenue\",\n" + - " \"firstName\": \"Andrew\",\n" + - " \"lastName\": \"Person\",\n" + - " \"zoneCode\": \"WLS\",\n" + - " \"phone\": \"+447915123456\"\n" + - " },\n" + - " \"paymentMethods\": [\n" + - " {\n" + - " \"type\": \"wallet\",\n" + - " \"details\": {\n" + - " \"amount\": \"13.99\",\n" + - " \"currency\": \"GBP\",\n" + - " \"name\": \"SHOP_PAY\"\n" + - " }\n" + - " }\n" + - " ],\n" + - " \"deliveries\": [\n" + - " {\n" + - " \"method\": \"SHIPPING\",\n" + - " \"details\": {\n" + - " \"location\": {\n" + - " \"city\": \"Swansea\",\n" + - " \"countryCode\": \"GB\",\n" + - " \"postalCode\": \"SA1 1AB\",\n" + - " \"address1\": \"100 Street Avenue\",\n" + - " \"firstName\": \"Andrew\",\n" + - " \"lastName\": \"Person\",\n" + - " \"name\": \"Andrew\",\n" + - " \"zoneCode\": \"WLS\",\n" + - " \"phone\": \"+447915123456\",\n" + - " \"coordinates\": {\n" + - " \"latitude\": 54.5936785,\n" + - " \"longitude\": -3.013167399999999\n" + - " }\n" + - " }\n" + - " }\n" + - " }\n" + - " ]\n" + + " \"discountAllocations\": []\n" + " }\n" + - " }"; + " ],\n" + + " \"cost\": {\n" + + " \"subtotalAmount\": {\n" + + " \"amount\": \"8.00\",\n" + + " \"currencyCode\": \"GBP\"\n" + + " },\n" + + " \"totalAmount\": {\n" + + " \"amount\": \"13.99\",\n" + + " \"currencyCode\": \"GBP\"\n" + + " }\n" + + " },\n" + + " \"buyerIdentity\": {\n" + + " \"email\": \"a.user@shopify.com\"\n" + + " },\n" + + " \"deliveryGroups\": [],\n" + + " \"discountCodes\": [],\n" + + " \"appliedGiftCards\": [],\n" + + " \"discountAllocations\": [],\n" + + " \"delivery\": {\n" + + " \"addresses\": []\n" + + " }\n" + + " }\n" + + "}"; private Configuration initialConfiguration = null; @Before @@ -157,7 +118,7 @@ public void onWebPixelEvent(@NonNull PixelEvent event) { } @Override - public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent checkoutCompletedEvent) { + public void onCheckoutCompleted(@NonNull CheckoutCompleteEvent checkoutCompletedEvent) { } @@ -170,6 +131,11 @@ public void onCheckoutFailed(@NonNull CheckoutException error) { public void onCheckoutCanceled() { } + + @Override + public void onAddressChangeRequested(@NonNull CheckoutAddressChangeRequestedEvent event) { + + } }; assertThat(processor).isNotNull(); @@ -240,19 +206,21 @@ public void canAccessFieldsOnExceptions() { @SuppressWarnings("all") @Test public void canAccessFieldsOnCheckoutCompletedEvent() { - WebToSdkEvent webEvent = new WebToSdkEvent("completed", EXAMPLE_EVENT); + WebToSdkEvent webEvent = new WebToSdkEvent(CheckoutMessageContract.METHOD_COMPLETE, EXAMPLE_EVENT); Json json = JsonKt.Json(Json.Default, b -> { b.setIgnoreUnknownKeys(true); return null; }); - CheckoutCompletedEventDecoder decoder = new CheckoutCompletedEventDecoder(json); + CheckoutCompleteEventDecoder decoder = new CheckoutCompleteEventDecoder(json); - CheckoutCompletedEvent event = decoder.decode(webEvent); + CheckoutCompleteEvent event = decoder.decode(webEvent); - assertThat(event.getOrderDetails().getId()) - .isEqualTo("gid://shopify/OrderIdentity/9697125302294"); - assertThat(event.getOrderDetails().getCart().getLines().get(0).getPrice().getAmount()) - .isEqualTo(8.0); + assertThat(event.getOrderConfirmation().getOrder().getId()) + .isEqualTo("gid://shopify/Order/9697125302294"); + assertThat(event.getCart().getCost().getTotalAmount().getAmount()) + .isEqualTo("13.99"); + assertThat(event.getCart().getLines().get(0).getMerchandise().getProduct().getTitle()) + .isEqualTo("The Box"); } @Test @@ -296,7 +264,7 @@ public void presentReturnsAHandleToAllowDismissingDialog() { activity, new DefaultCheckoutEventProcessor(activity) { @Override - public void onCheckoutCompleted(@NonNull CheckoutCompletedEvent checkoutCompletedEvent) { + public void onCheckoutCompleted(@NonNull CheckoutCompleteEvent checkoutCompletedEvent) { // do nothing } @@ -309,6 +277,11 @@ public void onCheckoutFailed(@NonNull CheckoutException error) { public void onCheckoutCanceled() { // do nothing } + + @Override + public void onAddressChangeRequested(@NonNull CheckoutAddressChangeRequestedEvent event) { + // do nothing + } } ); diff --git a/lib/src/test/java/com/shopify/checkoutsheetkit/ShopifyCheckoutSheetKitTest.kt b/lib/src/test/java/com/shopify/checkoutsheetkit/ShopifyCheckoutSheetKitTest.kt index 697c94ed..056192c8 100644 --- a/lib/src/test/java/com/shopify/checkoutsheetkit/ShopifyCheckoutSheetKitTest.kt +++ b/lib/src/test/java/com/shopify/checkoutsheetkit/ShopifyCheckoutSheetKitTest.kt @@ -23,7 +23,8 @@ package com.shopify.checkoutsheetkit import androidx.activity.ComponentActivity -import org.assertj.core.api.Assertions.assertThat +import androidx.core.net.toUri +import com.shopify.checkoutsheetkit.CheckoutAssertions.assertThat import org.junit.After import org.junit.Before import org.junit.Test @@ -40,6 +41,7 @@ class ShopifyCheckoutSheetKitTest { fun setUp() { ShopifyCheckoutSheetKit.configure { it.preloading = Preloading(enabled = false) + it.colorScheme = ColorScheme.Automatic() } } @@ -148,7 +150,9 @@ class ShopifyCheckoutSheetKitTest { assertThat(thirdEntry!!.key).isEqualTo("https://one.com") assertThat(thirdEntry.isStale).isFalse() - assertThat(shadowOf(thirdEntry.view).lastLoadedUrl).isEqualTo("https://one.com") + assertThat(shadowOf(thirdEntry.view).lastLoadedUrl?.toUri()) + .hasBaseUrl("https://one.com") + .withEmbedParameters() } } } @@ -184,8 +188,11 @@ class ShopifyCheckoutSheetKitTest { assertThat(thirdEntry?.key).isEqualTo("https://one.com") assertThat(thirdEntry?.isStale).isTrue() - assertThat(shadowOf(thirdEntry?.view).lastLoadedUrl).isEqualTo("https://two.com") + assertThat(shadowOf(thirdEntry?.view).lastLoadedUrl?.toUri()) + .hasBaseUrl("https://two.com") + .withEmbedParameters() } } } + } diff --git a/samples/MobileBuyIntegration/.env.example b/samples/MobileBuyIntegration/.env.example index 0c3e0790..1decb7ab 100644 --- a/samples/MobileBuyIntegration/.env.example +++ b/samples/MobileBuyIntegration/.env.example @@ -6,3 +6,7 @@ CUSTOMER_ACCOUNT_API_REDIRECT_URI=shop..app://callback CUSTOMER_ACCOUNT_API_GRAPHQL_BASE_URL=https://shopify.com//account/customer/api//graphql CUSTOMER_ACCOUNT_API_AUTH_BASE_URL=https://shopify.com/authentication/ + +CHECKOUT_AUTH_ENDPOINT= +CHECKOUT_AUTH_CLIENT_ID= +CHECKOUT_AUTH_CLIENT_SECRET= diff --git a/samples/MobileBuyIntegration/app/build.gradle b/samples/MobileBuyIntegration/app/build.gradle index 243e714b..a887781b 100644 --- a/samples/MobileBuyIntegration/app/build.gradle +++ b/samples/MobileBuyIntegration/app/build.gradle @@ -30,6 +30,11 @@ def customerAccountApiRedirectUri = properties.getProperty("CUSTOMER_ACCOUNT_API def customerAccountApiGraphQLBaseUrl = properties.getProperty("CUSTOMER_ACCOUNT_API_GRAPHQL_BASE_URL") def customerAccountApiAuthBaseUrl = properties.getProperty("CUSTOMER_ACCOUNT_API_AUTH_BASE_URL") +// Checkout Authentication (optional) +def checkoutAuthEndpoint = properties.getProperty("CHECKOUT_AUTH_ENDPOINT", "") +def checkoutAuthClientId = properties.getProperty("CHECKOUT_AUTH_CLIENT_ID", "") +def checkoutAuthClientSecret = properties.getProperty("CHECKOUT_AUTH_CLIENT_SECRET", "") + if (!storefrontDomain || !accessToken) { println("**** Please add a .env file with STOREFRONT_DOMAIN and STOREFRONT_ACCESS_TOKEN set *****") } @@ -55,6 +60,9 @@ android { buildConfigField "String", "customerAccountApiRedirectUri", "\"$customerAccountApiRedirectUri\"" buildConfigField "String", "customerAccountApiAuthBaseUrl", "\"$customerAccountApiAuthBaseUrl\"" buildConfigField "String", "customerAccountApiGraphQLBaseUrl", "\"$customerAccountApiGraphQLBaseUrl\"" + buildConfigField "String", "checkoutAuthEndpoint", "\"$checkoutAuthEndpoint\"" + buildConfigField "String", "checkoutAuthClientId", "\"$checkoutAuthClientId\"" + buildConfigField "String", "checkoutAuthClientSecret", "\"$checkoutAuthClientSecret\"" } buildTypes { diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/CheckoutSdkApp.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/CheckoutSdkApp.kt index 18db9f5e..9d15ad11 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/CheckoutSdkApp.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/CheckoutSdkApp.kt @@ -25,7 +25,6 @@ package com.shopify.checkout_sdk_mobile_buy_integration_sample import androidx.compose.foundation.Image import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset @@ -53,7 +52,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -65,6 +64,7 @@ import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.SnackbarCon import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.navigation.BottomAppBarWithNavigation import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.navigation.CheckoutSdkNavHost import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.navigation.Screen +import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.ui.NativeSheetsOrchestrator import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.ui.theme.CheckoutSdkSampleTheme import com.shopify.checkout_sdk_mobile_buy_integration_sample.logs.LogsViewModel import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.SettingsUiState @@ -94,8 +94,7 @@ fun CheckoutSdkAppRoot( val cartState = cartViewModel.cartState.collectAsState() val totalQuantity = cartState.value.totalQuantity - val context = LocalContext.current - + val resources = LocalResources.current CheckoutSdkSampleTheme(darkTheme = useDarkTheme) { Surface( modifier = Modifier.fillMaxSize(), @@ -108,7 +107,7 @@ fun CheckoutSdkAppRoot( ObserveAsEvents(flow = SnackbarController.events) { event -> scope.launch { snackbarHostState.currentSnackbarData?.dismiss() - snackbarHostState.showSnackbar(message = context.resources.getText(event.resourceId).toString()) + snackbarHostState.showSnackbar(message = resources.getText(event.resourceId).toString()) } } @@ -182,14 +181,12 @@ fun CheckoutSdkAppRoot( ) } } + + NativeSheetsOrchestrator() } } } -data class AppBarState( - val actions: @Composable RowScope.() -> Unit = {}, -) - private fun SettingsUiState.isDarkTheme(isSystemInDarkTheme: Boolean) = when (this) { is SettingsUiState.Loading -> isSystemInDarkTheme is SettingsUiState.Loaded -> { diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/CartViewModel.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/CartViewModel.kt index 25baee8b..778823c6 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/CartViewModel.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/CartViewModel.kt @@ -29,12 +29,14 @@ import androidx.navigation.NavController import com.shopify.checkout_sdk_mobile_buy_integration_sample.R import com.shopify.checkout_sdk_mobile_buy_integration_sample.cart.data.CartRepository import com.shopify.checkout_sdk_mobile_buy_integration_sample.cart.data.CartState +import com.shopify.checkout_sdk_mobile_buy_integration_sample.cart.data.CheckoutAppAuthenticationService import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.ID import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.SnackbarController import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.SnackbarEvent import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.navigation.Screen import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.PreferencesManager import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentication.data.CustomerRepository +import com.shopify.checkoutsheetkit.CheckoutOptions import com.shopify.checkoutsheetkit.DefaultCheckoutEventProcessor import com.shopify.checkoutsheetkit.ShopifyCheckoutSheetKit import kotlinx.coroutines.flow.MutableStateFlow @@ -49,6 +51,7 @@ class CartViewModel( private val cartRepository: CartRepository, private val preferencesManager: PreferencesManager, private val customerRepository: CustomerRepository, + private val checkoutAppAuthenticationService: CheckoutAppAuthenticationService, ) : ViewModel() { private val _cartState = MutableStateFlow(CartState.Empty) @@ -110,9 +113,21 @@ class CartViewModel( url: String, activity: ComponentActivity, eventProcessor: T - ) { + ) = viewModelScope.launch { Timber.i("Presenting checkout with $url") - ShopifyCheckoutSheetKit.present(url, activity, eventProcessor) + + if (checkoutAppAuthenticationService.hasConfiguration()) { + try { + val token = checkoutAppAuthenticationService.fetchAccessToken() + val options = CheckoutOptions(authToken = token) + ShopifyCheckoutSheetKit.present(url, activity, eventProcessor, options) + } catch (e: Exception) { + Timber.e("Failed to fetch checkout app authentication token, presenting without authentication: $e") + ShopifyCheckoutSheetKit.present(url, activity, eventProcessor) + } + } else { + ShopifyCheckoutSheetKit.present(url, activity, eventProcessor) + } } fun preloadCheckout( diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/data/CheckoutAppAuthenticationService.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/data/CheckoutAppAuthenticationService.kt new file mode 100644 index 00000000..dbe16a37 --- /dev/null +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/data/CheckoutAppAuthenticationService.kt @@ -0,0 +1,187 @@ +/* + * 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 + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkout_sdk_mobile_buy_integration_sample.cart.data + +import android.util.Base64 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import timber.log.Timber +import java.time.Instant +import kotlin.time.Duration.Companion.minutes + +/** + * Exception thrown when app authentication token fetching fails. + */ +class CheckoutAppAuthenticationException( + message: String, + val statusCode: Int? = null, + val errorBody: String? = null, + cause: Throwable? = null, +) : Exception(message, cause) + +/** + * Service for fetching checkout app authentication tokens using OAuth client credentials flow. + * This authenticates the calling application to enable app-specific checkout customizations. + * + * Tokens are cached and reused until they expire (with a 5-minute safety buffer). + */ +class CheckoutAppAuthenticationService( + private val client: OkHttpClient, + private val json: Json, + private val authEndpoint: String, + private val clientId: String, + private val clientSecret: String, +) { + private var cachedToken: String? = null + private var tokenExpiryTimestamp: Long = 0 + private val mutex = Mutex() + + /** + * Checks if the service has all required configuration to fetch tokens. + */ + fun hasConfiguration(): Boolean { + return authEndpoint.isNotBlank() && clientId.isNotBlank() && clientSecret.isNotBlank() + } + + /** + * Fetches an access token using OAuth client credentials flow. + * Returns a cached token if available and not expired (with 5-minute buffer). + * + * @return The JWT access token + * @throws CheckoutAppAuthenticationException if the request fails or configuration is missing + */ + suspend fun fetchAccessToken(): String = mutex.withLock { + val now = Instant.now().epochSecond + val expiryBuffer = 5.minutes.inWholeSeconds + + cachedToken?.let { token -> + if (tokenExpiryTimestamp > now + expiryBuffer) { + Timber.d("Using cached checkout app authentication token (expires in ${tokenExpiryTimestamp - now}s)") + return@withLock token + } else { + Timber.d("Cached token expired or about to expire, fetching new token") + } + } + + // Fetch new token + val token = fetchNewToken() + + // Extract expiry from JWT and cache + try { + tokenExpiryTimestamp = extractExpiryFromJwt(token) + cachedToken = token + Timber.d("Cached new token (expires in ${tokenExpiryTimestamp - now}s)") + } catch (e: Exception) { + Timber.w("Failed to parse token expiry, will not cache: $e") + } + + return@withLock token + } + + /** + * Extracts the expiry timestamp (exp claim) from a JWT token. + */ + private fun extractExpiryFromJwt(jwt: String): Long { + val parts = jwt.split(".") + if (parts.size != 3) { + throw IllegalArgumentException("Invalid JWT format") + } + + val payload = String(Base64.decode(parts[1], Base64.URL_SAFE or Base64.NO_WRAP)) + val jsonElement = Json.parseToJsonElement(payload) + return jsonElement.jsonObject["exp"]?.jsonPrimitive?.long + ?: throw IllegalArgumentException("JWT missing exp claim") + } + + /** + * Fetches a new token from the authentication endpoint. + */ + private suspend fun fetchNewToken(): String = withContext(Dispatchers.IO) { + if (!hasConfiguration()) { + throw CheckoutAppAuthenticationException("Checkout app authentication is not configured") + } + + val requestBody = json.encodeToString( + TokenRequest( + clientId = clientId, + clientSecret = clientSecret, + grantType = "client_credentials" + ) + ).toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url(authEndpoint) + .post(requestBody) + .build() + + Timber.d("Fetching checkout app authentication token from $authEndpoint") + + val response = client.newCall(request).execute() + + if (!response.isSuccessful) { + val errorBody = response.body?.string() ?: "Unknown error" + Timber.e("Failed to fetch app authentication token: ${response.code} - $errorBody") + throw CheckoutAppAuthenticationException( + message = "Failed to fetch app authentication token", + statusCode = response.code, + errorBody = errorBody + ) + } + + val responseBody = response.body?.string() + ?: throw CheckoutAppAuthenticationException("Empty response body from authentication endpoint") + + val tokenResponse = json.decodeFromString(responseBody) + + Timber.d("Successfully fetched checkout authentication token") + return@withContext tokenResponse.accessToken + } + + @Serializable + private data class TokenRequest( + @SerialName("client_id") + val clientId: String, + @SerialName("client_secret") + val clientSecret: String, + @SerialName("grant_type") + val grantType: String, + ) + + @Serializable + private data class TokenResponse( + @SerialName("access_token") + val accessToken: String, + ) +} diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/data/DemoBuyerIdentity.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/data/DemoBuyerIdentity.kt index 95bec516..c3d51824 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/data/DemoBuyerIdentity.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/data/DemoBuyerIdentity.kt @@ -32,34 +32,33 @@ import com.shopify.buy3.Storefront */ object DemoBuyerIdentity { internal val value = Storefront.CartBuyerIdentityInput() - .setEmail("example.customer@shopify.com") - .setCountryCode(Storefront.CountryCode.CA) - .setPhone("+441792123456") + .setEmail("john.smith@example.com") + .setCountryCode(Storefront.CountryCode.US) + .setPhone("+12125551234") .setDeliveryAddressPreferences( listOf( Storefront.DeliveryAddressInput().setDeliveryAddress( Storefront.MailingAddressInput() - .setAddress1("The Cloak & Dagger") - .setAddress2("1st Street Southeast") - .setCity("Calgary") - .setCountry("CA") - .setFirstName("Ada") - .setLastName("Lovelace") - .setProvince("AB") - .setPhone("+441792123456") - .setZip("T1X 0L3") + .setAddress1("150 5th Avenue") + .setCity("New York") + .setCountry("US") + .setFirstName("John") + .setLastName("Smith") + .setProvince("NY") + .setPhone("+12125551234") + .setZip("10011") ), Storefront.DeliveryAddressInput().setDeliveryAddress( Storefront.MailingAddressInput() - .setAddress1("8 Lon Heddwch") - .setAddress2("Llansamlet") - .setCity("Swansea") - .setCountry("GB") - .setFirstName("Ada") - .setLastName("Lovelace") - .setProvince("") - .setPhone("+441792123456") - .setZip("SA7 9UY") + .setAddress1("89 Haight Street") + .setAddress2("Apt 2B") + .setCity("San Francisco") + .setCountry("US") + .setFirstName("John") + .setLastName("Smith") + .setProvince("CA") + .setPhone("+12125551234") + .setZip("94117") ) ) ) 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..5fbdd873 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 @@ -37,9 +37,12 @@ import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.analytics.A import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.analytics.toAnalyticsEvent import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.logs.Logger import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.navigation.Screen +import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.ui.NativeSheet +import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.ui.NativeSheetState +import com.shopify.checkoutsheetkit.CheckoutAddressChangeRequestedEvent import com.shopify.checkoutsheetkit.CheckoutException import com.shopify.checkoutsheetkit.DefaultCheckoutEventProcessor -import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent import com.shopify.checkoutsheetkit.pixelevents.CustomPixelEvent import com.shopify.checkoutsheetkit.pixelevents.PixelEvent import com.shopify.checkoutsheetkit.pixelevents.StandardPixelEvent @@ -55,8 +58,8 @@ class MobileBuyEventProcessor( private val logger: Logger, private val context: Context ) : DefaultCheckoutEventProcessor(context) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { - logger.log(checkoutCompletedEvent) + override fun onCheckoutCompleted(checkoutCompleteEvent: CheckoutCompleteEvent) { + logger.log(checkoutCompleteEvent) cartViewModel.clearCart() GlobalScope.launch(Dispatchers.Main) { @@ -88,6 +91,11 @@ class MobileBuyEventProcessor( return (context as MainActivity).onGeolocationPermissionsShowPrompt(origin, callback) } + override fun onAddressChangeRequested(event: CheckoutAddressChangeRequestedEvent) { + logger.log("Address change requested") + NativeSheetState.show(NativeSheet.Address(event)) + } + override fun onShowFileChooser( webView: WebView, filePathCallback: ValueCallback>, diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/di/AppModule.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/di/AppModule.kt index 6ba0e0b8..06899f3d 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/di/AppModule.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/di/AppModule.kt @@ -31,11 +31,11 @@ import com.shopify.buy3.Storefront import com.shopify.checkout_sdk_mobile_buy_integration_sample.BuildConfig import com.shopify.checkout_sdk_mobile_buy_integration_sample.cart.CartViewModel import com.shopify.checkout_sdk_mobile_buy_integration_sample.cart.data.CartRepository +import com.shopify.checkout_sdk_mobile_buy_integration_sample.cart.data.CheckoutAppAuthenticationService import com.shopify.checkout_sdk_mobile_buy_integration_sample.cart.data.source.network.CartStorefrontApiClient import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.client.StorefrontApiRequestExecutor import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.logs.LogDatabase import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.logs.Logger -import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.logs.MIGRATION_1_2 import com.shopify.checkout_sdk_mobile_buy_integration_sample.home.HomeViewModel import com.shopify.checkout_sdk_mobile_buy_integration_sample.logs.LogsViewModel import com.shopify.checkout_sdk_mobile_buy_integration_sample.products.ProductsViewModel @@ -53,7 +53,7 @@ import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentic import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentication.data.source.local.CustomerAccessTokenStore import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentication.data.source.network.CustomerAccountsApiGraphQLClient import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentication.data.source.network.CustomerAccountsApiRestClient -import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentication.utils.AuthenticationHelper +import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentication.utils.CustomerAuthenticationHelper import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.data.SettingsRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -93,7 +93,7 @@ val appModules = module { single { Logger(logDb = get(), coroutineScope = CoroutineScope(Dispatchers.IO)) } single { Room.databaseBuilder(get(), LogDatabase::class.java, "log-db") - .addMigrations(MIGRATION_1_2) + .fallbackToDestructiveMigration() .build() } @@ -131,13 +131,23 @@ val appModules = module { } single { - AuthenticationHelper( + CustomerAuthenticationHelper( baseUrl = BuildConfig.customerAccountApiAuthBaseUrl, redirectUri = BuildConfig.customerAccountApiRedirectUri, clientId = BuildConfig.customerAccountApiClientId ) } + single { + CheckoutAppAuthenticationService( + client = OkHttpClient(), + json = get(), + authEndpoint = BuildConfig.checkoutAuthEndpoint, + clientId = BuildConfig.checkoutAuthClientId, + clientSecret = BuildConfig.checkoutAuthClientSecret, + ) + } + // Repositories singleOf(::CartRepository) singleOf(::ProductRepository) @@ -156,6 +166,6 @@ val appModules = module { viewModelOf(::AccountViewModel) single { // singleton instance of shared cart view model - CartViewModel(get(), get(), get()) + CartViewModel(get(), get(), get(), get()) } } diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/logs/LogDatabase.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/logs/LogDatabase.kt index 7c99124b..169eca2b 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/logs/LogDatabase.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/logs/LogDatabase.kt @@ -30,7 +30,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase @Database( entities = [LogLine::class], - version = 2, + version = 3, exportSchema = false, ) @TypeConverters(Converters::class) diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/logs/LogLine.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/logs/LogLine.kt index bbf58c96..93b50d41 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/logs/LogLine.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/logs/LogLine.kt @@ -26,13 +26,11 @@ import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverter -import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent -import com.shopify.checkoutsheetkit.lifecycleevents.OrderDetails +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent import com.shopify.checkoutsheetkit.pixelevents.Context import com.shopify.checkoutsheetkit.pixelevents.CustomPixelEvent import com.shopify.checkoutsheetkit.pixelevents.StandardPixelEvent import com.shopify.checkoutsheetkit.pixelevents.StandardPixelEventData -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.util.Date import java.util.UUID @@ -46,7 +44,7 @@ data class LogLine( @Embedded(prefix = "standard_pixel") val standardPixelEvent: StandardPixelEvent? = null, @Embedded(prefix = "custom_pixel") val customPixelEvent: CustomPixelEvent? = null, @Embedded(prefix = "error_details") val errorDetails: ErrorDetails? = null, - @Embedded(prefix = "checkout_completed") val checkoutCompleted: CheckoutCompletedEvent? = null, + val checkoutCompleted: CheckoutCompleteEvent? = null, ) enum class LogType { @@ -59,33 +57,46 @@ data class ErrorDetails( ) class Converters { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } @TypeConverter - fun standardPixelEventDataToString(value: StandardPixelEventData): String { - return Json.encodeToString(value) + fun standardPixelEventDataToString(value: StandardPixelEventData?): String? { + return value?.let { json.encodeToString(it) } } @TypeConverter - fun stringToStandardPixelEventData(value: String): StandardPixelEventData { - return Json.decodeFromString(value) + fun stringToStandardPixelEventData(value: String?): StandardPixelEventData? { + return decodeOrNull(value) } @TypeConverter - fun contextToString(value: Context): String { - return Json.encodeToString(value) + fun contextToString(value: Context?): String? { + return value?.let { json.encodeToString(it) } } @TypeConverter - fun stringToContext(value: String): Context { - return Json.decodeFromString(value) + fun stringToContext(value: String?): Context? { + return decodeOrNull(value) } @TypeConverter - fun orderDetailsToString(value: OrderDetails): String { - return Json.encodeToString(value) + fun checkoutCompletedToString(value: CheckoutCompleteEvent?): String? { + return value?.let { json.encodeToString(it) } } @TypeConverter - fun stringToOrderDetails(value: String): OrderDetails { - return Json.decodeFromString(value) + fun stringToCheckoutCompleted(value: String?): CheckoutCompleteEvent? { + return decodeOrNull(value) + } + + private inline fun decodeOrNull(value: String?): T? { + if (value.isNullOrBlank()) { + return null + } + return runCatching { + json.decodeFromString(value) + }.getOrNull() } } diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/logs/Logger.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/logs/Logger.kt index 39585e38..8c3f10d6 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/logs/Logger.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/logs/Logger.kt @@ -23,7 +23,7 @@ package com.shopify.checkout_sdk_mobile_buy_integration_sample.common.logs import com.shopify.checkoutsheetkit.CheckoutException -import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent import com.shopify.checkoutsheetkit.pixelevents.CustomPixelEvent import com.shopify.checkoutsheetkit.pixelevents.PixelEvent import com.shopify.checkoutsheetkit.pixelevents.StandardPixelEvent @@ -68,12 +68,12 @@ class Logger( ) } - fun log(checkoutCompletedEvent: CheckoutCompletedEvent) { + fun log(checkoutCompleteEvent: CheckoutCompleteEvent) { insert( LogLine( type = LogType.CHECKOUT_COMPLETED, message = "Checkout completed", - checkoutCompleted = checkoutCompletedEvent, + checkoutCompleted = checkoutCompleteEvent, ) ) } diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/ui/NativeSheetsOrchestrator.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/ui/NativeSheetsOrchestrator.kt new file mode 100644 index 00000000..25fdd274 --- /dev/null +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/ui/NativeSheetsOrchestrator.kt @@ -0,0 +1,103 @@ +/* + * 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 + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkout_sdk_mobile_buy_integration_sample.common.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.ui.sheets.AddressSelectionBottomSheet +import com.shopify.checkoutsheetkit.CartDelivery +import com.shopify.checkoutsheetkit.CartSelectableAddressInput +import com.shopify.checkoutsheetkit.CheckoutAddressChangeRequestedEvent +import com.shopify.checkoutsheetkit.DeliveryAddressChangePayload + +/** + * Represents the different types of native sheets that can be displayed during checkout. + */ +sealed class NativeSheet { + data class Address(val event: CheckoutAddressChangeRequestedEvent) : NativeSheet() + // Future: data class Payment(val event: CheckoutPaymentMethodChangeRequestedEvent) : NativeSheet() +} + +/** + * Orchestrates native sheets displayed during checkout (address picker, payment methods, etc.) + * Watches for checkout events and launches the appropriate native sheet components. + * Ensures only one sheet is displayed at a time. + */ +@Composable +fun NativeSheetsOrchestrator() { + when (val sheet = NativeSheetState.currentSheet) { + is NativeSheet.Address -> AddressSheet( + event = sheet.event, + onDismiss = { NativeSheetState.dismiss() } + ) + null -> { /* No sheet showing */ } + } +} + +/** + * Native sheet for address selection. + * Presents a bottom sheet with address options and responds to the checkout with the selected address. + */ +@Composable +fun AddressSheet( + event: CheckoutAddressChangeRequestedEvent, + onDismiss: () -> Unit +) { + AddressSelectionBottomSheet( + onAddressSelected = { selectedAddress -> + event.respondWith( + DeliveryAddressChangePayload( + delivery = CartDelivery( + addresses = listOf( + CartSelectableAddressInput( + address = selectedAddress + ) + ) + ) + ) + ) + onDismiss() + }, + onDismiss = onDismiss + ) +} + +/** + * Shared state holder for native sheets. + * Ensures only one sheet is displayed at a time. + * Used to bridge between the event callback and Compose UI. + */ +object NativeSheetState { + var currentSheet by mutableStateOf(null) + private set + + fun show(sheet: NativeSheet) { + currentSheet = sheet + } + + fun dismiss() { + currentSheet = null + } +} \ No newline at end of file diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/ui/sheets/AddressSelectionBottomSheet.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/ui/sheets/AddressSelectionBottomSheet.kt new file mode 100644 index 00000000..67bc7058 --- /dev/null +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/ui/sheets/AddressSelectionBottomSheet.kt @@ -0,0 +1,209 @@ +/* + * 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 + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.shopify.checkout_sdk_mobile_buy_integration_sample.common.ui.sheets + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.shopify.checkoutsheetkit.CartDeliveryAddressInput + +data class AddressOption( + val label: String, + val address: CartDeliveryAddressInput, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddressSelectionBottomSheet( + onAddressSelected: (CartDeliveryAddressInput) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val addressOptions = remember { + listOf( + AddressOption( + label = "Default", + address = CartDeliveryAddressInput( + firstName = "John", + lastName = "Smith", + address1 = "150 5th Avenue", + address2 = null, + city = "New York", + countryCode = "US", + phone = "+12125551234", + provinceCode = "NY", + zip = "10011", + ) + ), + AddressOption( + label = "West Coast Address", + address = CartDeliveryAddressInput( + firstName = "Evelyn", + lastName = "Hartley", + address1 = "89 Haight Street", + address2 = "Apt 2B", + city = "San Francisco", + countryCode = "US", + phone = "+14159876543", + provinceCode = "CA", + zip = "94117", + ) + ), + AddressOption( + label = "Invalid Address", + address = CartDeliveryAddressInput( + firstName = "Invalid", + lastName = "User", + address1 = "123 Fake Street", + address2 = null, + city = "Austin", + countryCode = "US", + phone = "+15125551234", + provinceCode = "TX", + zip = "00000", // Invalid zip code + ) + ), + ) + } + + var selectedIndex by remember { mutableIntStateOf(0) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + text = "Select Shipping Address", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp) + ) + + LazyColumn( + modifier = Modifier.weight(1f, fill = false) + ) { + itemsIndexed(addressOptions) { index, option -> + AddressOptionItem( + option = option, + isSelected = index == selectedIndex, + onClick = { selectedIndex = index } + ) + } + } + + Button( + onClick = { + onAddressSelected(addressOptions[selectedIndex].address) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + .height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("Use Selected Address") + } + } + } +} + +@Composable +fun AddressOptionItem( + option: AddressOption, + isSelected: Boolean, + onClick: () -> Unit, +) { + val backgroundColor = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor) + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = onClick, + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary + ) + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + ) { + Text( + text = option.label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "${option.address.city}, ${option.address.provinceCode ?: option.address.countryCode} ${option.address.zip}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} \ No newline at end of file diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/logs/details/CheckoutCompletedDetails.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/logs/details/CheckoutCompletedDetails.kt index 7339ec4a..7f9576ac 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/logs/details/CheckoutCompletedDetails.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/logs/details/CheckoutCompletedDetails.kt @@ -27,13 +27,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent -import kotlinx.serialization.encodeToString +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent import kotlinx.serialization.json.Json @Composable fun CheckoutCompletedDetails( - event: CheckoutCompletedEvent?, + event: CheckoutCompleteEvent?, prettyJson: Json, ) { LogDetails( diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/logs/details/PixelEventDetails.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/logs/details/PixelEventDetails.kt index 34b18528..d4743a18 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/logs/details/PixelEventDetails.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/logs/details/PixelEventDetails.kt @@ -32,7 +32,6 @@ import com.shopify.checkoutsheetkit.pixelevents.CustomPixelEvent import com.shopify.checkoutsheetkit.pixelevents.PixelEvent import com.shopify.checkoutsheetkit.pixelevents.StandardPixelEvent import com.shopify.checkoutsheetkit.pixelevents.StandardPixelEventData -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @Composable diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/LoginViewModel.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/LoginViewModel.kt index 44f20586..b60f2597 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/LoginViewModel.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/LoginViewModel.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.text.intl.Locale import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentication.data.CustomerRepository -import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentication.utils.AuthenticationHelper +import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentication.utils.CustomerAuthenticationHelper import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -34,14 +34,14 @@ import kotlinx.coroutines.launch import timber.log.Timber class LoginViewModel( - private val authenticationHelper: AuthenticationHelper, + private val customerAuthenticationHelper: CustomerAuthenticationHelper, private val customerRepository: CustomerRepository, ) : ViewModel() { private val _uiState = MutableStateFlow( LoginUIState( status = Status.Loading, - codeVerifier = authenticationHelper.createCodeVerifier() + codeVerifier = customerAuthenticationHelper.createCodeVerifier() ) ) val uiState: StateFlow = _uiState.asStateFlow() @@ -55,11 +55,11 @@ class LoginViewModel( val token = customerRepository.getCustomerAccessToken() if (token == null) { Timber.i("Not yet logged in") - val codeVerifier = authenticationHelper.createCodeVerifier() + val codeVerifier = customerAuthenticationHelper.createCodeVerifier() _uiState.value = _uiState.value.copy( status = Status.LoggedOut( - redirectUri = authenticationHelper.redirectUri, - loginUrl = authenticationHelper.buildAuthorizationURL( + redirectUri = customerAuthenticationHelper.redirectUri, + loginUrl = customerAuthenticationHelper.buildAuthorizationURL( codeVerifier = codeVerifier, locale = locale ) diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/data/source/network/CustomerAccountsApiRestClient.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/data/source/network/CustomerAccountsApiRestClient.kt index 203bcf9c..e1a9b3e7 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/data/source/network/CustomerAccountsApiRestClient.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/data/source/network/CustomerAccountsApiRestClient.kt @@ -23,7 +23,7 @@ package com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentication.data.source.network import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentication.data.AccessToken -import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentication.utils.AuthenticationHelper +import com.shopify.checkout_sdk_mobile_buy_integration_sample.settings.authentication.utils.CustomerAuthenticationHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -40,7 +40,7 @@ import java.io.IOException class CustomerAccountsApiRestClient( private val client: OkHttpClient, private val json: Json, - private val helper: AuthenticationHelper, + private val helper: CustomerAuthenticationHelper, private val clientId: String, private val redirectUri: String, ) { diff --git a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/utils/AuthenticationHelper.kt b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/utils/CustomerAuthenticationHelper.kt similarity index 94% rename from samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/utils/AuthenticationHelper.kt rename to samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/utils/CustomerAuthenticationHelper.kt index eebc8756..57c8017b 100644 --- a/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/utils/AuthenticationHelper.kt +++ b/samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/settings/authentication/utils/CustomerAuthenticationHelper.kt @@ -27,12 +27,13 @@ import android.util.Base64 import androidx.compose.ui.text.intl.Locale import java.security.MessageDigest import java.security.SecureRandom +import androidx.core.net.toUri /** - * Utility functions for building Authentication related URLs, generating - * code verifier, code challenge, and state values. + * Utility functions for building customer authentication related URLs, generating + * code verifier, code challenge, and state values for Customer Account API. */ -class AuthenticationHelper( +class CustomerAuthenticationHelper( val clientId: String, val redirectUri: String, private val baseUrl: String, @@ -46,7 +47,7 @@ class AuthenticationHelper( locale: Locale, ): String { val codeChallenge = codeChallenge(codeVerifier) - val url = Uri.parse("$baseUrl/oauth/authorize").buildUpon() + val url = "$baseUrl/oauth/authorize".toUri().buildUpon() .appendQueryParameter("scope", "openid email customer-account-api:full") .appendQueryParameter("client_id", clientId) .appendQueryParameter("response_type", "code") diff --git a/samples/MobileBuyIntegration/app/src/main/res/values/strings.xml b/samples/MobileBuyIntegration/app/src/main/res/values/strings.xml index 5f1a306b..6873f4be 100644 --- a/samples/MobileBuyIntegration/app/src/main/res/values/strings.xml +++ b/samples/MobileBuyIntegration/app/src/main/res/values/strings.xml @@ -44,7 +44,7 @@ Failed to create cart Failed to update cart An error occurred when completing log in - Failed to load customerR + Failed to load customer Select or Capture Image diff --git a/samples/SimpleCheckout/app/src/main/java/com/shopify/checkout_sdk_sample/product/ProductViewModel.kt b/samples/SimpleCheckout/app/src/main/java/com/shopify/checkout_sdk_sample/product/ProductViewModel.kt index 430077a6..b6be3692 100644 --- a/samples/SimpleCheckout/app/src/main/java/com/shopify/checkout_sdk_sample/product/ProductViewModel.kt +++ b/samples/SimpleCheckout/app/src/main/java/com/shopify/checkout_sdk_sample/product/ProductViewModel.kt @@ -34,7 +34,7 @@ import com.shopify.checkout_sdk_sample.data.ProductRepository import com.shopify.checkoutsheetkit.CheckoutException import com.shopify.checkoutsheetkit.DefaultCheckoutEventProcessor import com.shopify.checkoutsheetkit.ShopifyCheckoutSheetKit -import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent +import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompleteEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -69,7 +69,7 @@ class ProductViewModel : ViewModel() { */ fun setEventProcessor(activity: ComponentActivity) { eventProcessor = object : DefaultCheckoutEventProcessor(activity) { - override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) { + override fun onCheckoutCompleted(checkoutCompleteEvent: CheckoutCompleteEvent) { checkoutCompleted() }