From 118b01a19cb11bdad0654e497f4c10776dcf16e2 Mon Sep 17 00:00:00 2001 From: Andrii Hamula Date: Tue, 20 Sep 2022 20:17:36 +0300 Subject: [PATCH 1/2] add one tap related screens+vm --- app/build.gradle | 11 +- app/src/main/AndroidManifest.xml | 5 + .../skyyo/samples/application/Constants.kt | 1 + .../skyyo/samples/application/Destination.kt | 3 + .../application/activity/PopulatedNavHost.kt | 6 + .../application/injection/NetworkModule.kt | 5 + .../samples/application/models/OneTapUser.kt | 13 ++ .../application/network/calls/OneTapCalls.kt | 21 +++ .../features/oneTap/CreateUserScreen.kt | 81 +++++++++++ .../features/oneTap/CreateUserViewModel.kt | 56 ++++++++ .../samples/features/oneTap/OneTapEvent.kt | 5 + .../samples/features/oneTap/OneTapScreen.kt | 83 +++++++++++ .../features/oneTap/OneTapViewModel.kt | 134 ++++++++++++++++++ .../samples/features/oneTap/UserEvent.kt | 5 + .../samples/features/oneTap/UserScreen.kt | 78 ++++++++++ .../samples/features/oneTap/UserViewModel.kt | 37 +++++ .../sampleContainer/SampleContainerScreen.kt | 3 + .../SampleContainerViewModel.kt | 4 + build.gradle | 1 + gradle.properties | 4 +- 20 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/skyyo/samples/application/models/OneTapUser.kt create mode 100644 app/src/main/java/com/skyyo/samples/application/network/calls/OneTapCalls.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/oneTap/CreateUserScreen.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/oneTap/CreateUserViewModel.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/oneTap/OneTapEvent.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/oneTap/OneTapScreen.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/oneTap/OneTapViewModel.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/oneTap/UserEvent.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/oneTap/UserScreen.kt create mode 100644 app/src/main/java/com/skyyo/samples/features/oneTap/UserViewModel.kt diff --git a/app/build.gradle b/app/build.gradle index a3e3b940..ab4e8392 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -27,6 +27,11 @@ android { arg("room.schemaLocation", "$projectDir/schemas") } } + resValue 'string', 'asset_statements', """ + [{ + "include": "${getProperty('oneTapHost')}/.well-known/assetlinks.json" + }] + """ } buildTypes { @@ -34,11 +39,13 @@ android { signingConfig signingConfigs.debug minifyEnabled true buildConfigField "String", "BASE_URL", "\"https://cataas.com/api/\"" + buildConfigField "String", "ONE_TAP_URL", "\"${getProperty('oneTapHost')}\"" proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } debug { minifyEnabled false buildConfigField "String", "BASE_URL", "\"https://cataas.com/api/\"" + buildConfigField "String", "ONE_TAP_URL", "\"${getProperty('oneTapHost')}\"" } } compileOptions.coreLibraryDesugaringEnabled = true @@ -72,7 +79,8 @@ ktlint { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.8") - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutine_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$coroutine_version" implementation "androidx.navigation:navigation-runtime-ktx:2.5.1" implementation "androidx.navigation:navigation-compose:2.5.1" @@ -161,4 +169,5 @@ dependencies { implementation "androidx.health:health-connect-client:1.0.0-alpha03" implementation "com.google.android.gms:play-services-wallet:19.1.0" implementation "com.google.android.gms:play-services-pay:16.0.3" + implementation 'com.google.android.gms:play-services-auth:20.3.0' } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fbf77697..0bb32e5b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,6 +24,11 @@ android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/full_backup_content" tools:targetApi="32"> + + + + + @GET("${BuildConfig.ONE_TAP_URL}/auth/oneTapSignUp") + suspend fun signUp(@Query("token") token: String): Response + + @PUT("${BuildConfig.ONE_TAP_URL}/auth/oneTapUpdateUser") + suspend fun updateUser(@Body user: OneTapUser): Response + + @DELETE("${BuildConfig.ONE_TAP_URL}/auth/oneTapDeleteUser") + suspend fun deleteUser(@Query("userId") userId: String) +} diff --git a/app/src/main/java/com/skyyo/samples/features/oneTap/CreateUserScreen.kt b/app/src/main/java/com/skyyo/samples/features/oneTap/CreateUserScreen.kt new file mode 100644 index 00000000..b3e994d2 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/oneTap/CreateUserScreen.kt @@ -0,0 +1,81 @@ +package com.skyyo.samples.features.oneTap + +import androidx.compose.foundation.layout.* +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +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 androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.flowWithLifecycle +import com.skyyo.samples.extensions.toast +import kotlinx.coroutines.flow.receiveAsFlow + +@Composable +fun CreateUserScreen(viewModel: CreateUserViewModel = hiltViewModel()) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val events = remember { + viewModel.events.receiveAsFlow().flowWithLifecycle(lifecycleOwner.lifecycle) + } + + LaunchedEffect(Unit) { + events.collect { event -> + when (event) { + is UserEvent.ShowToast -> context.toast(event.message) + } + } + } + + val user by viewModel.user.collectAsState() + Column( + modifier = Modifier.systemBarsPadding(), + verticalArrangement = remember { Arrangement.spacedBy(20.dp) } + ) { + LabeledTextField(label = "User name: ", text = user.name) + LabeledTextField(label = "User surname: ", text = user.surname) + LabeledTextField( + label = "User phone: ", + text = user.phone, + onTextChanged = viewModel::setPhone + ) + Button(onClick = viewModel::applyUpdate) { + Text(text = "apply") + } + } +} + +@Composable +private fun LabeledTextField(label: String, text: String, onTextChanged: (String) -> Unit = {}) { + val labelStyle = remember { + TextStyle( + fontSize = 12.sp, + color = Color.DarkGray, + fontWeight = FontWeight.W300 + ) + } + val textStyle = remember { + TextStyle( + fontSize = 16.sp, + color = Color.Black, + fontWeight = FontWeight.W600 + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = label, style = labelStyle) + TextField( + modifier = Modifier.padding(start = 10.dp), + value = text, + textStyle = textStyle, + onValueChange = onTextChanged + ) + } +} diff --git a/app/src/main/java/com/skyyo/samples/features/oneTap/CreateUserViewModel.kt b/app/src/main/java/com/skyyo/samples/features/oneTap/CreateUserViewModel.kt new file mode 100644 index 00000000..28c7f8c4 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/oneTap/CreateUserViewModel.kt @@ -0,0 +1,56 @@ +package com.skyyo.samples.features.oneTap + +import androidx.core.os.bundleOf +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.skyyo.samples.application.CODE_200 +import com.skyyo.samples.application.Destination +import com.skyyo.samples.application.models.OneTapUser +import com.skyyo.samples.application.network.calls.OneTapCalls +import com.skyyo.samples.extensions.navigateWithObject +import com.skyyo.samples.extensions.tryOrNull +import com.skyyo.samples.utils.NavigationDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import javax.inject.Inject + +const val CREATE_USER_KEY = "user" + +@HiltViewModel +class CreateUserViewModel @Inject constructor( + private val handle: SavedStateHandle, + private val oneTapCalls: OneTapCalls, + private val navigationDispatcher: NavigationDispatcher +) : ViewModel() { + val events = Channel(Channel.UNLIMITED) + val user = handle.getStateFlow(CREATE_USER_KEY, OneTapUser.empty) + + fun setPhone(phone: String) { + handle[CREATE_USER_KEY] = user.value.copy(phone = phone) + } + + fun applyUpdate() = viewModelScope.launch(Dispatchers.IO) { + val response = tryOrNull { oneTapCalls.updateUser(user.value) } + when { + response?.code() == CODE_200 -> { + navigationDispatcher.emit { + it.popBackStack() + it.navigateWithObject( + route = Destination.OneTapAuthorised.route, + arguments = bundleOf(USER_KEY to user.value) + ) + } + } + else -> { + val message = when (response) { + null -> "No internet" + else -> "Update failed with code: ${response.code()}" + } + events.send(UserEvent.ShowToast(message)) + } + } + } +} diff --git a/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapEvent.kt b/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapEvent.kt new file mode 100644 index 00000000..03da0d66 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapEvent.kt @@ -0,0 +1,5 @@ +package com.skyyo.samples.features.oneTap + +sealed class OneTapEvent { + class ShowToast(val message: String) : OneTapEvent() +} diff --git a/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapScreen.kt b/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapScreen.kt new file mode 100644 index 00000000..2a4d3986 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapScreen.kt @@ -0,0 +1,83 @@ +package com.skyyo.samples.features.oneTap + +import android.app.Activity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.flowWithLifecycle +import com.google.android.gms.auth.api.identity.Identity +import com.skyyo.samples.extensions.toast +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.tasks.await + +@Composable +fun OneTapScreen(viewModel: OneTapViewModel = hiltViewModel()) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val events = remember { + viewModel.events.receiveAsFlow().flowWithLifecycle(lifecycleOwner.lifecycle) + } + + LaunchedEffect(Unit) { + events.collect { event -> + when (event) { + is OneTapEvent.ShowToast -> context.toast(event.message) + } + } + } + + val signInClient = remember { Identity.getSignInClient(context) } + val signInLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartIntentSenderForResult() + ) { + when (it.resultCode) { + Activity.RESULT_OK -> viewModel.signInAccepted(signInClient, it.data) + Activity.RESULT_CANCELED -> viewModel.signInRejected() + } + } + val signUpLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartIntentSenderForResult() + ) { + when (it.resultCode) { + Activity.RESULT_OK -> viewModel.signUpAccepted(signInClient, it.data) + Activity.RESULT_CANCELED -> viewModel.signUpRejected() + } + } + val isOneTapUiRejected by viewModel.isOneTapUiRejected.collectAsState() + + LaunchedEffect(isOneTapUiRejected) { + if (!isOneTapUiRejected) { + try { + val signInResult = signInClient.beginSignIn(viewModel.signInRequest).await() + signInLauncher.launch( + IntentSenderRequest.Builder(signInResult.pendingIntent.intentSender).build() + ) + } catch (e: Exception) { + // No saved credentials found. Launch the One Tap sign-up flow, or + // do nothing and continue presenting the signed-out UI. + try { + val signUpResult = signInClient.beginSignIn(viewModel.signUpRequest).await() + signUpLauncher.launch( + IntentSenderRequest.Builder(signUpResult.pendingIntent.intentSender).build() + ) + } catch (e: Exception) { + // No Google Accounts found. Just continue presenting the signed-out UI. + } + // Log.d(TAG, e.localizedMessage) + } + } + } + + Column(Modifier.fillMaxSize().systemBarsPadding()) { + Text(text = "Unauthorised content") + } +} diff --git a/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapViewModel.kt b/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapViewModel.kt new file mode 100644 index 00000000..274394eb --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapViewModel.kt @@ -0,0 +1,134 @@ +package com.skyyo.samples.features.oneTap + +import android.content.Intent +import androidx.core.os.bundleOf +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.gms.auth.api.identity.BeginSignInRequest +import com.google.android.gms.auth.api.identity.SignInClient +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.CommonStatusCodes +import com.skyyo.samples.application.CODE_200 +import com.skyyo.samples.application.CODE_UNAUTHORISED +import com.skyyo.samples.application.Destination +import com.skyyo.samples.application.network.calls.OneTapCalls +import com.skyyo.samples.extensions.navigateWithObject +import com.skyyo.samples.extensions.tryOrNull +import com.skyyo.samples.utils.NavigationDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val WEB_CLIENT_ID = "991166295182-b0s899mj3ev0rbqmuugs77390jgidm5g.apps.googleusercontent.com" +private const val IS_ONE_TAP_UI_REJECTED_KEY = "isOneTapUiRejected" + +@HiltViewModel +class OneTapViewModel @Inject constructor( + private val handle: SavedStateHandle, + private val navigationDispatcher: NavigationDispatcher, + private val oneTapCalls: OneTapCalls +) : ViewModel() { + val events = Channel(Channel.UNLIMITED) + val isOneTapUiRejected = handle.getStateFlow(IS_ONE_TAP_UI_REJECTED_KEY, false) + + val signInRequest = BeginSignInRequest.builder() + .setGoogleIdTokenRequestOptions( + BeginSignInRequest.GoogleIdTokenRequestOptions.builder() + .setSupported(true) + .setServerClientId(WEB_CLIENT_ID) + .setFilterByAuthorizedAccounts(true) // Only show accounts previously used to sign in. + .build() + ) + .setAutoSelectEnabled(true) // Automatically sign in when exactly one credential is retrieved. + .build() + + val signUpRequest = BeginSignInRequest.builder() + .setGoogleIdTokenRequestOptions( + BeginSignInRequest.GoogleIdTokenRequestOptions.builder() + .setSupported(true) + .setServerClientId(WEB_CLIENT_ID) + .setFilterByAuthorizedAccounts(false) // Show all accounts on the device. + .build() + ) + .build() + + fun signInAccepted(client: SignInClient, data: Intent?) = viewModelScope.launch(Dispatchers.IO) { + try { + val credential = client.getSignInCredentialFromIntent(data) + val idToken = credential.googleIdToken!! + val response = tryOrNull { oneTapCalls.signIn(idToken) } + when { + response?.code() == CODE_200 -> { + val user = response.body() + navigationDispatcher.emit { + it.popBackStack() + it.navigateWithObject( + route = Destination.OneTapAuthorised.route, + arguments = bundleOf(USER_KEY to user) + ) + } + } + response?.code() == CODE_UNAUTHORISED -> signUpAccepted(client, data) + else -> { + val message = when (response) { + null -> "No internet" + else -> "Update failed with code: ${response.code()}" + } + events.send(OneTapEvent.ShowToast(message)) + } + } + } catch (e: ApiException) { + when (e.statusCode) { + CommonStatusCodes.CANCELED -> { + handle[IS_ONE_TAP_UI_REJECTED_KEY] = true + } + CommonStatusCodes.NETWORK_ERROR -> { + // One-tap encountered a network error, try again or just ignore. + } + else -> { + // Couldn't get credential from result. + } + } + } + } + + fun signInRejected() { + events.trySend(OneTapEvent.ShowToast("one tap sign in rejected")) + } + + fun signUpAccepted(client: SignInClient, data: Intent?) = viewModelScope.launch(Dispatchers.IO) { + try { + val credential = client.getSignInCredentialFromIntent(data) + val idToken = credential.googleIdToken!! + val user = oneTapCalls.signUp(idToken).body()!! + navigationDispatcher.emit { + it.popBackStack() + it.navigateWithObject( + route = Destination.OneTapSignUpFinish.route, + arguments = bundleOf(CREATE_USER_KEY to user) + ) + } + } catch (e: ApiException) { + when (e.statusCode) { + CommonStatusCodes.CANCELED -> { + handle[IS_ONE_TAP_UI_REJECTED_KEY] = true + } + CommonStatusCodes.NETWORK_ERROR -> { + // One-tap encountered a network error, try again or just ignore. + } + else -> { + // Couldn't get credential from result. + } + } + } catch (e: Exception) { + events.send(OneTapEvent.ShowToast("Oops, something went wrong")) + } + } + + fun signUpRejected() { + events.trySend(OneTapEvent.ShowToast("one tap sign up rejected")) + } +} diff --git a/app/src/main/java/com/skyyo/samples/features/oneTap/UserEvent.kt b/app/src/main/java/com/skyyo/samples/features/oneTap/UserEvent.kt new file mode 100644 index 00000000..aa6c2713 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/oneTap/UserEvent.kt @@ -0,0 +1,5 @@ +package com.skyyo.samples.features.oneTap + +sealed class UserEvent { + class ShowToast(val message: String) : UserEvent() +} diff --git a/app/src/main/java/com/skyyo/samples/features/oneTap/UserScreen.kt b/app/src/main/java/com/skyyo/samples/features/oneTap/UserScreen.kt new file mode 100644 index 00000000..26e31045 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/oneTap/UserScreen.kt @@ -0,0 +1,78 @@ +package com.skyyo.samples.features.oneTap + +import androidx.compose.foundation.layout.* +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +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 androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.flowWithLifecycle +import com.google.android.gms.auth.api.identity.Identity +import com.skyyo.samples.extensions.toast +import kotlinx.coroutines.flow.receiveAsFlow + +@Composable +fun UserScreen(viewModel: UserViewModel = hiltViewModel()) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val events = remember { + viewModel.events.receiveAsFlow().flowWithLifecycle(lifecycleOwner.lifecycle) + } + + LaunchedEffect(Unit) { + events.collect { event -> + when (event) { + is UserEvent.ShowToast -> context.toast(event.message) + } + } + } + + val user = viewModel.user + val client = remember { Identity.getSignInClient(context) } + Column( + modifier = Modifier.systemBarsPadding(), + verticalArrangement = remember { Arrangement.spacedBy(20.dp) } + ) { + LabeledText(label = "User name: ", text = user.name) + LabeledText(label = "User surname: ", text = user.surname) + LabeledText(label = "User phone: ", text = user.phone) + Button(onClick = { viewModel.signOut(client) }) { + Text(text = "sign out") + } + Button(onClick = { viewModel.signOut(client = client, deleteUser = true) }) { + Text(text = "sign out with backend cleaning up") + } + } +} + +@Composable +private fun LabeledText(label: String, text: String) { + val labelStyle = remember { + TextStyle( + fontSize = 12.sp, + color = Color.DarkGray, + fontWeight = FontWeight.W300 + ) + } + val textStyle = remember { + TextStyle( + fontSize = 16.sp, + color = Color.Black, + fontWeight = FontWeight.W600 + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = label, style = labelStyle) + Text(modifier = Modifier.padding(start = 10.dp), text = text, style = textStyle) + } +} diff --git a/app/src/main/java/com/skyyo/samples/features/oneTap/UserViewModel.kt b/app/src/main/java/com/skyyo/samples/features/oneTap/UserViewModel.kt new file mode 100644 index 00000000..eeecbf49 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/oneTap/UserViewModel.kt @@ -0,0 +1,37 @@ +package com.skyyo.samples.features.oneTap + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.gms.auth.api.identity.SignInClient +import com.skyyo.samples.application.models.OneTapUser +import com.skyyo.samples.application.network.calls.OneTapCalls +import com.skyyo.samples.utils.NavigationDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +const val USER_KEY = "user" + +@HiltViewModel +class UserViewModel @Inject constructor( + handle: SavedStateHandle, + private val navigationDispatcher: NavigationDispatcher, + private val oneTapCalls: OneTapCalls +) : ViewModel() { + val events = Channel(Channel.UNLIMITED) + val user: OneTapUser = handle[USER_KEY]!! + + fun signOut(client: SignInClient, deleteUser: Boolean = false) = viewModelScope.launch(Dispatchers.IO) { + if (deleteUser) oneTapCalls.deleteUser(user.id) + try { + client.signOut().await() + } catch (e: Exception) { + events.send(UserEvent.ShowToast("sign out failed")) + } + navigationDispatcher.emit { it.popBackStack() } + } +} diff --git a/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerScreen.kt b/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerScreen.kt index 355f2ff1..2abc4e0c 100644 --- a/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerScreen.kt +++ b/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerScreen.kt @@ -74,6 +74,9 @@ fun SampleContainerScreen(viewModel: SampleContainerViewModel = hiltViewModel()) Button(modifier = Modifier.fillMaxWidth(), onClick = viewModel::goGooglePay) { Text(text = "google pay") } + Button(modifier = Modifier.fillMaxWidth(), onClick = viewModel::goOneTap) { + Text(text = "one tap") + } } } diff --git a/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerViewModel.kt b/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerViewModel.kt index b8bfb049..5a88dd51 100644 --- a/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerViewModel.kt +++ b/app/src/main/java/com/skyyo/samples/features/sampleContainer/SampleContainerViewModel.kt @@ -223,4 +223,8 @@ class SampleContainerViewModel @Inject constructor( fun goImeAwareLazyColumn() = navigationDispatcher.emit { it.navigate(Destination.ImeAwareLazyColumn.route) } + + fun goOneTap() = navigationDispatcher.emit { + it.navigate(Destination.OneTap.route) + } } diff --git a/build.gradle b/build.gradle index 5a5f2f1b..a76ddd22 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ buildscript { ext { kotlin_version = '1.7.10' compose_version = '1.3.0-beta01' + coroutine_version = '1.6.4' lifecycle_version = '2.6.0-alpha01' moshi_version = '1.13.0' retrofit_version = '2.9.0' diff --git a/gradle.properties b/gradle.properties index 9cbc3f7e..2df37150 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,4 +2,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true android.enableJetifier=false android.enableResourceOptimizations=true -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +# The URL of the server +oneTapHost=https://abrupt-tabby-medusaceratops.glitch.me \ No newline at end of file From 161d698ca9cd74ec7d0dd399652cc5159de53c80 Mon Sep 17 00:00:00 2001 From: Andrii Hamula Date: Thu, 22 Sep 2022 19:22:59 +0300 Subject: [PATCH 2/2] onetap sample cleaning up --- .../skyyo/samples/application/Constants.kt | 1 - .../models/OneTapAuthoriseUserRequest.kt | 6 ++ .../samples/application/models/OneTapUser.kt | 10 ++- .../application/network/calls/OneTapCalls.kt | 8 +- .../samples/features/oneTap/OneTapScreen.kt | 32 ++------ .../features/oneTap/OneTapViewModel.kt | 79 ++++++------------- gradle.properties | 1 - 7 files changed, 47 insertions(+), 90 deletions(-) create mode 100644 app/src/main/java/com/skyyo/samples/application/models/OneTapAuthoriseUserRequest.kt diff --git a/app/src/main/java/com/skyyo/samples/application/Constants.kt b/app/src/main/java/com/skyyo/samples/application/Constants.kt index 08457ec6..9011aaf2 100644 --- a/app/src/main/java/com/skyyo/samples/application/Constants.kt +++ b/app/src/main/java/com/skyyo/samples/application/Constants.kt @@ -1,5 +1,4 @@ package com.skyyo.samples.application const val CODE_200 = 200 -const val CODE_UNAUTHORISED = 401 const val STATE_FLOW_SUB_TIME = 5000L diff --git a/app/src/main/java/com/skyyo/samples/application/models/OneTapAuthoriseUserRequest.kt b/app/src/main/java/com/skyyo/samples/application/models/OneTapAuthoriseUserRequest.kt new file mode 100644 index 00000000..5b70264d --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/application/models/OneTapAuthoriseUserRequest.kt @@ -0,0 +1,6 @@ +package com.skyyo.samples.application.models + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +class OneTapAuthoriseUserRequest(val token: String) diff --git a/app/src/main/java/com/skyyo/samples/application/models/OneTapUser.kt b/app/src/main/java/com/skyyo/samples/application/models/OneTapUser.kt index aa03d620..9ced7164 100644 --- a/app/src/main/java/com/skyyo/samples/application/models/OneTapUser.kt +++ b/app/src/main/java/com/skyyo/samples/application/models/OneTapUser.kt @@ -6,8 +6,14 @@ import kotlinx.parcelize.Parcelize @Parcelize @JsonClass(generateAdapter = true) -data class OneTapUser(val id: String, val name: String, val surname: String, val phone: String = "") : Parcelable { +data class OneTapUser( + val id: String, + val name: String, + val surname: String, + val isCompleted: Boolean, + val phone: String = "" +) : Parcelable { companion object { - val empty = OneTapUser(id = "", name = "", surname = "") + val empty = OneTapUser(id = "", name = "", surname = "", isCompleted = false) } } diff --git a/app/src/main/java/com/skyyo/samples/application/network/calls/OneTapCalls.kt b/app/src/main/java/com/skyyo/samples/application/network/calls/OneTapCalls.kt index 83f0a3fe..afe9f634 100644 --- a/app/src/main/java/com/skyyo/samples/application/network/calls/OneTapCalls.kt +++ b/app/src/main/java/com/skyyo/samples/application/network/calls/OneTapCalls.kt @@ -1,17 +1,15 @@ package com.skyyo.samples.application.network.calls import com.skyyo.samples.BuildConfig +import com.skyyo.samples.application.models.OneTapAuthoriseUserRequest import com.skyyo.samples.application.models.OneTapUser import retrofit2.Response import retrofit2.http.* interface OneTapCalls { - @GET("${BuildConfig.ONE_TAP_URL}/auth/oneTapSignIn") - suspend fun signIn(@Query("token") token: String): Response - - @GET("${BuildConfig.ONE_TAP_URL}/auth/oneTapSignUp") - suspend fun signUp(@Query("token") token: String): Response + @POST("${BuildConfig.ONE_TAP_URL}/auth/oneTapAuthorise") + suspend fun authorise(@Body body: OneTapAuthoriseUserRequest): Response @PUT("${BuildConfig.ONE_TAP_URL}/auth/oneTapUpdateUser") suspend fun updateUser(@Body user: OneTapUser): Response diff --git a/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapScreen.kt b/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapScreen.kt index 2a4d3986..b9042984 100644 --- a/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapScreen.kt +++ b/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapScreen.kt @@ -35,21 +35,13 @@ fun OneTapScreen(viewModel: OneTapViewModel = hiltViewModel()) { } } - val signInClient = remember { Identity.getSignInClient(context) } - val signInLauncher = rememberLauncherForActivityResult( + val oneTapClient = remember { Identity.getSignInClient(context) } + val oneTapLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartIntentSenderForResult() ) { when (it.resultCode) { - Activity.RESULT_OK -> viewModel.signInAccepted(signInClient, it.data) - Activity.RESULT_CANCELED -> viewModel.signInRejected() - } - } - val signUpLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartIntentSenderForResult() - ) { - when (it.resultCode) { - Activity.RESULT_OK -> viewModel.signUpAccepted(signInClient, it.data) - Activity.RESULT_CANCELED -> viewModel.signUpRejected() + Activity.RESULT_OK -> viewModel.oneTapAccepted(oneTapClient, it.data) + Activity.RESULT_CANCELED -> viewModel.oneTapRejected() } } val isOneTapUiRejected by viewModel.isOneTapUiRejected.collectAsState() @@ -57,22 +49,12 @@ fun OneTapScreen(viewModel: OneTapViewModel = hiltViewModel()) { LaunchedEffect(isOneTapUiRejected) { if (!isOneTapUiRejected) { try { - val signInResult = signInClient.beginSignIn(viewModel.signInRequest).await() - signInLauncher.launch( + val signInResult = oneTapClient.beginSignIn(viewModel.oneTapRequest).await() + oneTapLauncher.launch( IntentSenderRequest.Builder(signInResult.pendingIntent.intentSender).build() ) } catch (e: Exception) { - // No saved credentials found. Launch the One Tap sign-up flow, or - // do nothing and continue presenting the signed-out UI. - try { - val signUpResult = signInClient.beginSignIn(viewModel.signUpRequest).await() - signUpLauncher.launch( - IntentSenderRequest.Builder(signUpResult.pendingIntent.intentSender).build() - ) - } catch (e: Exception) { - // No Google Accounts found. Just continue presenting the signed-out UI. - } - // Log.d(TAG, e.localizedMessage) + // No Google Accounts found. Just continue presenting the signed-out UI. } } } diff --git a/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapViewModel.kt b/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapViewModel.kt index 274394eb..4a2f640d 100644 --- a/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapViewModel.kt +++ b/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapViewModel.kt @@ -10,8 +10,8 @@ import com.google.android.gms.auth.api.identity.SignInClient import com.google.android.gms.common.api.ApiException import com.google.android.gms.common.api.CommonStatusCodes import com.skyyo.samples.application.CODE_200 -import com.skyyo.samples.application.CODE_UNAUTHORISED import com.skyyo.samples.application.Destination +import com.skyyo.samples.application.models.OneTapAuthoriseUserRequest import com.skyyo.samples.application.network.calls.OneTapCalls import com.skyyo.samples.extensions.navigateWithObject import com.skyyo.samples.extensions.tryOrNull @@ -22,6 +22,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import javax.inject.Inject +// sample web app with one tap support can be found here - https://easy-foul-ketchup.glitch.me private const val WEB_CLIENT_ID = "991166295182-b0s899mj3ev0rbqmuugs77390jgidm5g.apps.googleusercontent.com" private const val IS_ONE_TAP_UI_REJECTED_KEY = "isOneTapUiRejected" @@ -34,7 +35,7 @@ class OneTapViewModel @Inject constructor( val events = Channel(Channel.UNLIMITED) val isOneTapUiRejected = handle.getStateFlow(IS_ONE_TAP_UI_REJECTED_KEY, false) - val signInRequest = BeginSignInRequest.builder() + val oneTapRequest = BeginSignInRequest.builder() .setGoogleIdTokenRequestOptions( BeginSignInRequest.GoogleIdTokenRequestOptions.builder() .setSupported(true) @@ -45,33 +46,32 @@ class OneTapViewModel @Inject constructor( .setAutoSelectEnabled(true) // Automatically sign in when exactly one credential is retrieved. .build() - val signUpRequest = BeginSignInRequest.builder() - .setGoogleIdTokenRequestOptions( - BeginSignInRequest.GoogleIdTokenRequestOptions.builder() - .setSupported(true) - .setServerClientId(WEB_CLIENT_ID) - .setFilterByAuthorizedAccounts(false) // Show all accounts on the device. - .build() - ) - .build() - - fun signInAccepted(client: SignInClient, data: Intent?) = viewModelScope.launch(Dispatchers.IO) { + fun oneTapAccepted(client: SignInClient, data: Intent?) = viewModelScope.launch(Dispatchers.IO) { try { val credential = client.getSignInCredentialFromIntent(data) val idToken = credential.googleIdToken!! - val response = tryOrNull { oneTapCalls.signIn(idToken) } + val response = tryOrNull { oneTapCalls.authorise(OneTapAuthoriseUserRequest(idToken)) } when { response?.code() == CODE_200 -> { - val user = response.body() - navigationDispatcher.emit { - it.popBackStack() - it.navigateWithObject( - route = Destination.OneTapAuthorised.route, - arguments = bundleOf(USER_KEY to user) - ) + val user = response.body()!! + if (user.isCompleted) { + navigationDispatcher.emit { + it.popBackStack() + it.navigateWithObject( + route = Destination.OneTapAuthorised.route, + arguments = bundleOf(USER_KEY to user) + ) + } + } else { + navigationDispatcher.emit { + it.popBackStack() + it.navigateWithObject( + route = Destination.OneTapSignUpFinish.route, + arguments = bundleOf(CREATE_USER_KEY to user) + ) + } } } - response?.code() == CODE_UNAUTHORISED -> signUpAccepted(client, data) else -> { val message = when (response) { null -> "No internet" @@ -95,40 +95,7 @@ class OneTapViewModel @Inject constructor( } } - fun signInRejected() { + fun oneTapRejected() { events.trySend(OneTapEvent.ShowToast("one tap sign in rejected")) } - - fun signUpAccepted(client: SignInClient, data: Intent?) = viewModelScope.launch(Dispatchers.IO) { - try { - val credential = client.getSignInCredentialFromIntent(data) - val idToken = credential.googleIdToken!! - val user = oneTapCalls.signUp(idToken).body()!! - navigationDispatcher.emit { - it.popBackStack() - it.navigateWithObject( - route = Destination.OneTapSignUpFinish.route, - arguments = bundleOf(CREATE_USER_KEY to user) - ) - } - } catch (e: ApiException) { - when (e.statusCode) { - CommonStatusCodes.CANCELED -> { - handle[IS_ONE_TAP_UI_REJECTED_KEY] = true - } - CommonStatusCodes.NETWORK_ERROR -> { - // One-tap encountered a network error, try again or just ignore. - } - else -> { - // Couldn't get credential from result. - } - } - } catch (e: Exception) { - events.send(OneTapEvent.ShowToast("Oops, something went wrong")) - } - } - - fun signUpRejected() { - events.trySend(OneTapEvent.ShowToast("one tap sign up rejected")) - } } diff --git a/gradle.properties b/gradle.properties index 2df37150..34c8a77a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,5 +3,4 @@ android.useAndroidX=true android.enableJetifier=false android.enableResourceOptimizations=true kotlin.code.style=official -# The URL of the server oneTapHost=https://abrupt-tabby-medusaceratops.glitch.me \ No newline at end of file