From 53016e21ab042c699edc651f43c890c940d51d7c Mon Sep 17 00:00:00 2001 From: tomas-martins-crossmint <245057149+tomas-martins-crossmint@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:32:14 +0000 Subject: [PATCH] sync demo app from SDK 1.2.0 --- app/src/androidMain/AndroidManifest.xml | 5 + .../IdentityVerificationDemoHost.android.kt | 26 ++++ .../playground/PasskeyCreation.android.kt | 129 +++++++++++++++ .../kotlin/AuthMethodSelectionScreen.kt | 35 +++++ .../kotlin/com/crossmint/kotlin/DemoApp.kt | 48 ++++++ .../kotlin/com/crossmint/kotlin/Routes.kt | 3 + .../bringyourown/BringYourOwnAuthViewModel.kt | 2 - .../identity/IdentityVerificationScreen.kt | 147 ++++++++++++++++++ .../kotlin/wallet/CreateWalletViewModel.kt | 17 ++ .../crossmint/kotlin/wallet/WalletEvents.kt | 23 +++ .../crossmint/kotlin/wallet/WalletScreen.kt | 25 --- .../kotlin/wallet/WalletViewModel.kt | 12 ++ .../wallet/createwallet/AdminSignerCard.kt | 30 ++++ .../wallet/createwallet/CreateWalletScreen.kt | 7 +- .../wallet/createwallet/CreateWalletTypes.kt | 8 + gradle/libs.versions.toml | 2 +- 16 files changed, 490 insertions(+), 29 deletions(-) create mode 100644 app/src/androidMain/kotlin/com/crossmint/kotlin/identity/IdentityVerificationDemoHost.android.kt create mode 100644 app/src/commonMain/kotlin/com/crossmint/kotlin/identity/IdentityVerificationScreen.kt 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"