Oidc: Fallback to external browser instead of using Webview (#4808)

* Oidc: Fallback to external browser instead of using Webview

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty 2025-06-04 16:25:18 +02:00 committed by GitHub
parent 58d503f661
commit 36c7c7ab9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 14 additions and 709 deletions

View file

@ -36,6 +36,7 @@ import io.element.android.features.login.impl.screens.createaccount.CreateAccoun
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
@ -45,7 +46,6 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.oidc.api.OidcEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ -57,7 +57,6 @@ class LoginFlowNode @AssistedInject constructor(
private val accountProviderDataSource: AccountProviderDataSource,
private val defaultLoginUserStory: DefaultLoginUserStory,
private val oidcActionFlow: OidcActionFlow,
private val oidcEntryPoint: OidcEntryPoint,
) : BaseFlowNode<LoginFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.OnBoarding,
@ -74,15 +73,15 @@ class LoginFlowNode @AssistedInject constructor(
private var activity: Activity? = null
private var darkTheme: Boolean = false
private var customChromeTabStarted = false
private var externalAppStarted = false
override fun onBuilt() {
super.onBuilt()
defaultLoginUserStory.setLoginFlowIsDone(false)
lifecycle.subscribe(
onResume = {
if (customChromeTabStarted) {
customChromeTabStarted = false
if (externalAppStarted) {
externalAppStarted = false
// Workaround to detect that the Custom Chrome Tab has been closed
// If there is no coming OidcAction (that would end this Node),
// consider that the user has cancelled the login
@ -122,9 +121,6 @@ class LoginFlowNode @AssistedInject constructor(
@Parcelize
data class CreateAccount(val url: String) : NavTarget
@Parcelize
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -249,9 +245,6 @@ class LoginFlowNode @AssistedInject constructor(
NavTarget.LoginPassword -> {
createNode<LoginPasswordNode>(buildContext)
}
is NavTarget.OidcView -> {
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.oidcDetails.url)
}
is NavTarget.CreateAccount -> {
val inputs = CreateAccountNode.Inputs(
url = navTarget.url,
@ -262,15 +255,9 @@ class LoginFlowNode @AssistedInject constructor(
}
private fun navigateToMas(oidcDetails: OidcDetails) {
if (oidcEntryPoint.canUseCustomTab()) {
// In this case open a Chrome Custom tab
activity?.let {
customChromeTabStarted = true
oidcEntryPoint.openUrlInCustomTab(it, darkTheme, oidcDetails.url)
}
} else {
// Fallback to WebView mode
backstack.push(NavTarget.OidcView(oidcDetails))
activity?.let {
externalAppStarted = true
it.openUrlInChromeCustomTab(null, darkTheme, oidcDetails.url)
}
}

View file

@ -26,6 +26,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.securebackup.impl.reset.password.ResetIdentityPasswordNode
import io.element.android.features.securebackup.impl.reset.root.ResetIdentityRootNode
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
@ -37,7 +38,6 @@ import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.encryption.IdentityOidcResetHandle
import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
import io.element.android.libraries.oidc.api.OidcEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
@ -51,7 +51,6 @@ class ResetIdentityFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val resetIdentityFlowManager: ResetIdentityFlowManager,
private val coroutineScope: CoroutineScope,
private val oidcEntryPoint: OidcEntryPoint,
) : BaseFlowNode<ResetIdentityFlowNode.NavTarget>(
backstack = BackStack(initialElement = NavTarget.Root, savedStateMap = buildContext.savedStateMap),
buildContext = buildContext,
@ -67,12 +66,10 @@ class ResetIdentityFlowNode @AssistedInject constructor(
@Parcelize
data object ResetPassword : NavTarget
@Parcelize
data class ResetOidc(val url: String) : NavTarget
}
private lateinit var activity: Activity
private var darkTheme: Boolean = false
private var resetJob: Job? = null
override fun onBuilt() {
@ -80,7 +77,7 @@ class ResetIdentityFlowNode @AssistedInject constructor(
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
// If the custom tab was opened, we need to cancel the reset job
// If the custom tab / Web browser was opened, we need to cancel the reset job
// when we come back to the node if the reset wasn't successful
coroutineScope.launch {
cancelResetJob()
@ -115,9 +112,6 @@ class ResetIdentityFlowNode @AssistedInject constructor(
listOf(ResetIdentityPasswordNode.Inputs(handle))
)
}
is NavTarget.ResetOidc -> {
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.url)
}
}
}
@ -135,11 +129,7 @@ class ResetIdentityFlowNode @AssistedInject constructor(
Timber.d("No reset handle return, the reset is done.")
}
is IdentityOidcResetHandle -> {
if (oidcEntryPoint.canUseCustomTab()) {
activity.openUrlInChromeCustomTab(null, false, handle.url)
} else {
backstack.push(NavTarget.ResetOidc(handle.url))
}
activity.openUrlInChromeCustomTab(null, darkTheme, handle.url)
resetJob = launch { handle.resetOidc() }
}
is IdentityPasswordResetHandle -> backstack.push(NavTarget.ResetPassword)
@ -162,7 +152,7 @@ class ResetIdentityFlowNode @AssistedInject constructor(
if (!this::activity.isInitialized) {
activity = requireNotNull(LocalActivity.current)
}
darkTheme = !ElementTheme.isLightTheme
val startResetState by resetIdentityFlowManager.currentHandleFlow.collectAsState()
if (startResetState.isLoading()) {
ProgressDialog(

View file

@ -1,18 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.api
import android.app.Activity
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
interface OidcEntryPoint {
fun canUseCustomTab(): Boolean
fun openUrlInCustomTab(activity: Activity, darkTheme: Boolean, url: String)
fun createFallbackWebViewNode(parentNode: Node, buildContext: BuildContext, url: String): Node
}

View file

@ -1,26 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.impl
import android.content.Context
import androidx.browser.customtabs.CustomTabsClient
import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject
class CustomTabAvailabilityChecker @Inject constructor(
@ApplicationContext private val context: Context,
) {
/**
* Return true if the device supports Custom tab, i.e. there is an third party app with
* CustomTab support (ex: Chrome, Firefox, etc.).
*/
fun supportCustomTab(): Boolean {
val packageName = CustomTabsClient.getPackageName(context, null)
return packageName != null
}
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.impl.customtab
package io.element.android.libraries.oidc.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope

View file

@ -1,40 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.impl
import android.app.Activity
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.oidc.api.OidcEntryPoint
import io.element.android.libraries.oidc.impl.webview.OidcNode
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultOidcEntryPoint @Inject constructor(
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
) : OidcEntryPoint {
override fun canUseCustomTab(): Boolean {
return customTabAvailabilityChecker.supportCustomTab()
}
override fun openUrlInCustomTab(activity: Activity, darkTheme: Boolean, url: String) {
assert(canUseCustomTab()) { "Custom tab is not supported in this device." }
activity.openUrlInChromeCustomTab(null, darkTheme, url)
}
override fun createFallbackWebViewNode(parentNode: Node, buildContext: BuildContext, url: String): Node {
assert(!canUseCustomTab()) { "Custom tab should be used instead of the fallback node." }
val inputs = OidcNode.Inputs(OidcDetails(url))
return parentNode.createNode<OidcNode>(buildContext, listOf(inputs))
}
}

View file

@ -1,69 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.impl.customtab
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import androidx.browser.customtabs.CustomTabsClient
import androidx.browser.customtabs.CustomTabsServiceConnection
import androidx.browser.customtabs.CustomTabsSession
import androidx.core.net.toUri
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject
class CustomTabHandler @Inject constructor(
@ApplicationContext private val context: Context,
) {
private var customTabsSession: CustomTabsSession? = null
private var customTabsClient: CustomTabsClient? = null
private var customTabsServiceConnection: CustomTabsServiceConnection? = null
fun prepareCustomTab(url: String) {
val packageName = CustomTabsClient.getPackageName(context, null)
// packageName can be null if there are 0 or several CustomTabs compatible browsers installed on the device
if (packageName != null) {
customTabsServiceConnection = object : CustomTabsServiceConnection() {
override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) {
customTabsClient = client.apply { warmup(0L) }
prefetchUrl(url)
}
override fun onServiceDisconnected(name: ComponentName?) {
}
}
.also {
CustomTabsClient.bindCustomTabsService(
context,
// Despite the API, packageName cannot be null
packageName,
it
)
}
}
}
private fun prefetchUrl(url: String) {
if (customTabsSession == null) {
customTabsSession = customTabsClient?.newSession(null)
}
customTabsSession?.mayLaunchUrl(url.toUri(), null, null)
}
fun disposeCustomTab() {
customTabsServiceConnection?.let { context.unbindService(it) }
customTabsServiceConnection = null
}
fun open(activity: Activity, darkTheme: Boolean, url: String) {
activity.openUrlInChromeCustomTab(customTabsSession, darkTheme, url)
}
}

View file

@ -1,16 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.impl.webview
import io.element.android.libraries.oidc.api.OidcAction
sealed interface OidcEvents {
data object Cancel : OidcEvents
data class OidcActionEvent(val oidcAction: OidcAction) : OidcEvents
data object ClearError : OidcEvents
}

View file

@ -1,48 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.impl.webview
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.oidc.impl.OidcUrlParser
@ContributesNode(AppScope::class)
class OidcNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: OidcPresenter.Factory,
private val oidcUrlParser: OidcUrlParser,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val oidcDetails: OidcDetails,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.oidcDetails)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
OidcView(
state = state,
oidcUrlParser = oidcUrlParser,
modifier = modifier,
onNavigateBack = ::navigateUp,
)
}
}

View file

@ -1,90 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.impl.webview
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.oidc.api.OidcAction
import kotlinx.coroutines.launch
class OidcPresenter @AssistedInject constructor(
@Assisted private val oidcDetails: OidcDetails,
private val authenticationService: MatrixAuthenticationService,
) : Presenter<OidcState> {
@AssistedFactory
interface Factory {
fun create(oidcDetails: OidcDetails): OidcPresenter
}
@Composable
override fun present(): OidcState {
var requestState: AsyncAction<Unit> by remember {
mutableStateOf(AsyncAction.Uninitialized)
}
val localCoroutineScope = rememberCoroutineScope()
fun handleCancel() {
requestState = AsyncAction.Loading
localCoroutineScope.launch {
authenticationService.cancelOidcLogin()
.fold(
onSuccess = {
// Then go back
requestState = AsyncAction.Success(Unit)
},
onFailure = {
requestState = AsyncAction.Failure(it)
}
)
}
}
fun handleSuccess(url: String) {
requestState = AsyncAction.Loading
localCoroutineScope.launch {
authenticationService.loginWithOidc(url)
.onFailure {
requestState = AsyncAction.Failure(it)
}
// On success, the node tree will be updated, there is nothing to do
}
}
fun handleAction(action: OidcAction) {
when (action) {
OidcAction.GoBack -> handleCancel()
is OidcAction.Success -> handleSuccess(action.url)
}
}
fun handleEvents(event: OidcEvents) {
when (event) {
OidcEvents.Cancel -> handleCancel()
is OidcEvents.OidcActionEvent -> handleAction(event.oidcAction)
OidcEvents.ClearError -> requestState = AsyncAction.Uninitialized
}
}
return OidcState(
oidcDetails = oidcDetails,
requestState = requestState,
eventSink = ::handleEvents
)
}
}

View file

@ -1,17 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.impl.webview
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.auth.OidcDetails
data class OidcState(
val oidcDetails: OidcDetails,
val requestState: AsyncAction<Unit>,
val eventSink: (OidcEvents) -> Unit
)

View file

@ -1,30 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.impl.webview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.auth.OidcDetails
open class OidcStateProvider : PreviewParameterProvider<OidcState> {
override val values: Sequence<OidcState>
get() = sequenceOf(
aOidcState(),
aOidcState().copy(requestState = AsyncAction.Loading),
)
}
fun aOidcState() = OidcState(
oidcDetails = aOidcDetails(),
requestState = AsyncAction.Uninitialized,
eventSink = {}
)
fun aOidcDetails() = OidcDetails(
url = "aUrl",
)

View file

@ -1,117 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.impl.webview
import android.annotation.SuppressLint
import android.webkit.WebView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.oidc.impl.OidcUrlParser
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OidcView(
state: OidcState,
oidcUrlParser: OidcUrlParser,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val isPreview = LocalInspectionMode.current
var webView by remember { mutableStateOf<WebView?>(null) }
fun shouldOverrideUrl(url: String): Boolean {
val action = oidcUrlParser.parse(url)
if (action != null) {
state.eventSink.invoke(OidcEvents.OidcActionEvent(action))
return true
}
return false
}
val oidcWebViewClient = remember {
OidcWebViewClient(::shouldOverrideUrl)
}
fun onBack() {
if (webView?.canGoBack().orFalse()) {
webView?.goBack()
} else {
// To properly cancel Oidc login
state.eventSink.invoke(OidcEvents.Cancel)
}
}
BackHandler { onBack() }
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
navigationIcon = {
BackButton(onClick = ::onBack)
},
)
}
) { contentPadding ->
AndroidView(
modifier = Modifier.padding(contentPadding),
factory = { context ->
WebView(context).apply {
if (!isPreview) {
webViewClient = oidcWebViewClient
settings.apply {
@SuppressLint("SetJavaScriptEnabled")
javaScriptEnabled = true
allowContentAccess = true
allowFileAccess = true
@Suppress("DEPRECATION")
databaseEnabled = true
domStorageEnabled = true
}
loadUrl(state.oidcDetails.url)
}
}.also {
webView = it
}
}
)
AsyncActionView(
async = state.requestState,
onSuccess = { onNavigateBack() },
onErrorDismiss = { state.eventSink(OidcEvents.ClearError) }
)
}
}
@PreviewsDayNight
@Composable
internal fun OidcViewPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) = ElementPreview {
OidcView(
state = state,
oidcUrlParser = { null },
onNavigateBack = {},
)
}

View file

@ -1,29 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.impl.webview
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
class OidcWebViewClient(
private val eventListener: WebViewEventListener,
) : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
return shouldOverrideUrl(request.url.toString())
}
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
return shouldOverrideUrl(url)
}
private fun shouldOverrideUrl(url: String): Boolean {
return eventListener.shouldOverrideUrlLoading(url)
}
}

View file

@ -1,18 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.impl.webview
fun interface WebViewEventListener {
/**
* Triggered when a Webview loads an url.
*
* @param url The url about to be rendered.
* @return true if the method needs to manage some custom handling
*/
fun shouldOverrideUrlLoading(url: String): Boolean
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.oidc.impl.customtab
package io.element.android.libraries.oidc.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.oidc.api.OidcAction

View file

@ -1,142 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.oidc.impl.webview
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class OidcPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = OidcPresenter(
A_OIDC_DATA,
FakeMatrixAuthenticationService(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.oidcDetails).isEqualTo(A_OIDC_DATA)
assertThat(initialState.requestState).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - go back`() = runTest {
val presenter = OidcPresenter(
A_OIDC_DATA,
FakeMatrixAuthenticationService(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.Cancel)
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(AsyncAction.Loading)
val finalState = awaitItem()
assertThat(finalState.requestState).isEqualTo(AsyncAction.Success(Unit))
}
}
@Test
fun `present - go back with failure`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
val presenter = OidcPresenter(
A_OIDC_DATA,
authenticationService,
)
authenticationService.givenOidcCancelError(AN_EXCEPTION)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.Cancel)
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(AsyncAction.Loading)
val finalState = awaitItem()
assertThat(finalState.requestState).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
// Note: in real life I do not think this can happen, and the app should not block the user.
}
}
@Test
fun `present - user cancels from webview`() = runTest {
val presenter = OidcPresenter(
A_OIDC_DATA,
FakeMatrixAuthenticationService(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.GoBack))
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(AsyncAction.Loading)
val finalState = awaitItem()
assertThat(finalState.requestState).isEqualTo(AsyncAction.Success(Unit))
}
}
@Test
fun `present - login success`() = runTest {
val presenter = OidcPresenter(
A_OIDC_DATA,
FakeMatrixAuthenticationService(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.Success("A_URL")))
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(AsyncAction.Loading)
// In this case, no success, the session is created and the node get destroyed.
}
}
@Test
fun `present - login error`() = runTest {
val authenticationService = FakeMatrixAuthenticationService()
val presenter = OidcPresenter(
A_OIDC_DATA,
authenticationService,
)
authenticationService.givenLoginError(AN_EXCEPTION)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.Success("A_URL")))
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(AsyncAction.Loading)
val errorState = awaitItem()
assertThat(errorState.requestState).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
errorState.eventSink.invoke(OidcEvents.ClearError)
val finalState = awaitItem()
assertThat(finalState.requestState).isEqualTo(AsyncAction.Uninitialized)
}
}
}

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:154a56d848cc735efc25274052ce92b1a20bc8f80f75e91d0d4cfc7d488cd246
size 5874

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9ff42261f4d991650578ceadaa13be0b1924c1af8f71cf10832f53c7d958827d
size 9317

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2dd3462935091cfe022f0e6fc71b082eed7dab4769def13398a81a90f871b61a
size 5807

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e6158b2d0447199de84156fbfdc7bd9aa1af7ff2092b498db254ded465605b76
size 8208