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
5 changes: 5 additions & 0 deletions app/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Live capture in the identity verification (KYC) step -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />

<application
android:name="com.crossmint.kotlin.AndroidApplication"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.crossmint.kotlin.identity

import androidx.compose.runtime.Composable

@Composable
actual fun IdentityVerificationDemoHost(
apiKey: String,
inquiryId: String,
sessionToken: String?,
onEvent: (String) -> Unit,
) {
CrossmintIdentityVerification(
apiKey = apiKey,
credentials =
IdentityVerificationCredentials(
inquiryId = inquiryId,
sessionToken = sessionToken,
),
onReady = { onEvent("kyc:ready") },
onComplete = { status -> onEvent("kyc:completed status=$status") },
onCancel = { onEvent("kyc:cancelled") },
onError = { error ->
onEvent("kyc:error code=${error.code} retriable=${error.retriable} message=${error.message}")
},
)
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,153 @@
package com.crossmint.kotlin.wallet.playground

import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.util.Base64
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.CreatePublicKeyCredentialResponse
import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.CreateCredentialCancellationException
import com.crossmint.kotlin.signers.DelegatedSigner
import com.crossmint.kotlin.utility.exposeTestTags
import java.math.BigInteger
import java.security.SecureRandom
import kotlinx.coroutines.CompletableDeferred
import org.json.JSONObject

@Composable
actual fun rememberPasskeyCreator(): (suspend (name: String) -> DelegatedSigner.Passkey?)? {
val context = LocalContext.current

// Mock mode is for CI e2e flows ONLY: GitHub Actions emulators cannot create real
// passkeys via CredentialManager (no signed-in Google account). The Maestro passkey
// flow launches the app with `arguments: { mockPasskey: true }`, which Android
// delivers as launcher-intent extras. Local/manual testing keeps the real path.
val mockMode =
remember(context) {
context
.findActivity()
?.intent
?.extras
?.get("mockPasskey")
?.toString() == "true"
}

if (mockMode) {
return rememberMockPasskeyCreator()
}
return { name -> createPasskeySigner(context, name) }
}

private tailrec fun Context.findActivity(): Activity? =
when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}

// ---------------------------------------------------------------------------
// Mock passkey creation (CI e2e only) — mirrors the Flutter playground dialog.
// ---------------------------------------------------------------------------

@Composable
private fun rememberMockPasskeyCreator(): suspend (name: String) -> DelegatedSigner.Passkey? {
var pendingRequest by remember { mutableStateOf<PendingPasskeyRequest?>(null) }

pendingRequest?.let { request ->
AlertDialog(
onDismissRequest = {
request.result.complete(null)
pendingRequest = null
},
// AlertDialog hosts its own window: re-expose testTags for Maestro.
modifier = Modifier.exposeTestTags(),
title = { Text("Create Passkey (Mock)") },
text = {
Text(
"Name: ${request.name}\n\n" +
"Playground-only simulation. On a real device this would trigger the " +
"Android Credential Manager passkey prompt. Tap Simulate to return " +
"mock credential data.",
)
},
confirmButton = {
Button(
onClick = {
request.result.complete(mockPasskeySigner(request.name))
pendingRequest = null
},
modifier = Modifier.semantics { testTag = "passkey-simulate-button" },
) {
Text("Simulate")
}
},
dismissButton = {
TextButton(
onClick = {
request.result.complete(null)
pendingRequest = null
},
) {
Text("Cancel")
}
},
)
}

return { name ->
val result = CompletableDeferred<DelegatedSigner.Passkey?>()
pendingRequest = PendingPasskeyRequest(name, result)
try {
result.await()
} finally {
pendingRequest = null
}
}
}

private class PendingPasskeyRequest(
val name: String,
val result: CompletableDeferred<DelegatedSigner.Passkey?>,
)

private fun mockPasskeySigner(name: String): DelegatedSigner.Passkey {
val random = SecureRandom()

fun hex(byteCount: Int): String {
val bytes = ByteArray(byteCount).also { random.nextBytes(it) }
return bytes.joinToString("") { "%02x".format(it) }
}

// The 0x prefix is required: the register-signer API validates publicKey.x/y as
// "a valid positive BigInt decimal or hex string" and rejects bare hex with HTTP 400.
// The server does not validate attestation/cryptography, so random values are
// accepted — mirrors the Flutter playground mock.
return DelegatedSigner.Passkey(
id = "mock-credential-${hex(8)}",
name = name,
publicKeyX = "0x${hex(32)}",
publicKeyY = "0x${hex(32)}",
)
}

