BIT-764: Chrome Custom Tabs for hcpatcha verification (#105)

This commit is contained in:
Ramsey Smith 2023-10-11 09:37:03 -06:00 committed by Álison Fernandes
parent 5a2a2f93f3
commit 635cc120c9
10 changed files with 52 additions and 28 deletions

View File

@ -62,6 +62,11 @@ The following is a list of all third-party dependencies included as part of the
- Purpose: Supplementary Android Compose features. - Purpose: Supplementary Android Compose features.
- License: Apache 2.0 - License: Apache 2.0
- **AndroidX Browser**
- https://developer.android.com/jetpack/androidx/releases/browser
- Purpose: Displays webpages with the user's default browser.
- License: Apache 2.0
- **Core SplashScreen** - **Core SplashScreen**
- https://developer.android.com/jetpack/androidx/releases/core - https://developer.android.com/jetpack/androidx/releases/core
- Purpose: Backwards compatible SplashScreen API implementation. - Purpose: Backwards compatible SplashScreen API implementation.

View File

@ -76,6 +76,7 @@ android {
dependencies { dependencies {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.browser)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)

View File

@ -13,9 +13,9 @@ private const val CAPTCHA_HOST: String = "captcha-callback"
private const val CALLBACK_URI = "bitwarden://$CAPTCHA_HOST" private const val CALLBACK_URI = "bitwarden://$CAPTCHA_HOST"
/** /**
* Generates an [Intent] to display a CAPTCHA challenge for Bitwarden authentication. * Generates a [Uri] to display a CAPTCHA challenge for Bitwarden authentication.
*/ */
fun LoginResult.CaptchaRequired.generateIntentForCaptcha(): Intent { fun LoginResult.CaptchaRequired.generateUriForCaptcha(): Uri {
val json = buildJsonObject { val json = buildJsonObject {
put(key = "siteKey", value = captchaId) put(key = "siteKey", value = captchaId)
put(key = "locale", value = Locale.getDefault().toString()) put(key = "locale", value = Locale.getDefault().toString())
@ -32,7 +32,7 @@ fun LoginResult.CaptchaRequired.generateIntentForCaptcha(): Intent {
val parentParam = URLEncoder.encode(CALLBACK_URI, "UTF-8") val parentParam = URLEncoder.encode(CALLBACK_URI, "UTF-8")
val url = "https://vault.bitwarden.com/captcha-mobile-connector.html" + val url = "https://vault.bitwarden.com/captcha-mobile-connector.html" +
"?data=$base64Data&parent=$parentParam&v=1" "?data=$base64Data&parent=$parentParam&v=1"
return Intent(Intent.ACTION_VIEW, Uri.parse(url)) return Uri.parse(url)
} }
/** /**

View File

@ -49,7 +49,9 @@ fun LoginScreen(
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
when (event) { when (event) {
LoginEvent.NavigateBack -> onNavigateBack() LoginEvent.NavigateBack -> onNavigateBack()
is LoginEvent.NavigateToCaptcha -> intentHandler.startActivity(intent = event.intent) is LoginEvent.NavigateToCaptcha -> {
intentHandler.startCustomTabsActivity(uri = event.uri)
}
is LoginEvent.ShowToast -> { is LoginEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
} }

View File

@ -2,14 +2,14 @@
package com.x8bit.bitwarden.ui.auth.feature.login package com.x8bit.bitwarden.ui.auth.feature.login
import android.content.Intent import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.generateIntentForCaptcha import com.x8bit.bitwarden.data.auth.datasource.network.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -84,7 +84,7 @@ class LoginViewModel @Inject constructor(
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) } mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) }
sendEvent( sendEvent(
event = LoginEvent.NavigateToCaptcha( event = LoginEvent.NavigateToCaptcha(
intent = loginResult.generateIntentForCaptcha(), uri = loginResult.generateUriForCaptcha(),
), ),
) )
} }
@ -201,7 +201,7 @@ sealed class LoginEvent {
/** /**
* Navigates to the captcha verification screen. * Navigates to the captcha verification screen.
*/ */
data class NavigateToCaptcha(val intent: Intent) : LoginEvent() data class NavigateToCaptcha(val uri: Uri) : LoginEvent()
/** /**
* Shows a toast with the given [message]. * Shows a toast with the given [message].

View File

@ -2,6 +2,8 @@ package com.x8bit.bitwarden.ui.platform.base.util
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
/** /**
* A utility class for simplifying the handling of Android Intents within a given context. * A utility class for simplifying the handling of Android Intents within a given context.
@ -14,4 +16,14 @@ class IntentHandler(private val context: Context) {
fun startActivity(intent: Intent) { fun startActivity(intent: Intent) {
context.startActivity(intent) context.startActivity(intent)
} }
/**
* Start a Custom Tabs Activity using the provided [Uri].
*/
fun startCustomTabsActivity(uri: Uri) {
CustomTabsIntent
.Builder()
.build()
.launchUrl(context, uri)
}
} }

View File

@ -3,23 +3,25 @@ package com.x8bit.bitwarden.data.auth.datasource.network.util
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
class LoginResultExtensionsTest { class LoginResultExtensionsTest : BaseComposeTest() {
@Test @Test
fun `generateIntentForCaptcha should return valid Intent`() { fun `generateIntentForCaptcha should return valid Uri`() {
val captchaRequired = LoginResult.CaptchaRequired("testCaptchaId") val captchaRequired = LoginResult.CaptchaRequired("testCaptchaId")
val intent = captchaRequired.generateIntentForCaptcha() val actualUri = captchaRequired.generateUriForCaptcha()
val expectedUrl = "https://vault.bitwarden.com/captcha-mobile-connector.html" + val expectedUrl = "https://vault.bitwarden.com/captcha-mobile-connector.html" +
"?data=eyJzaXRlS2V5IjoidGVzdENhcHRjaGkxZGQiLCJsb2NhbGUiOiJlbl9VUyJ9" + "?data=eyJzaXRlS2V5IjoidGVzdENhcHRjaGFJZCIsImxvY2FsZSI6ImVuX1VTIiwiY2Fsb" +
"&parent=bitwarden%3A%2F%2Fcaptcha-callback&v=1" "GJhY2tVcmkiOiJiaXR3YXJkZW46Ly9jYXB0Y2hhLWNhbGxiYWNrIiwiY2FwdGNoYVJlcXVp" +
val expectedIntent = Intent(Intent.ACTION_VIEW, Uri.parse(expectedUrl)) "cmVkVGV4dCI6IkNhcHRjaGEgcmVxdWlyZWQifQ==&parent=bitwarden%3A%2F%2F" +
assertEquals(expectedIntent.action, intent.action) "captcha-callback&v=1"
assertEquals(expectedIntent.data, intent.data) val expectedUri = Uri.parse(expectedUrl)
assertEquals(expectedUri, actualUri)
} }
@Test @Test

View File

@ -1,6 +1,6 @@
package com.x8bit.bitwarden.ui.auth.feature.login package com.x8bit.bitwarden.ui.auth.feature.login
import android.content.Intent import android.net.Uri
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.filter import androidx.compose.ui.test.filter
import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.filterToOne
@ -200,13 +200,13 @@ class LoginScreenTest : BaseComposeTest() {
} }
@Test @Test
fun `NavigateToCaptcha should call intentHandler startActivity`() { fun `NavigateToCaptcha should call intentHandler startCustomTabsActivity`() {
val intentHandler = mockk<IntentHandler>(relaxed = true) { val intentHandler = mockk<IntentHandler>(relaxed = true) {
every { startActivity(any()) } returns Unit every { startCustomTabsActivity(any()) } returns Unit
} }
val mockIntent = mockk<Intent>() val mockUri = mockk<Uri>()
val viewModel = mockk<LoginViewModel>(relaxed = true) { val viewModel = mockk<LoginViewModel>(relaxed = true) {
every { eventFlow } returns flowOf(LoginEvent.NavigateToCaptcha(mockIntent)) every { eventFlow } returns flowOf(LoginEvent.NavigateToCaptcha(mockUri))
every { stateFlow } returns MutableStateFlow( every { stateFlow } returns MutableStateFlow(
LoginState( LoginState(
emailAddress = "", emailAddress = "",
@ -225,6 +225,6 @@ class LoginScreenTest : BaseComposeTest() {
viewModel = viewModel, viewModel = viewModel,
) )
} }
verify { intentHandler.startActivity(mockIntent) } verify { intentHandler.startCustomTabsActivity(mockUri) }
} }
} }

View File

@ -1,12 +1,12 @@
package com.x8bit.bitwarden.ui.auth.feature.login package com.x8bit.bitwarden.ui.auth.feature.login
import android.content.Intent import android.net.Uri
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test import app.cash.turbine.test
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.generateIntentForCaptcha import com.x8bit.bitwarden.data.auth.datasource.network.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -174,12 +174,12 @@ class LoginViewModelTest : BaseViewModelTest() {
@Test @Test
fun `LoginButtonClick login returns CaptchaRequired should emit NavigateToCaptcha`() = fun `LoginButtonClick login returns CaptchaRequired should emit NavigateToCaptcha`() =
runTest { runTest {
val mockkIntent = mockk<Intent>() val mockkUri = mockk<Uri>()
every { every {
LoginResult LoginResult
.CaptchaRequired(captchaId = "mock_captcha_id") .CaptchaRequired(captchaId = "mock_captcha_id")
.generateIntentForCaptcha() .generateUriForCaptcha()
} returns mockkIntent } returns mockkUri
val authRepository = mockk<AuthRepository> { val authRepository = mockk<AuthRepository> {
coEvery { login("test@gmail.com", "", captchaToken = null) } returns coEvery { login("test@gmail.com", "", captchaToken = null) } returns
LoginResult.CaptchaRequired(captchaId = "mock_captcha_id") LoginResult.CaptchaRequired(captchaId = "mock_captcha_id")
@ -192,7 +192,7 @@ class LoginViewModelTest : BaseViewModelTest() {
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LoginAction.LoginButtonClick) viewModel.actionChannel.trySend(LoginAction.LoginButtonClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
assertEquals(LoginEvent.NavigateToCaptcha(intent = mockkIntent), awaitItem()) assertEquals(LoginEvent.NavigateToCaptcha(uri = mockkUri), awaitItem())
} }
coVerify { coVerify {
authRepository.login(email = "test@gmail.com", password = "", captchaToken = null) authRepository.login(email = "test@gmail.com", password = "", captchaToken = null)

View File

@ -10,6 +10,7 @@ minSdk = "28"
accompanist = "0.30.1" accompanist = "0.30.1"
androidGradlePlugin = "8.1.0" androidGradlePlugin = "8.1.0"
androidxActivity = "1.7.2" androidxActivity = "1.7.2"
androidxBrowser = "1.6.0"
androidxComposeBom = "2023.09.02" androidxComposeBom = "2023.09.02"
# TODO: Once the Material3 color scheme changes are no longer in alpha, we should remove this # TODO: Once the Material3 color scheme changes are no longer in alpha, we should remove this
# individual dependency version and use the Compose BOM version (BIT-702). # individual dependency version and use the Compose BOM version (BIT-702).
@ -46,6 +47,7 @@ zxing = "3.5.2"
[libraries] [libraries]
# Format: <maintainer>-<artifact-name> # Format: <maintainer>-<artifact-name>
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
androidx-browser = {module = "androidx.browser:browser", version.ref = "androidxBrowser"}
androidx-compose-animation = { module = "androidx.compose.animation:animation" } androidx-compose-animation = { module = "androidx.compose.animation:animation" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidxComposeBom" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidxComposeBom" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidxComposeMaterial3" } androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidxComposeMaterial3" }