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"> + + + + + @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..b9042984 --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapScreen.kt @@ -0,0 +1,65 @@ +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 oneTapClient = remember { Identity.getSignInClient(context) } + val oneTapLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartIntentSenderForResult() + ) { + when (it.resultCode) { + Activity.RESULT_OK -> viewModel.oneTapAccepted(oneTapClient, it.data) + Activity.RESULT_CANCELED -> viewModel.oneTapRejected() + } + } + val isOneTapUiRejected by viewModel.isOneTapUiRejected.collectAsState() + + LaunchedEffect(isOneTapUiRejected) { + if (!isOneTapUiRejected) { + try { + val signInResult = oneTapClient.beginSignIn(viewModel.oneTapRequest).await() + oneTapLauncher.launch( + IntentSenderRequest.Builder(signInResult.pendingIntent.intentSender).build() + ) + } catch (e: Exception) { + // No Google Accounts found. Just continue presenting the signed-out UI. + } + } + } + + 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..4a2f640d --- /dev/null +++ b/app/src/main/java/com/skyyo/samples/features/oneTap/OneTapViewModel.kt @@ -0,0 +1,101 @@ +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.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 +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 + +// 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" + +@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 oneTapRequest = 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() + + fun oneTapAccepted(client: SignInClient, data: Intent?) = viewModelScope.launch(Dispatchers.IO) { + try { + val credential = client.getSignInCredentialFromIntent(data) + val idToken = credential.googleIdToken!! + val response = tryOrNull { oneTapCalls.authorise(OneTapAuthoriseUserRequest(idToken)) } + when { + response?.code() == CODE_200 -> { + 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) + ) + } + } + } + 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 oneTapRejected() { + events.trySend(OneTapEvent.ShowToast("one tap sign in 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..34c8a77a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,4 +2,5 @@ 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 +oneTapHost=https://abrupt-tabby-medusaceratops.glitch.me \ No newline at end of file