// ---------------------------------------------------------------------------
// Real passkey creation via Android Credential Manager (default path).
// ---------------------------------------------------------------------------

private suspend fun createPasskeySigner(
context: Context,
name: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ fun AuthMethodSelectionScreen(
CheckoutButton(onClick = {
navController.navigate(Routes.Checkout)
})

IdentityVerificationButton(onClick = {
navController.navigate(Routes.IdentityVerification)
})
}

Spacer(modifier = Modifier.weight(1f))
Expand Down Expand Up @@ -280,3 +284,34 @@ fun CheckoutButton(onClick: () -> Unit) {
}
}
}

@Composable
fun IdentityVerificationButton(onClick: () -> Unit) {
OutlinedButton(
onClick = { onClick() },
modifier =
Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(12.dp),
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.primary,
),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Identity Verification",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
)
Text(
"KYC Flow",
fontSize = 12.sp,
fontWeight = FontWeight.Normal,
)
}
}
}
48 changes: 48 additions & 0 deletions app/src/commonMain/kotlin/com/crossmint/kotlin/DemoApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
Expand All @@ -17,10 +20,12 @@ import com.crossmint.kotlin.auth.crossmint.CrossmintOTPEmailScreen
import com.crossmint.kotlin.auth.crossmint.CrossmintOTPVerificationScreen
import com.crossmint.kotlin.checkout.CheckoutScreen
import com.crossmint.kotlin.compose.LocalCrossmintSDK
import com.crossmint.kotlin.identity.IdentityVerificationScreen
import com.crossmint.kotlin.wallet.CreateWalletViewModel
import com.crossmint.kotlin.wallet.WalletScreen
import com.crossmint.kotlin.wallet.WalletViewModel
import com.crossmint.kotlin.wallet.createwallet.CreateWalletScreen
import kotlinx.coroutines.launch

// AuthMode is defined in androidMain, so we pass it as a parameter from AppRoot
@Composable
Expand Down Expand Up @@ -140,5 +145,48 @@ fun DemoApp(
composable<Routes.Checkout> {
CheckoutScreen(navController = navController)
}

composable<Routes.IdentityVerification> {
IdentityVerificationScreen(navController = navController)
}
}

DeviceSignerOTPDialog(
walletViewModel = walletViewModel,
createWalletViewModel = createWalletViewModel,
)
}

@Composable
private fun DeviceSignerOTPDialog(
walletViewModel: WalletViewModel,
createWalletViewModel: CreateWalletViewModel,
) {
val sdk = LocalCrossmintSDK.current
val scope = rememberCoroutineScope()
var shouldShowOTP by remember { mutableStateOf(false) }

LaunchedEffect(Unit) {
sdk.isOTPRequired.collect { shouldShowOTP = it }
}

if (shouldShowOTP) {
val walletUiState by walletViewModel.uiState.collectAsState()
val createWalletUiState by createWalletViewModel.uiState.collectAsState()
val signerType =
createWalletUiState.pendingOTPSignerType
?: when (walletUiState.selectedSigner?.type?.lowercase()) {
"phone" -> OTPSignerType.PHONE
else -> OTPSignerType.EMAIL
}
OTPDialog(
signerType = signerType,
onOTPSubmit = { scope.launch { sdk.submit(it) } },
onDismiss = {
scope.launch { sdk.cancelTransaction() }
walletViewModel.clearTransaction()
shouldShowOTP = false
},
)
}
}
3 changes: 3 additions & 0 deletions app/src/commonMain/kotlin/com/crossmint/kotlin/Routes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,7 @@ class Routes {

@Serializable
object Checkout

@Serializable
object IdentityVerification
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@ class BringYourOwnAuthViewModel(
return@launch
}

// This function doesn't actually validate the JWT against the JWKS endpoint, but my presumption is that it
// would. Right now we are kind of implicitly relying on the first getWallet api call to validate our JWT token.
authManager.setJWT(jwt)

showToast("Authentication successful")
Expand Down
Loading