Skip to content
Draft
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
9 changes: 6 additions & 3 deletions platforms/android/samples/CheckoutKitAndroidDemo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This sample demonstrates how to integrate Checkout Kit with the Shopify Storefro
- Checkout fail/dismiss callbacks through the presentation builder
- File chooser, geolocation, and web permission host callbacks
- Buyer identity demo data for checkout prefill
- Customer Account API sign-in and customer access token cart identity
- Customer Account API sign-in through Android Auth Tab (with a Custom Tabs fallback), secure token storage, and customer access token cart identity

## Checkout flow

Expand Down Expand Up @@ -86,12 +86,15 @@ Optional values enable Customer Account API and buyer identity demo flows:
CUSTOMER_ACCOUNT_API_CLIENT_ID=your-client-id
CUSTOMER_ACCOUNT_API_SHOP_ID=your-shop-id
CUSTOMER_ACCOUNT_API_VERSION=2026-04
CUSTOMER_ACCOUNT_API_REDIRECT_URI=https://example.com/customer-account/callback
EMAIL=test.buyer@example.com
PHONE=+16135550123
```

The setup script generates this sample's local `.env`.

Use a verified HTTPS App Link for `CUSTOMER_ACCOUNT_API_REDIRECT_URI` in a production app. A custom URI scheme also works and Auth Tab protects its callback on supported browsers, but an App Link prevents another installed app from claiming the Custom Tabs fallback redirect. Authentication uses the browser's shared session by default so it participates in browser SSO; logout uses the same browser surface and then always clears the local session.

Open the project in Android Studio, sync Gradle, then build and run.

## Updating the Storefront API version
Expand Down Expand Up @@ -149,5 +152,5 @@ Open the project in Android Studio, sync Gradle, then build and run.
| `settings/authentication/data/CustomerRepository.kt` | Customer Account API token exchange and customer lookup. |
| `common/navigation/CheckoutKitNavHost.kt` | App navigation. |
| `cart/CartViewModel.kt` | Checkout presentation, fail/dismiss callbacks, and protocol lifecycle handlers. |
| `MainActivity.kt` | File chooser and geolocation permission callbacks. |
| `settings/authentication/` | Customer Account API sign-in screens and WebView flow. |
| `MainActivity.kt` | File chooser, geolocation, and Auth Tab/Custom Tabs result callbacks. |
| `settings/authentication/` | Customer Account API browser sign-in, OAuth validation, and encrypted credential storage. |
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ if (!customerAccountApiAuthBaseUrl && customerAccountApiShopId) {
customerAccountApiAuthBaseUrl = "https://shopify.com/authentication/${customerAccountApiShopId}"
}

def customerAccountRedirect = customerAccountApiRedirectUri ? new URI(customerAccountApiRedirectUri) : null
def customerAccountRedirectScheme = customerAccountRedirect?.scheme ?: "invalid-customer-account-callback"
def customerAccountRedirectHost = customerAccountRedirect?.host ?: "callback"

// Demo buyer identity (prefill toggle in Settings)
def prefillEmail = properties.getProperty("EMAIL", properties.getProperty("PREFILL_EMAIL", "test.buyer@example.com"))
def prefillPhone = properties.getProperty("PHONE", properties.getProperty("PREFILL_PHONE", "+14165550100"))
Expand All @@ -70,6 +74,11 @@ android {
versionCode = 34
versionName = "0.0.${versionCode}"

manifestPlaceholders = [
customerAccountRedirectScheme: customerAccountRedirectScheme,
customerAccountRedirectHost: customerAccountRedirectHost,
]

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
Expand Down Expand Up @@ -163,6 +172,10 @@ dependencies {

debugImplementation libs.androidx.compose.ui.tooling
debugImplementation libs.androidx.compose.ui.test.manifest

testImplementation libs.junit
testImplementation libs.assertj.core
testImplementation libs.robolectric
}

apollo {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />

<queries>
<!-- Allows selecting a browser and checking whether it supports Auth Tab. -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>

<!-- for supporting deep links to the maps app -->
<intent>
<action android:name="android.intent.action.VIEW" />
Expand Down Expand Up @@ -54,13 +59,30 @@
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="portrait"
tools:ignore="LockedOrientationActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<!-- Auth Tab returns callbacks directly. This filter handles the Custom Tabs
compatibility fallback. Prefer a verified HTTPS App Link redirect in a
production app so another installed app cannot claim the callback. -->
<intent-filter
android:autoVerify="true"
tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:host="${customerAccountRedirectHost}"
android:scheme="${customerAccountRedirectScheme}" />
</intent-filter>

<meta-data
android:name="android.app.lib_name"
android:value="" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.shopify.checkout_kit_android_demo

import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
Expand All @@ -13,21 +15,29 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.browser.auth.AuthTabIntent
import androidx.browser.customtabs.CustomTabsClient
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import com.shopify.checkout_kit_android_demo.settings.authentication.BrowserAuthenticationRequest
import com.shopify.checkout_kit_android_demo.settings.authentication.BrowserAuthenticationResult
import timber.log.Timber
import timber.log.Timber.DebugTree

class MainActivity : ComponentActivity() {
private lateinit var requestPermissionLauncher: ActivityResultLauncher<String>
private lateinit var showFileChooserLauncher: ActivityResultLauncher<FileChooserParams>
private lateinit var geolocationLauncher: ActivityResultLauncher<Array<String>>
private lateinit var authTabLauncher: ActivityResultLauncher<Intent>

private var filePathCallback: ValueCallback<Array<Uri>>? = null
private var fileChooserParams: FileChooserParams? = null

private var geolocationPermissionCallback: GeolocationPermissions.Callback? = null
private var geolocationOrigin: String? = null

private var pendingBrowserAuthentication: PendingBrowserAuthentication? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

Expand All @@ -41,8 +51,17 @@ class MainActivity : ComponentActivity() {
Timber.plant(DebugTree())
}

setContent {
CheckoutKitApp()
authTabLauncher = AuthTabIntent.registerActivityResultLauncher(this) { result ->
val resultUri = result.resultUri
val browserResult = when {
result.resultCode == AuthTabIntent.RESULT_OK && resultUri != null -> {
BrowserAuthenticationResult.Redirect(resultUri)
}

result.resultCode == AuthTabIntent.RESULT_CANCELED -> BrowserAuthenticationResult.Cancelled
else -> BrowserAuthenticationResult.Failed("Authentication browser verification failed")
}
completeBrowserAuthentication(browserResult)
}

requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
Expand All @@ -65,6 +84,95 @@ class MainActivity : ComponentActivity() {
geolocationPermissionCallback = null
geolocationOrigin = null
}

setContent {
CheckoutKitApp()
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
intent.data?.let { callbackUri ->
if (pendingBrowserAuthentication?.usesCustomTabs == true) {
completeBrowserAuthentication(BrowserAuthenticationResult.Redirect(callbackUri))
}
}
}

override fun onPause() {
pendingBrowserAuthentication?.takeIf { it.usesCustomTabs }?.didLeaveApp = true
super.onPause()
}

override fun onResume() {
super.onResume()
val pending = pendingBrowserAuthentication
if (pending?.usesCustomTabs == true && pending.didLeaveApp) {
window.decorView.post {
if (pendingBrowserAuthentication === pending) {
completeBrowserAuthentication(BrowserAuthenticationResult.Cancelled)
}
}
}
}

/**
* Opens OAuth in the browser's shared session. Auth Tab securely returns the redirect URI on
* supported browsers; Custom Tabs and an app deep link provide the compatibility fallback.
*/
fun launchCustomerAccountAuthentication(
request: BrowserAuthenticationRequest,
onResult: (BrowserAuthenticationResult) -> Unit,
) {
if (pendingBrowserAuthentication != null) {
onResult(BrowserAuthenticationResult.Failed("Another authentication request is already active"))
return
}

val browserPackage = CustomTabsClient.getPackageName(this, emptyList())
val useAuthTab = browserPackage != null && CustomTabsClient.isAuthTabSupported(this, browserPackage)
pendingBrowserAuthentication = PendingBrowserAuthentication(
onResult = onResult,
usesCustomTabs = !useAuthTab,
)

try {
if (useAuthTab) {
launchAuthTab(request, requireNotNull(browserPackage))
} else {
val customTabsIntent = CustomTabsIntent.Builder().build()
browserPackage?.let(customTabsIntent.intent::setPackage)
customTabsIntent.launchUrl(this, request.url)
}
} catch (error: ActivityNotFoundException) {
completeBrowserAuthentication(BrowserAuthenticationResult.Failed("No browser is available"))
} catch (error: IllegalArgumentException) {
completeBrowserAuthentication(BrowserAuthenticationResult.Failed("The authentication URL is invalid"))
}
}

private fun launchAuthTab(request: BrowserAuthenticationRequest, browserPackage: String) {
val authTabIntent = AuthTabIntent.Builder().build()
authTabIntent.intent.setPackage(browserPackage)
val redirectScheme = request.redirectUri.scheme.orEmpty()
if (redirectScheme.equals("https", ignoreCase = true)) {
val host = requireNotNull(request.redirectUri.host)
authTabIntent.launch(
authTabLauncher,
request.url,
host,
request.redirectUri.path.orEmpty(),
)
} else {
authTabIntent.launch(authTabLauncher, request.url, redirectScheme)
}
}

private fun completeBrowserAuthentication(result: BrowserAuthenticationResult) {
val pending = pendingBrowserAuthentication ?: return
pendingBrowserAuthentication = null
pending.onResult(result)
}

fun onShowFileChooser(filePathCallback: ValueCallback<Array<Uri>>, fileChooserParams: FileChooserParams): Boolean {
Expand Down Expand Up @@ -99,4 +207,10 @@ class MainActivity : ComponentActivity() {
private fun permissionAlreadyGranted(permission: String): Boolean {
return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
}

private data class PendingBrowserAuthentication(
val onResult: (BrowserAuthenticationResult) -> Unit,
val usesCustomTabs: Boolean,
var didLeaveApp: Boolean = false,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
Expand Down Expand Up @@ -74,6 +75,16 @@ class CartViewModel(
windowOpenHandler = it.windowOpenHandler
}
}
// A cart's buyer identity is fixed when it is created. Discard carts and preloaded
// checkout state whenever Customer Account authentication changes.
viewModelScope.launch {
customerRepository.isAuthenticated
.drop(1)
.collect {
clearCart()
ShopifyCheckoutKit.invalidate()
}
}
}

fun addToCart(variantId: ID, quantity: Int, onComplete: OnComplete) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import com.shopify.checkout_kit_android_demo.settings.authentication.data.source
import com.shopify.checkout_kit_android_demo.settings.authentication.data.source.network.CustomerAccountsApiGraphQLClient
import com.shopify.checkout_kit_android_demo.settings.authentication.data.source.network.CustomerAccountsApiRestClient
import com.shopify.checkout_kit_android_demo.settings.authentication.utils.AuthenticationHelper
import com.shopify.checkout_kit_android_demo.settings.authentication.utils.IDTokenValidator
import com.shopify.checkout_kit_android_demo.settings.data.SettingsRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -110,6 +111,13 @@ val appModules = module {
clientId = BuildConfig.customerAccountApiClientId
)
}
single {
IDTokenValidator(
issuer = get<AuthenticationHelper>().issuer,
clientId = BuildConfig.customerAccountApiClientId,
json = get(),
)
}

// Repositories
singleOf(::CartRepository)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.activity.compose.LocalActivity
import androidx.navigation.NavHostController
import com.shopify.checkout_kit_android_demo.R
import com.shopify.checkout_kit_android_demo.MainActivity
import com.shopify.checkout_kit_android_demo.common.ObserveAsEvents
import com.shopify.checkout_kit_android_demo.common.components.BodyMedium
import com.shopify.checkout_kit_android_demo.common.components.Header2
import com.shopify.checkout_kit_android_demo.common.components.ProgressIndicator
Expand All @@ -36,6 +39,10 @@ fun SettingsView(
settingsViewModel: SettingsViewModel,
navController: NavHostController,
) {
val activity = LocalActivity.current as MainActivity
ObserveAsEvents(settingsViewModel.logoutRequests) { request ->
activity.launchCustomerAccountAuthentication(request, settingsViewModel::browserLogoutCompleted)
}

when (val uiState = settingsViewModel.uiState.collectAsState().value) {
is SettingsUiState.Loading -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.shopify.checkout_kit_android_demo.BuildConfig
import com.shopify.checkout_kit_android_demo.common.withCustomCloseIcon
import com.shopify.checkout_kit_android_demo.settings.authentication.data.CustomerRepository
import com.shopify.checkout_kit_android_demo.settings.authentication.BrowserAuthenticationRequest
import com.shopify.checkout_kit_android_demo.settings.authentication.BrowserAuthenticationResult
import com.shopify.checkout_kit_android_demo.settings.data.CheckoutPresentationMode
import com.shopify.checkout_kit_android_demo.settings.data.CheckoutSheetPreset
import com.shopify.checkout_kit_android_demo.settings.data.Settings
Expand All @@ -18,6 +20,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch

class SettingsViewModel(
Expand All @@ -26,6 +30,8 @@ class SettingsViewModel(
) : ViewModel() {
private val _uiState = MutableStateFlow<SettingsUiState>(SettingsUiState.Loading)
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
private val logoutRequestChannel = Channel<BrowserAuthenticationRequest>()
val logoutRequests = logoutRequestChannel.receiveAsFlow()

init {
viewModelScope.launch {
Expand Down Expand Up @@ -125,7 +131,11 @@ class SettingsViewModel(
}

fun logout() = viewModelScope.launch {
customerRepository.logout()
customerRepository.prepareLogout()?.let { logoutRequestChannel.send(it) }
}

fun browserLogoutCompleted(result: BrowserAuthenticationResult) = viewModelScope.launch {
customerRepository.completeLogout(result)
}

private fun currentSettings(): Settings? =
Expand Down
Loading
Loading