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:
parent
58d503f661
commit
36c7c7ab9b
21 changed files with 14 additions and 709 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:154a56d848cc735efc25274052ce92b1a20bc8f80f75e91d0d4cfc7d488cd246
|
||||
size 5874
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9ff42261f4d991650578ceadaa13be0b1924c1af8f71cf10832f53c7d958827d
|
||||
size 9317
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2dd3462935091cfe022f0e6fc71b082eed7dab4769def13398a81a90f871b61a
|
||||
size 5807
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e6158b2d0447199de84156fbfdc7bd9aa1af7ff2092b498db254ded465605b76
|
||||
size 8208
|
||||
Loading…
Add table
Add a link
Reference in a new issue