From bf7aac4e10e563d5514e962d6264669bda954c34 Mon Sep 17 00:00:00 2001 From: Chen Cen <79880926+ccen-stripe@users.noreply.github.com> Date: Thu, 11 Aug 2022 17:20:58 -0700 Subject: [PATCH] [Identity] Add a factory method for Compose and update the example app (#5370) * [Identity] Add a factory method for Compose and update the example app --- CHANGELOG.md | 3 + identity-example/build.gradle | 21 + identity-example/src/main/AndroidManifest.xml | 6 +- .../example/ComposeExampleActivity.kt | 544 ++++++++++++++++++ .../android/identity/example/EntryActivity.kt | 43 ++ .../example/IdentityExampleViewModel.kt | 72 +++ .../android/identity/example/MainActivity.kt | 126 +--- .../android/identity/example/network.kt | 31 + .../src/main/res/layout/activity_main.xml | 2 +- .../src/main/res/values/strings.xml | 4 + .../identity/example/ComposeThemeActivity.kt | 5 + .../identity/example/ComposeThemeActivity.kt | 5 + identity/api/identity.api | 7 + identity/build.gradle | 9 + .../identity/IdentityVerificationSheet.kt | 33 ++ .../StripeIdentityVerificationSheet.kt | 30 +- 16 files changed, 825 insertions(+), 116 deletions(-) create mode 100644 identity-example/src/main/java/com/stripe/android/identity/example/ComposeExampleActivity.kt create mode 100644 identity-example/src/main/java/com/stripe/android/identity/example/EntryActivity.kt create mode 100644 identity-example/src/main/java/com/stripe/android/identity/example/IdentityExampleViewModel.kt create mode 100644 identity-example/src/main/java/com/stripe/android/identity/example/network.kt create mode 100644 identity-example/src/theme1/java/com/stripe/android/identity/example/ComposeThemeActivity.kt create mode 100644 identity-example/src/theme2/java/com/stripe/android/identity/example/ComposeThemeActivity.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 445293fd4c9..013ff745b88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,9 @@ and adds new `rememberLauncher` features for Payments * [DEPRECATED][5274](https://github.com/stripe/stripe-android/pull/5274) Deprecate `PaymentLauncher.createForCompose` in favor of `PaymentLauncher.rememberLauncher`. +### Identity +* [ADDED][5370](https://github.com/stripe/stripe-android/pull/5370) Add factory method for Compose + ## 20.7.0 - 2022-07-06 This release adds additional support for Afterpay/Clearpay in PaymentSheet. diff --git a/identity-example/build.gradle b/identity-example/build.gradle index d9227b31fff..41cbcc8caed 100644 --- a/identity-example/build.gradle +++ b/identity-example/build.gradle @@ -43,6 +43,11 @@ android { buildFeatures { viewBinding true + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion "$androidxComposeVersion" } flavorDimensions += "theme" @@ -75,13 +80,29 @@ dependencies { implementation "androidx.appcompat:appcompat:$androidxAppcompatVersion" implementation "com.google.android.material:material:$materialVersion" implementation 'com.github.kittinunf.fuel:fuel:2.3.1' + implementation 'com.github.kittinunf.fuel:fuel-coroutines:2.3.1' implementation "androidx.constraintlayout:constraintlayout:$androidxConstraintlayoutVersion" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion" implementation "androidx.browser:browser:$androidxBrowserVersion" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidxLifecycleVersion" testImplementation "junit:junit:$junitVersion" ktlint "com.pinterest:ktlint:$ktlintVersion" + + // compose + // Integration with activities + implementation "androidx.activity:activity-compose:$androidxComposeVersion" + // Integration with ViewModels + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion" + implementation("androidx.compose.ui:ui:$androidxComposeVersion") + // Material Design + implementation("androidx.compose.material:material:$androidxComposeVersion") + // Integration with observables + implementation("androidx.compose.runtime:runtime-livedata:$androidxComposeVersion") + // MdcTheme + implementation "com.google.android.material:compose-theme-adapter:1.1.15" + } task ktlint(type: JavaExec, group: "verification") { diff --git a/identity-example/src/main/AndroidManifest.xml b/identity-example/src/main/AndroidManifest.xml index 81ee35d9348..3b294c88046 100644 --- a/identity-example/src/main/AndroidManifest.xml +++ b/identity-example/src/main/AndroidManifest.xml @@ -13,7 +13,7 @@ android:supportsRtl="true" android:theme="${appTheme}"> @@ -21,6 +21,10 @@ + + diff --git a/identity-example/src/main/java/com/stripe/android/identity/example/ComposeExampleActivity.kt b/identity-example/src/main/java/com/stripe/android/identity/example/ComposeExampleActivity.kt new file mode 100644 index 00000000000..ea73d3481ed --- /dev/null +++ b/identity-example/src/main/java/com/stripe/android/identity/example/ComposeExampleActivity.kt @@ -0,0 +1,544 @@ +package com.stripe.android.identity.example + +import android.content.ContentResolver +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.LocalContentColor +import androidx.compose.material.RadioButton +import androidx.compose.material.Scaffold +import androidx.compose.material.ScaffoldState +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +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.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.github.kittinunf.result.Result +import com.google.android.material.composethemeadapter.MdcTheme +import com.stripe.android.identity.IdentityVerificationSheet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +abstract class ComposeExampleActivity : ComponentActivity() { + protected abstract val getBrandLogoResId: Int + + private val viewModel: IdentityExampleViewModel by viewModels() + private val configuration by lazy { + IdentityVerificationSheet.Configuration( + // Or use webImage by + // brandLogo = Uri.parse("https://path/to/a/logo.jpg") + brandLogo = Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(resources.getResourcePackageName(getBrandLogoResId)) + .appendPath(resources.getResourceTypeName(getBrandLogoResId)) + .appendPath(resources.getResourceEntryName(getBrandLogoResId)) + .build() + ) + } + + data class IdentitySubmissionState( + val shouldUseNativeSdk: Boolean = true, + val allowDrivingLicense: Boolean = true, + val allowPassport: Boolean = true, + val allowId: Boolean = true, + val requireLiveCapture: Boolean = true, + val requireId: Boolean = false, + val requireSelfie: Boolean = false + ) + + sealed class LoadingState { + object Idle : LoadingState() + object Loading : LoadingState() + class Result(val vsId: String = "", val resultString: String) : LoadingState() + } + + @Composable + fun ExampleScreen() { + val scaffoldState = rememberScaffoldState() + val coroutineScope = rememberCoroutineScope() + Scaffold( + scaffoldState = scaffoldState, + topBar = { + TopAppBar { + Text( + text = stringResource(id = R.string.compose_example), + modifier = Modifier.padding(start = 10.dp), + fontWeight = FontWeight.Bold, + color = LocalContentColor.current + ) + } + } + ) { + Column( + modifier = Modifier + .padding(it) + .fillMaxWidth() + .fillMaxHeight() + ) { + val (submissionState, onSubmissionStateChanged) = remember { + mutableStateOf(IdentitySubmissionState()) + } + NativeOrWeb(submissionState, onSubmissionStateChanged) + Divider() + AllowedDocumentTypes(submissionState, onSubmissionStateChanged) + RequireDocTypes(submissionState, onSubmissionStateChanged) + Divider() + + val (loadingState, onLoadingStateChanged) = remember { + mutableStateOf(LoadingState.Idle) + } + var vsId by remember { mutableStateOf("") } + + val identityVerificationSheet = + IdentityVerificationSheet.rememberIdentityVerificationSheet( + configuration = configuration + ) { result -> + when (result) { + is IdentityVerificationSheet.VerificationFlowResult.Failed -> { + onLoadingStateChanged( + LoadingState.Result( + vsId = vsId, + resultString = "Verification result: ${result.javaClass.simpleName} - ${result.throwable}" + ) + ) + } + is IdentityVerificationSheet.VerificationFlowResult.Canceled -> { + onLoadingStateChanged( + LoadingState.Result( + vsId = vsId, + resultString = "Verification result: ${result.javaClass.simpleName}" + ) + ) + } + is IdentityVerificationSheet.VerificationFlowResult.Completed -> { + onLoadingStateChanged( + LoadingState.Result( + vsId = vsId, + resultString = "Verification result: ${result.javaClass.simpleName}" + ) + ) + } + } + } + + LoadVSView( + loadingState, + submissionState, + scaffoldState, + coroutineScope, + onLoadingStateChanged + ) { verificationSessionId, ephemeralKeySecret, url -> + vsId = verificationSessionId + if (submissionState.shouldUseNativeSdk) { + identityVerificationSheet.present(verificationSessionId, ephemeralKeySecret) + } else { + onLoadingStateChanged( + LoadingState.Result(vsId, "web redirect") + ) + CustomTabsIntent.Builder().build() + .launchUrl(this@ComposeExampleActivity, Uri.parse(url)) + } + } + } + } + } + + @Composable + fun NativeOrWeb( + identitySubmissionState: IdentitySubmissionState, + onSubmissionStateChangedListener: (IdentitySubmissionState) -> Unit + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = identitySubmissionState.shouldUseNativeSdk, + onClick = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + shouldUseNativeSdk = true + ) + ) + } + ) + ClickableText( + text = AnnotatedString(getString(R.string.use_native)), + onClick = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + shouldUseNativeSdk = true + ) + ) + } + ) + Spacer(modifier = Modifier.width(40.dp)) + + RadioButton( + selected = !identitySubmissionState.shouldUseNativeSdk, + onClick = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + shouldUseNativeSdk = false + ) + ) + } + ) + ClickableText( + text = AnnotatedString(getString(R.string.use_web)), + onClick = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + shouldUseNativeSdk = false + ) + ) + } + ) + Spacer(modifier = Modifier.width(40.dp)) + } + } + + @Composable + fun AllowedDocumentTypes( + identitySubmissionState: IdentitySubmissionState, + onSubmissionStateChangedListener: (IdentitySubmissionState) -> Unit + ) { + Text( + text = stringResource(id = R.string.allowed_types), + fontSize = 16.sp, + modifier = Modifier.padding(start = 10.dp, top = 16.dp) + ) + Row( + modifier = Modifier.padding(start = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = identitySubmissionState.allowDrivingLicense, + onCheckedChange = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + allowDrivingLicense = it + ) + ) + }, + modifier = Modifier.padding(end = 0.dp) + ) + ClickableText( + text = AnnotatedString(stringResource(id = R.string.driver_license)), + onClick = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + allowDrivingLicense = !identitySubmissionState.allowDrivingLicense + ) + ) + } + ) + + Checkbox(checked = identitySubmissionState.allowPassport, onCheckedChange = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + allowPassport = it + ) + ) + }) + ClickableText( + text = AnnotatedString(stringResource(id = R.string.passport)), + onClick = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + allowPassport = !identitySubmissionState.allowPassport + ) + ) + } + ) + + Checkbox(checked = identitySubmissionState.allowId, onCheckedChange = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + allowId = it + ) + ) + }) + ClickableText( + text = AnnotatedString(stringResource(id = R.string.id_card)), + onClick = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + allowId = !identitySubmissionState.allowId + ) + ) + } + ) + } + } + + @Composable + fun RequireDocTypes( + identitySubmissionState: IdentitySubmissionState, + onSubmissionStateChangedListener: (IdentitySubmissionState) -> Unit + ) { + Row( + modifier = Modifier.padding(start = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = identitySubmissionState.requireLiveCapture, + onCheckedChange = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + requireLiveCapture = it + ) + ) + } + ) + ClickableText( + text = AnnotatedString(stringResource(id = R.string.require_live_capture)), + onClick = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + requireLiveCapture = !identitySubmissionState.requireLiveCapture + ) + ) + } + ) + } + + Row( + modifier = Modifier.padding(start = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (identitySubmissionState.shouldUseNativeSdk) { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + requireId = false + ) + ) + Checkbox( + checked = identitySubmissionState.requireId, + onCheckedChange = { + // no-op + }, + enabled = false + ) + Text( + text = stringResource(id = R.string.require_id_number), + style = TextStyle.Default, + color = Color.Unspecified.copy(alpha = 0.5f) + ) + } else { + Checkbox( + checked = identitySubmissionState.requireId, + onCheckedChange = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + requireId = it + ) + ) + } + ) + ClickableText( + text = AnnotatedString(stringResource(id = R.string.require_id_number)), + onClick = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + requireId = !identitySubmissionState.requireId + ) + ) + } + ) + } + } + + Row( + modifier = Modifier.padding(start = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = identitySubmissionState.requireSelfie, + onCheckedChange = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + requireSelfie = it + ) + ) + } + ) + ClickableText( + text = AnnotatedString(stringResource(id = R.string.require_matching_selfie)), + onClick = { + onSubmissionStateChangedListener( + identitySubmissionState.copy( + requireSelfie = !identitySubmissionState.requireSelfie + ) + ) + } + ) + } + } + + @Composable + internal fun LoadVSView( + loadingState: LoadingState, + identitySubmissionState: IdentitySubmissionState, + scaffoldState: ScaffoldState, + coroutineScope: CoroutineScope, + onLoadingStateChanged: (LoadingState) -> Unit, + onPostResult: (String, String, String) -> Unit + ) { + when (loadingState) { + LoadingState.Idle -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + Spacer(modifier = Modifier.weight(1f)) + LoadingButton( + enabled = true, + submissionState = identitySubmissionState, + scaffoldState = scaffoldState, + coroutineScope = coroutineScope, + onLoadingStateChanged = onLoadingStateChanged, + onPostResult = onPostResult + ) + } + } + LoadingState.Loading -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator() + } + LoadingButton( + enabled = false, + submissionState = identitySubmissionState, + scaffoldState = scaffoldState, + coroutineScope = coroutineScope, + onLoadingStateChanged = onLoadingStateChanged, + onPostResult = onPostResult + ) + } + } + is LoadingState.Result -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + SelectionContainer { + Text( + text = loadingState.vsId, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(10.dp) + ) + } + Text( + text = loadingState.resultString, + modifier = Modifier.padding(horizontal = 10.dp) + ) + } + LoadingButton( + enabled = true, + submissionState = identitySubmissionState, + scaffoldState = scaffoldState, + coroutineScope = coroutineScope, + onLoadingStateChanged = onLoadingStateChanged, + onPostResult = onPostResult + ) + } + } + } + } + + @Composable + fun LoadingButton( + enabled: Boolean, + submissionState: IdentitySubmissionState, + scaffoldState: ScaffoldState, + coroutineScope: CoroutineScope, + onLoadingStateChanged: (LoadingState) -> Unit, + onPostResult: (String, String, String) -> Unit + ) { + Button( + onClick = { + coroutineScope.launch { + scaffoldState.snackbarHostState.showSnackbar("Getting verificationSessionId and ephemeralKeySecret from backend...") + } + onLoadingStateChanged(LoadingState.Loading) + viewModel.postForResult( + allowDrivingLicense = submissionState.allowDrivingLicense, + allowPassport = submissionState.allowPassport, + allowId = submissionState.allowId, + requireLiveCapture = submissionState.requireLiveCapture, + requireId = submissionState.requireId, + requireSelfie = submissionState.requireSelfie + ).observe(this) { + when (it) { + is Result.Success -> { + onPostResult( + it.value.verificationSessionId, + it.value.ephemeralKeySecret, + it.value.url + ) + } + is Result.Failure -> { + onLoadingStateChanged( + LoadingState.Result( + resultString = "Error generating verificationSessionId and ephemeralKeySecret: ${it.getException().message}" + ) + ) + } + } + } + }, + modifier = Modifier.padding(bottom = 40.dp), + enabled = enabled + ) { + Text(text = stringResource(id = R.string.start_verification)) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MdcTheme { + ExampleScreen() + } + } + } +} diff --git a/identity-example/src/main/java/com/stripe/android/identity/example/EntryActivity.kt b/identity-example/src/main/java/com/stripe/android/identity/example/EntryActivity.kt new file mode 100644 index 00000000000..87e42c51597 --- /dev/null +++ b/identity-example/src/main/java/com/stripe/android/identity/example/EntryActivity.kt @@ -0,0 +1,43 @@ +package com.stripe.android.identity.example + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.google.android.material.composethemeadapter.MdcTheme + +class EntryActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MdcTheme { + val context = LocalContext.current + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button(onClick = { + startActivity(Intent(context, ThemeActivity::class.java)) + }) { + Text(stringResource(id = R.string.classic_example)) + } + Button(onClick = { + startActivity(Intent(context, ComposeThemeActivity::class.java)) + }) { + Text(stringResource(id = R.string.compose_example)) + } + } + } + } + } +} diff --git a/identity-example/src/main/java/com/stripe/android/identity/example/IdentityExampleViewModel.kt b/identity-example/src/main/java/com/stripe/android/identity/example/IdentityExampleViewModel.kt new file mode 100644 index 00000000000..945b0b85680 --- /dev/null +++ b/identity-example/src/main/java/com/stripe/android/identity/example/IdentityExampleViewModel.kt @@ -0,0 +1,72 @@ +package com.stripe.android.identity.example + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.liveData +import com.github.kittinunf.fuel.Fuel +import com.github.kittinunf.fuel.coroutines.awaitStringResult +import com.github.kittinunf.result.Result +import kotlinx.serialization.json.Json + +internal class IdentityExampleViewModel : ViewModel() { + private val json by lazy { + Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + } + } + + fun postForResult( + allowDrivingLicense: Boolean, + allowPassport: Boolean, + allowId: Boolean, + requireLiveCapture: Boolean, + requireId: Boolean, + requireSelfie: Boolean + ) = liveData { + val result = Fuel.post(EXAMPLE_BACKEND_URL) + .header("content-type", "application/json") + .body( + json.encodeToString( + VerificationSessionCreationRequest.serializer(), + VerificationSessionCreationRequest( + options = VerificationSessionCreationRequest.Options( + document = VerificationSessionCreationRequest.Document( + requireIdNumber = requireId, + requireMatchingSelfie = requireSelfie, + requireLiveCapture = requireLiveCapture, + allowedTypes = mutableListOf().also { + if (allowDrivingLicense) it.add(DRIVING_LICENSE) + if (allowPassport) it.add(PASSPORT) + if (allowId) it.add(ID_CARD) + } + ) + ) + ) + ) + ).awaitStringResult() + + if (result is Result.Failure) { + emit(result) + } else { + try { + json.decodeFromString( + VerificationSessionCreationResponse.serializer(), + result.get() + ).let { + emit(Result.success(it)) + } + } catch (t: Throwable) { + emit(Result.error(Exception(t))) + } + } + } + + private companion object { + const val DRIVING_LICENSE = "driving_license" + const val PASSPORT = "passport" + const val ID_CARD = "id_card" + const val EXAMPLE_BACKEND_URL = + "https://reflective-fossil-rib.glitch.me/create-verification-session" + } +} diff --git a/identity-example/src/main/java/com/stripe/android/identity/example/MainActivity.kt b/identity-example/src/main/java/com/stripe/android/identity/example/MainActivity.kt index b2aae4a11a8..0c22f978b46 100644 --- a/identity-example/src/main/java/com/stripe/android/identity/example/MainActivity.kt +++ b/identity-example/src/main/java/com/stripe/android/identity/example/MainActivity.kt @@ -4,16 +4,13 @@ import android.content.ContentResolver import android.net.Uri import android.os.Bundle import android.view.View +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.browser.customtabs.CustomTabsIntent -import com.github.kittinunf.fuel.Fuel import com.github.kittinunf.result.Result import com.google.android.material.snackbar.Snackbar import com.stripe.android.identity.IdentityVerificationSheet import com.stripe.android.identity.example.databinding.ActivityMainBinding -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json abstract class MainActivity : AppCompatActivity() { @@ -23,16 +20,10 @@ abstract class MainActivity : AppCompatActivity() { private lateinit var identityVerificationSheet: IdentityVerificationSheet - private val json by lazy { - Json { - ignoreUnknownKeys = true - isLenient = true - encodeDefaults = false - } - } - protected abstract val getBrandLogoResId: Int + private val viewModel: IdentityExampleViewModel by viewModels() + private val logoUri: Uri get() = Uri.Builder() .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) @@ -93,60 +84,36 @@ abstract class MainActivity : AppCompatActivity() { Snackbar.LENGTH_LONG ) - Fuel.post(EXAMPLE_BACKEND_URL) - .header("content-type", "application/json") - .body( - json.encodeToString( - VerificationSessionCreationRequest.serializer(), - VerificationSessionCreationRequest( - options = VerificationSessionCreationRequest.Options( - document = VerificationSessionCreationRequest.Document( - requireIdNumber = binding.requireIdNumber.isChecked, - requireMatchingSelfie = binding.requireMatchingSelfie.isChecked, - requireLiveCapture = binding.requireLiveCapture.isChecked, - allowedTypes = mutableListOf().also { - if (binding.allowedTypeDl.isChecked) it.add(DRIVING_LICENSE) - if (binding.allowedTypePassport.isChecked) it.add(PASSPORT) - if (binding.allowedTypeId.isChecked) it.add(ID_CARD) - } - ) + viewModel.postForResult( + allowDrivingLicense = binding.allowedTypeDl.isChecked, + allowPassport = binding.allowedTypePassport.isChecked, + allowId = binding.allowedTypeId.isChecked, + requireLiveCapture = binding.requireLiveCapture.isChecked, + requireId = binding.requireIdNumber.isChecked, + requireSelfie = binding.requireMatchingSelfie.isChecked + ).observe(this) { result -> + binding.progressCircular.visibility = View.INVISIBLE + binding.startVerification.isEnabled = true + when (result) { + is Result.Failure -> { + binding.resultView.text = + "Error generating verificationSessionId and ephemeralKeySecret: ${result.getException().message}" + } + is Result.Success -> runOnUiThread { + binding.vsView.text = result.value.verificationSessionId + if (binding.useNative.isChecked) { + identityVerificationSheet.present( + verificationSessionId = result.value.verificationSessionId, + ephemeralKeySecret = result.value.ephemeralKeySecret ) - ) - ) - ) - .responseString { _, _, result -> - when (result) { - is Result.Failure -> { - binding.resultView.text = - "Error generating verificationSessionId and ephemeralKeySecret: ${result.getException().message}" - binding.progressCircular.visibility = View.INVISIBLE - binding.startVerification.isEnabled = true - } - is Result.Success -> runOnUiThread { - binding.progressCircular.visibility = View.INVISIBLE - binding.startVerification.isEnabled = true - try { - json.decodeFromString( - VerificationSessionCreationResponse.serializer(), - result.get() - ).let { - binding.vsView.text = it.verificationSessionId - if (binding.useNative.isChecked) { - identityVerificationSheet.present( - verificationSessionId = it.verificationSessionId, - ephemeralKeySecret = it.ephemeralKeySecret - ) - } else { - CustomTabsIntent.Builder().build() - .launchUrl(this, Uri.parse(it.url)) - } - } - } catch (t: Throwable) { - binding.resultView.text = "Fail to decode" - } + } else { + CustomTabsIntent.Builder().build() + .launchUrl(this, Uri.parse(result.value.url)) + binding.resultView.text = "web redirect" } } } + } } } @@ -154,39 +121,4 @@ abstract class MainActivity : AppCompatActivity() { Snackbar.make(binding.root, message, duration) .show() } - - @Serializable - data class VerificationSessionCreationResponse( - @SerialName("client_secret") val clientSecret: String, - @SerialName("ephemeral_key_secret") val ephemeralKeySecret: String, - @SerialName("id") val verificationSessionId: String, - @SerialName("url") val url: String - ) - - @Serializable - data class VerificationSessionCreationRequest( - @SerialName("options") val options: Options? = null, - @SerialName("type") val type: String = "document" - ) { - @Serializable - data class Options( - @SerialName("document") val document: Document? = null - ) - - @Serializable - data class Document( - @SerialName("allowed_types") val allowedTypes: List? = null, - @SerialName("require_id_number") val requireIdNumber: Boolean? = null, - @SerialName("require_live_capture") val requireLiveCapture: Boolean? = null, - @SerialName("require_matching_selfie") val requireMatchingSelfie: Boolean? = null - ) - } - - private companion object { - const val DRIVING_LICENSE = "driving_license" - const val PASSPORT = "passport" - const val ID_CARD = "id_card" - const val EXAMPLE_BACKEND_URL = - "https://reflective-fossil-rib.glitch.me/create-verification-session" - } } diff --git a/identity-example/src/main/java/com/stripe/android/identity/example/network.kt b/identity-example/src/main/java/com/stripe/android/identity/example/network.kt new file mode 100644 index 00000000000..17797a85f58 --- /dev/null +++ b/identity-example/src/main/java/com/stripe/android/identity/example/network.kt @@ -0,0 +1,31 @@ +package com.stripe.android.identity.example + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class VerificationSessionCreationResponse( + @SerialName("client_secret") val clientSecret: String, + @SerialName("ephemeral_key_secret") val ephemeralKeySecret: String, + @SerialName("id") val verificationSessionId: String, + @SerialName("url") val url: String +) + +@Serializable +data class VerificationSessionCreationRequest( + @SerialName("options") val options: Options? = null, + @SerialName("type") val type: String = "document" +) { + @Serializable + data class Options( + @SerialName("document") val document: Document? = null + ) + + @Serializable + data class Document( + @SerialName("allowed_types") val allowedTypes: List? = null, + @SerialName("require_id_number") val requireIdNumber: Boolean? = null, + @SerialName("require_live_capture") val requireLiveCapture: Boolean? = null, + @SerialName("require_matching_selfie") val requireMatchingSelfie: Boolean? = null + ) +} diff --git a/identity-example/src/main/res/layout/activity_main.xml b/identity-example/src/main/res/layout/activity_main.xml index 286c4c6aa52..298ceca41bf 100644 --- a/identity-example/src/main/res/layout/activity_main.xml +++ b/identity-example/src/main/res/layout/activity_main.xml @@ -169,7 +169,7 @@ Require matching selfie Use native SDK Use web redirect + + Classic Activity example + Compose Activity example + diff --git a/identity-example/src/theme1/java/com/stripe/android/identity/example/ComposeThemeActivity.kt b/identity-example/src/theme1/java/com/stripe/android/identity/example/ComposeThemeActivity.kt new file mode 100644 index 00000000000..16975b287c7 --- /dev/null +++ b/identity-example/src/theme1/java/com/stripe/android/identity/example/ComposeThemeActivity.kt @@ -0,0 +1,5 @@ +package com.stripe.android.identity.example + +class ComposeThemeActivity : ComposeExampleActivity() { + override val getBrandLogoResId = R.drawable.merchant_logo_purple +} diff --git a/identity-example/src/theme2/java/com/stripe/android/identity/example/ComposeThemeActivity.kt b/identity-example/src/theme2/java/com/stripe/android/identity/example/ComposeThemeActivity.kt new file mode 100644 index 00000000000..6aac664041c --- /dev/null +++ b/identity-example/src/theme2/java/com/stripe/android/identity/example/ComposeThemeActivity.kt @@ -0,0 +1,5 @@ +package com.stripe.android.identity.example + +class ComposeThemeActivity : ComposeExampleActivity() { + override val getBrandLogoResId = R.drawable.merchant_logo_red +} diff --git a/identity/api/identity.api b/identity/api/identity.api index 3b5b687075e..e036e7599f9 100644 --- a/identity/api/identity.api +++ b/identity/api/identity.api @@ -23,9 +23,11 @@ public abstract interface class com/stripe/android/identity/IdentityVerification public final class com/stripe/android/identity/IdentityVerificationSheet$Companion { public final fun create (Landroidx/activity/ComponentActivity;Lcom/stripe/android/identity/IdentityVerificationSheet$Configuration;Lcom/stripe/android/identity/IdentityVerificationSheet$IdentityVerificationCallback;)Lcom/stripe/android/identity/IdentityVerificationSheet; public final fun create (Landroidx/fragment/app/Fragment;Lcom/stripe/android/identity/IdentityVerificationSheet$Configuration;Lcom/stripe/android/identity/IdentityVerificationSheet$IdentityVerificationCallback;)Lcom/stripe/android/identity/IdentityVerificationSheet; + public final fun rememberIdentityVerificationSheet (Lcom/stripe/android/identity/IdentityVerificationSheet$Configuration;Lcom/stripe/android/identity/IdentityVerificationSheet$IdentityVerificationCallback;Landroidx/compose/runtime/Composer;I)Lcom/stripe/android/identity/IdentityVerificationSheet; } public final class com/stripe/android/identity/IdentityVerificationSheet$Configuration { + public static final field $stable I public fun (Landroid/net/Uri;)V public final fun component1 ()Landroid/net/Uri; public final fun copy (Landroid/net/Uri;)Lcom/stripe/android/identity/IdentityVerificationSheet$Configuration; @@ -41,10 +43,12 @@ public abstract interface class com/stripe/android/identity/IdentityVerification } public abstract class com/stripe/android/identity/IdentityVerificationSheet$VerificationFlowResult : android/os/Parcelable { + public static final field $stable I public final synthetic fun toBundle ()Landroid/os/Bundle; } public final class com/stripe/android/identity/IdentityVerificationSheet$VerificationFlowResult$Canceled : com/stripe/android/identity/IdentityVerificationSheet$VerificationFlowResult { + public static final field $stable I public static final field CREATOR Landroid/os/Parcelable$Creator; public static final field INSTANCE Lcom/stripe/android/identity/IdentityVerificationSheet$VerificationFlowResult$Canceled; public fun describeContents ()I @@ -60,6 +64,7 @@ public final class com/stripe/android/identity/IdentityVerificationSheet$Verific } public final class com/stripe/android/identity/IdentityVerificationSheet$VerificationFlowResult$Completed : com/stripe/android/identity/IdentityVerificationSheet$VerificationFlowResult { + public static final field $stable I public static final field CREATOR Landroid/os/Parcelable$Creator; public static final field INSTANCE Lcom/stripe/android/identity/IdentityVerificationSheet$VerificationFlowResult$Completed; public fun describeContents ()I @@ -75,6 +80,7 @@ public final class com/stripe/android/identity/IdentityVerificationSheet$Verific } public final class com/stripe/android/identity/IdentityVerificationSheet$VerificationFlowResult$Failed : com/stripe/android/identity/IdentityVerificationSheet$VerificationFlowResult { + public static final field $stable I public static final field CREATOR Landroid/os/Parcelable$Creator; public fun (Ljava/lang/Throwable;)V public fun describeContents ()I @@ -383,6 +389,7 @@ public final class com/stripe/android/identity/networking/UploadedResult$Creator } public final class com/stripe/android/identity/utils/ContentUriResult { + public static final field $stable I public fun (Landroid/net/Uri;Ljava/lang/String;)V public final fun component1 ()Landroid/net/Uri; public final fun component2 ()Ljava/lang/String; diff --git a/identity/build.gradle b/identity/build.gradle index 5215bec7737..e049abcb343 100644 --- a/identity/build.gradle +++ b/identity/build.gradle @@ -53,6 +53,10 @@ dependencies { kapt "com.google.dagger:dagger-compiler:$daggerVersion" implementation "androidx.browser:browser:$androidxBrowserVersion" + // Compose + implementation "androidx.compose.runtime:runtime:$androidxComposeVersion" + implementation "androidx.activity:activity-compose:$androidxActivityVersion" + testImplementation "junit:junit:$junitVersion" testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" testImplementation "org.mockito:mockito-inline:$mockitoCoreVersion" @@ -119,6 +123,11 @@ android { buildFeatures { viewBinding true + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion "$androidxComposeVersion" } } diff --git a/identity/src/main/java/com/stripe/android/identity/IdentityVerificationSheet.kt b/identity/src/main/java/com/stripe/android/identity/IdentityVerificationSheet.kt index 755dd1e8ec3..d429c35fbc2 100644 --- a/identity/src/main/java/com/stripe/android/identity/IdentityVerificationSheet.kt +++ b/identity/src/main/java/com/stripe/android/identity/IdentityVerificationSheet.kt @@ -4,7 +4,11 @@ import android.content.Intent import android.net.Uri import android.os.Parcelable import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResultLauncher +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import kotlinx.parcelize.Parcelize @@ -92,5 +96,34 @@ interface IdentityVerificationSheet { identityVerificationCallback: IdentityVerificationCallback ): IdentityVerificationSheet = StripeIdentityVerificationSheet(from, configuration, identityVerificationCallback) + + /** + * Creates a [IdentityVerificationSheet] instance in a [Composable]. Which would be + * recreated if [configuration] or [identityVerificationCallback] changed. + * + * This API uses Compose specific API [rememberLauncherForActivityResult] to register a + * [ActivityResultLauncher] into current activity, it should be called as part of Compose + * initialization path. + * The [IdentityVerificationSheet] created is remembered across recompositions. + * Recomposition will always return the value produced by composition. + */ + @Composable + fun rememberIdentityVerificationSheet( + configuration: Configuration, + identityVerificationCallback: IdentityVerificationCallback + ): IdentityVerificationSheet { + val context = LocalContext.current + val activityResultLauncher = rememberLauncherForActivityResult( + IdentityVerificationSheetContract(), + identityVerificationCallback::onVerificationFlowResult + ) + return remember(configuration) { + StripeIdentityVerificationSheet( + activityResultLauncher, + context, + configuration + ) + } + } } } diff --git a/identity/src/main/java/com/stripe/android/identity/StripeIdentityVerificationSheet.kt b/identity/src/main/java/com/stripe/android/identity/StripeIdentityVerificationSheet.kt index 606cf6e8729..226e86cc4e4 100644 --- a/identity/src/main/java/com/stripe/android/identity/StripeIdentityVerificationSheet.kt +++ b/identity/src/main/java/com/stripe/android/identity/StripeIdentityVerificationSheet.kt @@ -2,7 +2,6 @@ package com.stripe.android.identity import android.content.Context import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultCaller import androidx.activity.result.ActivityResultLauncher import androidx.fragment.app.Fragment import com.stripe.android.core.injection.Injectable @@ -12,11 +11,10 @@ import com.stripe.android.core.injection.WeakMapInjectorRegistry import com.stripe.android.identity.injection.DaggerIdentityVerificationSheetComponent import com.stripe.android.identity.injection.IdentityVerificationSheetComponent -internal class StripeIdentityVerificationSheet private constructor( - activityResultCaller: ActivityResultCaller, +internal class StripeIdentityVerificationSheet internal constructor( + private val activityResultLauncher: ActivityResultLauncher, context: Context, - private val configuration: IdentityVerificationSheet.Configuration, - identityVerificationCallback: IdentityVerificationSheet.IdentityVerificationCallback + private val configuration: IdentityVerificationSheet.Configuration ) : IdentityVerificationSheet, Injector { constructor( @@ -24,10 +22,12 @@ internal class StripeIdentityVerificationSheet private constructor( configuration: IdentityVerificationSheet.Configuration, identityVerificationCallback: IdentityVerificationSheet.IdentityVerificationCallback ) : this( - from as ActivityResultCaller, + from.registerForActivityResult( + IdentityVerificationSheetContract(), + identityVerificationCallback::onVerificationFlowResult + ), from, - configuration, - identityVerificationCallback + configuration ) constructor( @@ -35,10 +35,12 @@ internal class StripeIdentityVerificationSheet private constructor( configuration: IdentityVerificationSheet.Configuration, identityVerificationCallback: IdentityVerificationSheet.IdentityVerificationCallback ) : this( - from as ActivityResultCaller, + from.registerForActivityResult( + IdentityVerificationSheetContract(), + identityVerificationCallback::onVerificationFlowResult + ), from.requireContext(), - configuration, - identityVerificationCallback + configuration ) @InjectorKey @@ -54,12 +56,6 @@ internal class StripeIdentityVerificationSheet private constructor( .context(context) .build() - private val activityResultLauncher: ActivityResultLauncher = - activityResultCaller.registerForActivityResult( - IdentityVerificationSheetContract(), - identityVerificationCallback::onVerificationFlowResult - ) - override fun present( verificationSessionId: String, ephemeralKeySecret: String