Add WebAuthCallbackActivity to handle hCaptcha callbacks (#705)

This commit is contained in:
Brian Yencho 2024-01-22 09:08:28 -06:00 committed by Álison Fernandes
parent 49ff8a761d
commit e3547f4e13
7 changed files with 172 additions and 71 deletions

View File

@ -237,6 +237,7 @@ koverReport {
// OS-level components // OS-level components
"com.x8bit.bitwarden.BitwardenApplication", "com.x8bit.bitwarden.BitwardenApplication",
"com.x8bit.bitwarden.MainActivity*", "com.x8bit.bitwarden.MainActivity*",
"com.x8bit.bitwarden.WebAuthCallbackActivity*",
"com.x8bit.bitwarden.data.autofill.BitwardenAutofillService*", "com.x8bit.bitwarden.data.autofill.BitwardenAutofillService*",
"com.x8bit.bitwarden.data.push.BitwardenFirebaseMessagingService*", "com.x8bit.bitwarden.data.push.BitwardenFirebaseMessagingService*",
// Empty Composables // Empty Composables

View File

@ -34,6 +34,23 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="text/*" />
</intent-filter>
</activity>
<activity
android:name=".WebAuthCallbackActivity"
android:exported="true"
android:launchMode="singleTop"
android:noHistory="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@ -44,15 +61,6 @@
android:host="captcha-callback" android:host="captcha-callback"
android:scheme="bitwarden" /> android:scheme="bitwarden" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="text/*" />
</intent-filter>
</activity> </activity>
<provider <provider

View File

@ -5,7 +5,6 @@ import android.os.Parcelable
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
@ -50,39 +49,34 @@ class MainViewModel @Inject constructor(
} }
private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) { private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) {
val shareData = intentManager.getShareDataFromIntent(action.intent) handleIntent(
when { intent = action.intent,
shareData != null -> { isFirstIntent = true,
authRepository.specialCircumstance = )
UserState.SpecialCircumstance.ShareNewSend(
data = shareData,
shouldFinishWhenComplete = true,
)
}
}
} }
private fun handleNewIntentReceived(action: MainAction.ReceiveNewIntent) { private fun handleNewIntentReceived(action: MainAction.ReceiveNewIntent) {
val captchaCallbackTokenResult = action.intent.getCaptchaCallbackTokenResult() handleIntent(
val shareData = intentManager.getShareDataFromIntent(action.intent) intent = action.intent,
when { isFirstIntent = false,
captchaCallbackTokenResult != null -> { )
authRepository.setCaptchaCallbackTokenResult( }
tokenResult = captchaCallbackTokenResult,
)
}
private fun handleIntent(
intent: Intent,
isFirstIntent: Boolean,
) {
val shareData = intentManager.getShareDataFromIntent(intent)
when {
shareData != null -> { shareData != null -> {
authRepository.specialCircumstance = authRepository.specialCircumstance =
UserState.SpecialCircumstance.ShareNewSend( UserState.SpecialCircumstance.ShareNewSend(
data = shareData, data = shareData,
// Allow users back into the already-running app when completing the // Allow users back into the already-running app when completing the
// Send task. // Send task when this is not the first intent.
shouldFinishWhenComplete = false, shouldFinishWhenComplete = isFirstIntent,
) )
} }
else -> Unit
} }
} }
} }

View File

@ -0,0 +1,32 @@
package com.x8bit.bitwarden
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import dagger.hilt.android.AndroidEntryPoint
/**
* An activity to receive callbacks from Custom Chrome tabs or other web-auth related flows such
* the current state of the task holding the [MainActivity] can remain undisturbed.
*/
@AndroidEntryPoint
class WebAuthCallbackActivity : AppCompatActivity() {
private val webAuthCallbackViewModel: WebAuthCallbackViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
webAuthCallbackViewModel.trySendAction(
WebAuthCallbackAction.IntentReceive(intent = intent),
)
val intent = Intent(this, MainActivity::class.java)
.apply {
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
}
startActivity(intent)
finishAffinity()
}
}

View File

