diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/LoginResultExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/LoginResultExtensions.kt new file mode 100644 index 0000000000..03df5bd2fb --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/util/LoginResultExtensions.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.util + +import android.content.Intent +import android.net.Uri +import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.util.Base64 +import java.util.Locale + +/** + * Generates an [Intent] to display a CAPTCHA challenge for Bitwarden authentication. + */ +fun LoginResult.CaptchaRequired.generateIntentForCaptcha(): Intent { + val json = buildJsonObject { + put(key = "siteKey", value = captchaId) + put(key = "locale", value = Locale.getDefault().toString()) + put(key = "callbackUri", value = "bitwarden://captcha-callback") + put(key = "captchaRequiredText", value = "Captcha required") + } + val base64Data = Base64 + .getEncoder() + .encodeToString( + json + .toString() + .toByteArray(Charsets.UTF_8), + ) + val parentParam = "bitwarden%3A%2F%2Fcaptcha-callback" + val url = "https://vault.bitwarden.com/captcha-mobile-connector.html" + + "?data=$base64Data&parent=$parentParam&v=1" + return Intent(Intent.ACTION_VIEW, Uri.parse(url)) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt index 5068deda81..c17eb047ab 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -21,6 +22,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField /** @@ -31,11 +33,13 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField fun LoginScreen( onNavigateToLanding: () -> Unit, viewModel: LoginViewModel = hiltViewModel(), + intentHandler: IntentHandler = IntentHandler(context = LocalContext.current), ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() EventsEffect(viewModel = viewModel) { event -> when (event) { LoginEvent.NavigateToLanding -> onNavigateToLanding() + is LoginEvent.NavigateToCaptcha -> intentHandler.startActivity(intent = event.intent) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt index d33c59b210..19f79e69b3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt @@ -1,9 +1,11 @@ package com.x8bit.bitwarden.ui.auth.feature.login +import android.content.Intent import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult +import com.x8bit.bitwarden.data.auth.datasource.network.util.generateIntentForCaptcha import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel @@ -60,8 +62,13 @@ class LoginViewModel @Inject constructor( LoginResult.Error -> Unit // No action required on success, root nav will navigate to logged in state LoginResult.Success -> Unit - // TODO: launch intent with captcha URL BIT-399 - is LoginResult.CaptchaRequired -> Unit + is LoginResult.CaptchaRequired -> { + sendEvent( + event = LoginEvent.NavigateToCaptcha( + intent = result.generateIntentForCaptcha(), + ), + ) + } } } } @@ -97,6 +104,11 @@ sealed class LoginEvent { * Navigates to the Landing screen. */ data object NavigateToLanding : LoginEvent() + + /** + * Navigates to the captcha verification screen. + */ + data class NavigateToCaptcha(val intent: Intent) : LoginEvent() } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/IntentHandler.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/IntentHandler.kt new file mode 100644 index 0000000000..5895bb2759 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/IntentHandler.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.ui.platform.base.util + +import android.content.Context +import android.content.Intent + +/** + * A utility class for simplifying the handling of Android Intents within a given context. + */ +class IntentHandler(private val context: Context) { + + /** + * Start an activity using the provided [Intent]. + */ + fun startActivity(intent: Intent) { + context.startActivity(intent) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/LoginResultExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/LoginResultExtensionsTest.kt new file mode 100644 index 0000000000..724a148af0 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/util/LoginResultExtensionsTest.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.util + +import android.content.Intent +import android.net.Uri +import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult +import org.junit.Assert.assertEquals +import org.junit.Test + +class LoginResultExtensionsTest { + + @Test + fun `generateIntentForCaptcha should return valid Intent`() { + val captchaRequired = LoginResult.CaptchaRequired("testCaptchaId") + val intent = captchaRequired.generateIntentForCaptcha() + val expectedUrl = "https://vault.bitwarden.com/captcha-mobile-connector.html" + + "?data=eyJzaXRlS2V5IjoidGVzdENhcHRjaGkxZGQiLCJsb2NhbGUiOiJlbl9VUyJ9" + + "&parent=bitwarden%3A%2F%2Fcaptcha-callback&v=1" + val expectedIntent = Intent(Intent.ACTION_VIEW, Uri.parse(expectedUrl)) + assertEquals(expectedIntent.action, intent.action) + assertEquals(expectedIntent.data, intent.data) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt index 655d123798..941c336101 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreenTest.kt @@ -1,9 +1,11 @@ package com.x8bit.bitwarden.ui.auth.feature.login +import android.content.Intent import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -85,4 +87,30 @@ class LoginScreenTest : BaseComposeTest() { } assertTrue(onNavigateToLandingCalled) } + + @Test + fun `NavigateToCaptcha should call intentHandler startActivity`() { + val intentHandler = mockk(relaxed = true) { + every { startActivity(any()) } returns Unit + } + val mockIntent = mockk() + val viewModel = mockk(relaxed = true) { + every { eventFlow } returns flowOf(LoginEvent.NavigateToCaptcha(mockIntent)) + every { stateFlow } returns MutableStateFlow( + LoginState( + emailAddress = "", + isLoginButtonEnabled = false, + passwordInput = "", + ), + ) + } + composeTestRule.setContent { + LoginScreen( + onNavigateToLanding = {}, + intentHandler = intentHandler, + viewModel = viewModel, + ) + } + verify { intentHandler.startActivity(mockIntent) } + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt index 5bd2beb10b..9ff2ef260f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt @@ -1,15 +1,22 @@ package com.x8bit.bitwarden.ui.auth.feature.login +import android.content.Intent import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult +import com.x8bit.bitwarden.data.auth.datasource.network.util.generateIntentForCaptcha import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test class LoginViewModelTest : BaseViewModelTest() { @@ -18,6 +25,16 @@ class LoginViewModelTest : BaseViewModelTest() { it["email_address"] = "test@gmail.com" } + @BeforeEach + fun setUp() { + mockkStatic(LOGIN_RESULT_PATH) + } + + @AfterEach + fun tearDown() { + unmockkStatic(LOGIN_RESULT_PATH) + } + @Test fun `initial state should be correct`() = runTest { val viewModel = LoginViewModel( @@ -87,6 +104,33 @@ class LoginViewModelTest : BaseViewModelTest() { } } + @Test + fun `LoginButtonClick login returns CaptchaRequired should emit NavigateToCaptcha`() = + runTest { + val mockkIntent = mockk() + every { + LoginResult + .CaptchaRequired(captchaId = "mock_captcha_id") + .generateIntentForCaptcha() + } returns mockkIntent + val authRepository = mockk { + coEvery { login("test@gmail.com", "") } returns + LoginResult.CaptchaRequired(captchaId = "mock_captcha_id") + } + val viewModel = LoginViewModel( + authRepository = authRepository, + savedStateHandle = savedStateHandle, + ) + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(LoginAction.LoginButtonClick) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + assertEquals(LoginEvent.NavigateToCaptcha(intent = mockkIntent), awaitItem()) + } + coVerify { + authRepository.login(email = "test@gmail.com", password = "") + } + } + @Test fun `SingleSignOnClick should do nothing`() = runTest { val viewModel = LoginViewModel( @@ -136,5 +180,8 @@ class LoginViewModelTest : BaseViewModelTest() { passwordInput = "", isLoginButtonEnabled = true, ) + + private const val LOGIN_RESULT_PATH = + "com.x8bit.bitwarden.data.auth.datasource.network.util.LoginResultExtensionsKt" } }