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