@ -0,0 +1,45 @@
package com.x8bit.bitwarden
import android.content.Intent
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* A view model that handles logic for the [WebAuthCallbackActivity].
*/
@HiltViewModel
class WebAuthCallbackViewModel @Inject constructor(
private val authRepository: AuthRepository,
) : BaseViewModel<Unit, Unit, WebAuthCallbackAction>(Unit) {
override fun handleAction(action: WebAuthCallbackAction) {
when (action) {
is WebAuthCallbackAction.IntentReceive -> handleIntentReceived(action)
}
}
private fun handleIntentReceived(action: WebAuthCallbackAction.IntentReceive) {
val captchaCallbackTokenResult = action.intent.getCaptchaCallbackTokenResult()
when {
captchaCallbackTokenResult != null -> {
authRepository.setCaptchaCallbackTokenResult(
tokenResult = captchaCallbackTokenResult,
)
}
else -> Unit
}
}
}
/**
* Actions for the [WebAuthCallbackViewModel].
*/
sealed class WebAuthCallbackAction {
/**
* Receive Intent by the application.
*/
data class IntentReceive(val intent: Intent) : WebAuthCallbackAction()
}

View File

@ -3,7 +3,6 @@ package com.x8bit.bitwarden
import android.content.Intent import android.content.Intent
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment
@ -13,14 +12,10 @@ import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class MainViewModelTest : BaseViewModelTest() { class MainViewModelTest : BaseViewModelTest() {
@ -32,7 +27,6 @@ class MainViewModelTest : BaseViewModelTest() {
every { activeUserId } returns USER_ID every { activeUserId } returns USER_ID
every { specialCircumstance } returns null every { specialCircumstance } returns null
every { specialCircumstance = any() } just runs every { specialCircumstance = any() } just runs
every { setCaptchaCallbackTokenResult(any()) } just runs
} }
private val settingsRepository = mockk<SettingsRepository> { private val settingsRepository = mockk<SettingsRepository> {
every { appTheme } returns AppTheme.DEFAULT every { appTheme } returns AppTheme.DEFAULT
@ -42,16 +36,6 @@ class MainViewModelTest : BaseViewModelTest() {
every { getShareDataFromIntent(any()) } returns null every { getShareDataFromIntent(any()) } returns null
} }
@BeforeEach
fun setUp() {
mockkStatic(Intent::getCaptchaCallbackTokenResult)
}
@AfterEach
fun tearDown() {
unmockkStatic(Intent::getCaptchaCallbackTokenResult)
}
@Test @Test
fun `on AppThemeChanged should update state`() { fun `on AppThemeChanged should update state`() {
val viewModel = createViewModel() val viewModel = createViewModel()
@ -102,29 +86,6 @@ class MainViewModelTest : BaseViewModelTest() {
} }
} }
@Test
fun `on ReceiveNewIntent with captcha host should call setCaptchaCallbackToken`() {
val viewModel = createViewModel()
val mockIntent = mockk<Intent>()
every {
mockIntent.getCaptchaCallbackTokenResult()
} returns CaptchaCallbackTokenResult.Success(
token = "mockk_token",
)
viewModel.trySendAction(
MainAction.ReceiveNewIntent(
intent = mockIntent,
),
)
verify {
authRepository.setCaptchaCallbackTokenResult(
tokenResult = CaptchaCallbackTokenResult.Success(
token = "mockk_token",
),
)
}
}
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `on ReceiveNewIntent with share data should set the special circumstance to ShareNewSend`() { fun `on ReceiveNewIntent with share data should set the special circumstance to ShareNewSend`() {

View File

@ -0,0 +1,60 @@
package com.x8bit.bitwarden
import android.content.Intent
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class WebAuthCallbackViewModelTest : BaseViewModelTest() {
private val authRepository = mockk<AuthRepository> {
every { setCaptchaCallbackTokenResult(any()) } just runs
}
@BeforeEach
fun setUp() {
mockkStatic(Intent::getCaptchaCallbackTokenResult)
}
@AfterEach
fun tearDown() {
unmockkStatic(Intent::getCaptchaCallbackTokenResult)
}
@Test
fun `on ReceiveNewIntent with captcha host should call setCaptchaCallbackToken`() {
val viewModel = createViewModel()
val mockIntent = mockk<Intent>()
every {
mockIntent.getCaptchaCallbackTokenResult()
} returns CaptchaCallbackTokenResult.Success(
token = "mockk_token",
)
viewModel.trySendAction(
WebAuthCallbackAction.IntentReceive(
intent = mockIntent,
),
)
verify {
authRepository.setCaptchaCallbackTokenResult(
tokenResult = CaptchaCallbackTokenResult.Success(
token = "mockk_token",
),
)
}
}
private fun createViewModel() = WebAuthCallbackViewModel(
authRepository = authRepository,
)
}