Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
local.properties
.env
upload-keystore.jks
.claude/
87 changes: 82 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
}
}
37 changes: 24 additions & 13 deletions lib/src/main/java/com/shopify/checkoutsheetkit/BaseWebView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
Expand Down
Loading