diff --git a/app/src/androidMain/AndroidManifest.xml b/app/src/androidMain/AndroidManifest.xml
index 3b0e971..fa016ba 100644
--- a/app/src/androidMain/AndroidManifest.xml
+++ b/app/src/androidMain/AndroidManifest.xml
@@ -3,6 +3,11 @@
+
+
+
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}")
+ },
+ )
+}
diff --git a/app/src/androidMain/kotlin/com/crossmint/kotlin/wallet/playground/PasskeyCreation.android.kt b/app/src/androidMain/kotlin/com/crossmint/kotlin/wallet/playground/PasskeyCreation.android.kt
index ce085f8..6bce9ab 100644
--- a/app/src/androidMain/kotlin/com/crossmint/kotlin/wallet/playground/PasskeyCreation.android.kt
+++ b/app/src/androidMain/kotlin/com/crossmint/kotlin/wallet/playground/PasskeyCreation.android.kt
@@ -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(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()
+ pendingRequest = PendingPasskeyRequest(name, result)
+ try {
+ result.await()
+ } finally {
+ pendingRequest = null
+ }
+ }
+}
+
+private class PendingPasskeyRequest(
+ val name: String,
+ val result: CompletableDeferred,
+)
+
+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,
diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/AuthMethodSelectionScreen.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/AuthMethodSelectionScreen.kt
index 73de51c..297c793 100644
--- a/app/src/commonMain/kotlin/com/crossmint/kotlin/AuthMethodSelectionScreen.kt
+++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/AuthMethodSelectionScreen.kt
@@ -108,6 +108,10 @@ fun AuthMethodSelectionScreen(
CheckoutButton(onClick = {
navController.navigate(Routes.Checkout)
})
+
+ IdentityVerificationButton(onClick = {
+ navController.navigate(Routes.IdentityVerification)
+ })
}
Spacer(modifier = Modifier.weight(1f))
@@ -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,
+ )
+ }
+ }
+}
diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/DemoApp.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/DemoApp.kt
index 5e39bd0..b6ffde1 100644
--- a/app/src/commonMain/kotlin/com/crossmint/kotlin/DemoApp.kt
+++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/DemoApp.kt
@@ -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
@@ -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
@@ -140,5 +145,48 @@ fun DemoApp(
composable {
CheckoutScreen(navController = navController)
}
+
+ composable {
+ 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
+ },
+ )
}
}
diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/Routes.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/Routes.kt
index 1c8af63..2e5a091 100644
--- a/app/src/commonMain/kotlin/com/crossmint/kotlin/Routes.kt
+++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/Routes.kt
@@ -23,4 +23,7 @@ class Routes {
@Serializable
object Checkout
+
+ @Serializable
+ object IdentityVerification
}
diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/auth/bringyourown/BringYourOwnAuthViewModel.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/auth/bringyourown/BringYourOwnAuthViewModel.kt
index 738e741..7c1c57b 100644
--- a/app/src/commonMain/kotlin/com/crossmint/kotlin/auth/bringyourown/BringYourOwnAuthViewModel.kt
+++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/auth/bringyourown/BringYourOwnAuthViewModel.kt
@@ -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")
diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/identity/IdentityVerificationScreen.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/identity/IdentityVerificationScreen.kt
new file mode 100644
index 0000000..0056407
--- /dev/null
+++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/identity/IdentityVerificationScreen.kt
@@ -0,0 +1,147 @@
+package com.crossmint.kotlin.identity
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavController
+import com.crossmint.demo.BuildKonfig
+
+@Composable
+expect fun IdentityVerificationDemoHost(
+ apiKey: String,
+ inquiryId: String,
+ sessionToken: String?,
+ onEvent: (String) -> Unit,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun IdentityVerificationScreen(navController: NavController) {
+ var inquiryId by remember { mutableStateOf("") }
+ var sessionToken by remember { mutableStateOf("") }
+ var activeCredentials by remember { mutableStateOf(null) }
+ var attempt by remember { mutableStateOf(0) }
+ val events = remember { mutableStateListOf() }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(Color.White),
+ ) {
+ TopAppBar(
+ title = { Text("Identity Verification") },
+ navigationIcon = {
+ IconButton(onClick = { navController.popBackStack() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
+ },
+ )
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ ) {
+ OutlinedTextField(
+ value = inquiryId,
+ onValueChange = { inquiryId = it },
+ label = { Text("Inquiry ID") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = sessionToken,
+ onValueChange = { sessionToken = it },
+ label = { Text("Session token (optional)") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Button(
+ onClick = {
+ events.clear()
+ attempt += 1
+ activeCredentials =
+ DemoCredentials(
+ inquiryId = inquiryId.trim(),
+ sessionToken = sessionToken.trim().ifEmpty { null },
+ )
+ },
+ enabled = inquiryId.isNotBlank(),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Start verification")
+ }
+
+ activeCredentials?.let { credentials ->
+ Spacer(modifier = Modifier.height(16.dp))
+
+ key(attempt) {
+ IdentityVerificationDemoHost(
+ apiKey = BuildKonfig.CROSSMINT_API_KEY,
+ inquiryId = credentials.inquiryId,
+ sessionToken = credentials.sessionToken,
+ onEvent = { events.add(it) },
+ )
+ }
+ }
+
+ if (events.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text("Events", fontSize = 14.sp)
+ for (event in events) {
+ Text(
+ text = event,
+ fontSize = 12.sp,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.padding(vertical = 2.dp),
+ )
+ }
+ }
+ }
+ }
+}
+
+private data class DemoCredentials(
+ val inquiryId: String,
+ val sessionToken: String?,
+)
diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/CreateWalletViewModel.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/CreateWalletViewModel.kt
index eac4805..7e41d41 100644
--- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/CreateWalletViewModel.kt
+++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/CreateWalletViewModel.kt
@@ -3,6 +3,7 @@ package com.crossmint.kotlin.wallet
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
+import com.crossmint.kotlin.OTPSignerType
import com.crossmint.kotlin.signers.DelegatedSigner
import com.crossmint.kotlin.signers.SignerType
import com.crossmint.kotlin.types.Chain
@@ -18,6 +19,7 @@ data class CreateWalletUiState(
val isCreating: Boolean = false,
val errorMessage: String? = null,
val createdWallet: Wallet? = null,
+ val pendingOTPSignerType: OTPSignerType? = null,
) {
val hasError: Boolean
get() = errorMessage != null
@@ -44,6 +46,16 @@ class CreateWalletViewModel(
_uiState.value.copy(
isCreating = true,
errorMessage = null,
+ pendingOTPSignerType =
+ if (deviceSigner) {
+ when (signer) {
+ is SignerType.Email -> OTPSignerType.EMAIL
+ is SignerType.Phone -> OTPSignerType.PHONE
+ SignerType.ApiKey, is SignerType.Passkey -> null
+ }
+ } else {
+ null
+ },
)
when (
@@ -56,11 +68,15 @@ class CreateWalletViewModel(
)
) {
is Result.Success -> {
+ if (signer is SignerType.Phone) {
+ WalletEvents.rememberPhoneChannel(signer.phoneNumber, signer.channel)
+ }
_uiState.value =
_uiState.value.copy(
createdWallet = result.value,
isCreating = false,
errorMessage = null,
+ pendingOTPSignerType = null,
)
// Signal WalletViewModel to refresh
WalletEvents.notifyWalletCreated()
@@ -72,6 +88,7 @@ class CreateWalletViewModel(
_uiState.value.copy(
isCreating = false,
errorMessage = "Failed to create wallet: ${result.error.message}",
+ pendingOTPSignerType = null,
)
}
}
diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletEvents.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletEvents.kt
index 72a2a28..dce8ce0 100644
--- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletEvents.kt
+++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletEvents.kt
@@ -1,5 +1,6 @@
package com.crossmint.kotlin.wallet
+import com.crossmint.kotlin.signers.OTPDeliveryChannel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -24,4 +25,26 @@ object WalletEvents {
fun notifyWalletCreated() {
_walletCreated.tryEmit(Unit)
}
+
+ /**
+ * The OTP delivery channel chosen for a phone signer, by phone number.
+ *
+ * The wallet API never returns this, so a wallet fetched with getWallet always falls back to
+ * SMS. The demo keeps the choice here and reapplies it with useSigner on every wallet load.
+ */
+ private val phoneChannels = mutableMapOf()
+
+ fun rememberPhoneChannel(
+ phoneNumber: String,
+ channel: OTPDeliveryChannel?,
+ ) {
+ if (channel != null) phoneChannels[phoneNumber] = channel
+ }
+
+ fun phoneChannel(phoneNumber: String): OTPDeliveryChannel? = phoneChannels[phoneNumber]
+
+ /** Called on sign out, so a channel never carries over to the next session. */
+ fun clearPhoneChannels() {
+ phoneChannels.clear()
+ }
}
diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletScreen.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletScreen.kt
index 52d10d9..5a54a66 100644
--- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletScreen.kt
+++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletScreen.kt
@@ -65,10 +65,7 @@ import com.crossmint.crossmintdemoapp.generated.resources.ic_crossmint
import com.crossmint.crossmintdemoapp.generated.resources.ic_eth
import com.crossmint.crossmintdemoapp.generated.resources.ic_solana
import com.crossmint.crossmintdemoapp.generated.resources.ic_stellar
-import com.crossmint.kotlin.CrossmintSDK
import com.crossmint.kotlin.Deps
-import com.crossmint.kotlin.OTPDialog
-import com.crossmint.kotlin.OTPSignerType
import com.crossmint.kotlin.Routes
import com.crossmint.kotlin.auth.AuthMethod
import com.crossmint.kotlin.auth.crossmint.CrossmintAuthViewModel
@@ -108,14 +105,9 @@ fun WalletScreen(
var showSignersSheet by remember { mutableStateOf(false) }
var showActivitySheet by remember { mutableStateOf(false) }
var showSigningSheet by remember { mutableStateOf(false) }
- val shouldShowOTP = remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
val passkeyCreator = rememberPasskeyCreator()
- LaunchedEffect(Unit) {
- CrossmintSDK.shared.isOTPRequired.collect { shouldShowOTP.value = it }
- }
-
LaunchedEffect(Unit) {
walletViewModel.sessionExpired.collect {
when (authMethod) {
@@ -411,23 +403,6 @@ fun WalletScreen(
onDismiss = { showSigningSheet = false },
)
}
-
- if (shouldShowOTP.value) {
- val signerType =
- when (uiState.selectedSigner?.type?.lowercase()) {
- "phone" -> OTPSignerType.PHONE
- else -> OTPSignerType.EMAIL
- }
- OTPDialog(
- signerType = signerType,
- onOTPSubmit = { scope.launch { CrossmintSDK.shared.submit(it) } },
- onDismiss = {
- scope.launch { CrossmintSDK.shared.cancelTransaction() }
- walletViewModel.clearTransaction()
- shouldShowOTP.value = false
- },
- )
- }
}
}
diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletViewModel.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletViewModel.kt
index 0e1ac6e..350e4da 100644
--- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletViewModel.kt
+++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletViewModel.kt
@@ -64,6 +64,12 @@ class WalletViewModel(
@OptIn(ExperimentalUuidApi::class)
private fun newIdempotencyKey(): String = Uuid.random().toString()
+ private suspend fun reapplyPhoneChannel(wallet: Wallet) {
+ val admin = wallet.config.adminSigner as? SignerData.Phone ?: return
+ val channel = WalletEvents.phoneChannel(admin.phone) ?: return
+ wallet.useSigner(DelegatedSigner.Phone(admin.phone, channel = channel))
+ }
+
fun fetchWallet(chain: Chain) {
val supportedChain = SupportedChain.entries.find { it.chain == chain } ?: return
viewModelScope.launch {
@@ -82,6 +88,7 @@ class WalletViewModel(
) {
val signers = buildAvailableSigners(wallet)
val securityLevel = crossmintWallets.getDeviceSignerSecurityLevel(wallet.address)
+ reapplyPhoneChannel(wallet)
walletCache[supportedChain] = CachedWallet(wallet, securityLevel)
notFoundChains.remove(supportedChain)
activeFetches.remove(supportedChain)
@@ -154,6 +161,7 @@ class WalletViewModel(
is Result.Success -> {
val wallet = result.value
val securityLevel = crossmintWallets.getDeviceSignerSecurityLevel(wallet.address)
+ reapplyPhoneChannel(wallet)
walletCache[chain] = CachedWallet(wallet, securityLevel)
}
is Result.Failure -> {
@@ -205,6 +213,9 @@ class WalletViewModel(
_uiState.value = _uiState.value.copy(isCreatingWallet = true, errorMessage = null)
when (val result = crossmintWallets.createWallet(chain, signer, delegatedSigners)) {
is Result.Success -> {
+ if (signer is SignerType.Phone) {
+ WalletEvents.rememberPhoneChannel(signer.phoneNumber, signer.channel)
+ }
val wallet = result.value
val signers = buildAvailableSigners(wallet)
val supportedChain = SupportedChain.entries.find { it.chain == chain }
@@ -315,6 +326,7 @@ class WalletViewModel(
walletCache.clear()
notFoundChains.clear()
activeFetches.clear()
+ WalletEvents.clearPhoneChannels()
_uiState.value = WalletUiState()
}
diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/AdminSignerCard.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/AdminSignerCard.kt
index 3e0a972..5ea2ddc 100644
--- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/AdminSignerCard.kt
+++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/AdminSignerCard.kt
@@ -17,10 +17,14 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -31,7 +35,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.crossmint.kotlin.signers.OTPDeliveryChannel
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminSignerCard(
selectedType: AdminSignerType,
@@ -40,6 +46,8 @@ fun AdminSignerCard(
onEmailChange: (String) -> Unit,
phone: String,
onPhoneChange: (String) -> Unit,
+ phoneChannel: OTPDeliveryChannel,
+ onPhoneChannelChange: (OTPDeliveryChannel) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
@@ -131,6 +139,28 @@ fun AdminSignerCard(
),
singleLine = true,
)
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Text(
+ text = "OTP delivery",
+ fontSize = 12.sp,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
+ OTPDeliveryChannel.entries.forEachIndexed { index, channel ->
+ SegmentedButton(
+ selected = phoneChannel == channel,
+ onClick = { onPhoneChannelChange(channel) },
+ shape =
+ SegmentedButtonDefaults.itemShape(
+ index = index,
+ count = OTPDeliveryChannel.entries.size,
+ ),
+ ) { Text(channel.displayName) }
+ }
+ }
}
AdminSignerType.API_KEY -> {
Text(
diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletScreen.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletScreen.kt
index 49afa6b..dd6c0df 100644
--- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletScreen.kt
+++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletScreen.kt
@@ -36,6 +36,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.crossmint.kotlin.signers.DelegatedSigner
+import com.crossmint.kotlin.signers.OTPDeliveryChannel
import com.crossmint.kotlin.signers.SignerType
import com.crossmint.kotlin.types.Chain
import com.crossmint.kotlin.wallet.CreateWalletViewModel
@@ -58,6 +59,7 @@ fun CreateWalletScreen(
var selectedAdminSignerType by remember { mutableStateOf(AdminSignerType.EMAIL) }
var adminEmail by remember { mutableStateOf(userEmail ?: "") }
var adminPhone by remember { mutableStateOf("") }
+ var adminPhoneChannel by remember { mutableStateOf(OTPDeliveryChannel.SMS) }
val delegatedSigners = remember { mutableStateListOf() }
@@ -99,7 +101,8 @@ fun CreateWalletScreen(
val adminSigner =
when (selectedAdminSignerType) {
AdminSignerType.EMAIL -> SignerType.Email(adminEmail)
- AdminSignerType.PHONE -> SignerType.Phone(adminPhone)
+ AdminSignerType.PHONE ->
+ SignerType.Phone(adminPhone, channel = adminPhoneChannel)
AdminSignerType.API_KEY -> SignerType.ApiKey
}
val deviceEntry = delegatedSigners.firstOrNull { it.type == DelegatedSignerType.DEVICE }
@@ -182,6 +185,8 @@ fun CreateWalletScreen(
onEmailChange = { adminEmail = it },
phone = adminPhone,
onPhoneChange = { adminPhone = it },
+ phoneChannel = adminPhoneChannel,
+ onPhoneChannelChange = { adminPhoneChannel = it },
)
Spacer(modifier = Modifier.height(32.dp))
diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletTypes.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletTypes.kt
index 4c39975..2e4af49 100644
--- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletTypes.kt
+++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletTypes.kt
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.ui.graphics.vector.ImageVector
+import com.crossmint.kotlin.signers.OTPDeliveryChannel
enum class AdminSignerType(
val displayName: String,
@@ -16,6 +17,13 @@ enum class AdminSignerType(
API_KEY("API Key"),
}
+val OTPDeliveryChannel.displayName: String
+ get() =
+ when (this) {
+ OTPDeliveryChannel.SMS -> "SMS"
+ OTPDeliveryChannel.WHATSAPP -> "WhatsApp"
+ }
+
enum class DelegatedSignerType(
val displayName: String,
val icon: ImageVector,
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5f8908a..5640307 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-crossmint = "1.1.0"
+crossmint = "1.2.0"
agp = "8.12.2"
android-compileSdk = "35"