Merge pull request #347 from vector-im/feature/bma/oidc2
Add (disabled) Oidc support
This commit is contained in:
@@ -33,7 +33,7 @@
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||
android:exported="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.ElementX.Splash"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
@@ -49,6 +49,14 @@
|
||||
android:host="open"
|
||||
android:scheme="elementx" />
|
||||
</intent-filter-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="io.element" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
|
||||
@@ -38,14 +38,17 @@ import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.appnav.di.MatrixClientsHolder
|
||||
import io.element.android.appnav.intent.IntentResolver
|
||||
import io.element.android.appnav.intent.ResolvedIntent
|
||||
import io.element.android.appnav.root.RootPresenter
|
||||
import io.element.android.appnav.root.RootView
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.api.oidc.OidcActionFlow
|
||||
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.deeplink.DeeplinkData
|
||||
import io.element.android.libraries.deeplink.DeeplinkParser
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
@@ -65,7 +68,8 @@ class RootFlowNode @AssistedInject constructor(
|
||||
private val matrixClientsHolder: MatrixClientsHolder,
|
||||
private val presenter: RootPresenter,
|
||||
private val bugReportEntryPoint: BugReportEntryPoint,
|
||||
private val deeplinkParser: DeeplinkParser,
|
||||
private val intentResolver: IntentResolver,
|
||||
private val oidcActionFlow: OidcActionFlow,
|
||||
) :
|
||||
BackstackNode<RootFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
@@ -204,8 +208,11 @@ class RootFlowNode @AssistedInject constructor(
|
||||
}
|
||||
|
||||
suspend fun handleIntent(intent: Intent) {
|
||||
deeplinkParser.getFromIntent(intent)
|
||||
?.let { navigateTo(it) }
|
||||
val resolvedIntent = intentResolver.resolve(intent) ?: return
|
||||
when (resolvedIntent) {
|
||||
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
|
||||
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
|
||||
@@ -223,6 +230,10 @@ class RootFlowNode @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun onOidcAction(oidcAction: OidcAction) {
|
||||
oidcActionFlow.post(oidcAction)
|
||||
}
|
||||
|
||||
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
|
||||
return attachChild {
|
||||
backstack.newRoot(NavTarget.LoggedInFlow(sessionId))
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.appnav.intent
|
||||
|
||||
import android.content.Intent
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.api.oidc.OidcIntentResolver
|
||||
import io.element.android.libraries.deeplink.DeeplinkData
|
||||
import io.element.android.libraries.deeplink.DeeplinkParser
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface ResolvedIntent {
|
||||
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
|
||||
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
|
||||
}
|
||||
|
||||
class IntentResolver @Inject constructor(
|
||||
private val deeplinkParser: DeeplinkParser,
|
||||
private val oidcIntentResolver: OidcIntentResolver
|
||||
) {
|
||||
fun resolve(intent: Intent): ResolvedIntent? {
|
||||
val deepLinkData = deeplinkParser.getFromIntent(intent)
|
||||
if (deepLinkData != null) return ResolvedIntent.Navigation(deepLinkData)
|
||||
|
||||
val oidcAction = oidcIntentResolver.resolve(intent)
|
||||
if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
|
||||
|
||||
// Unknown intent
|
||||
Timber.w("Unknown intent")
|
||||
return null
|
||||
}
|
||||
}
|
||||
47
docs/oidc.md
Normal file
47
docs/oidc.md
Normal file
@@ -0,0 +1,47 @@
|
||||
This file contains some rough notes about Oidc implementation, with some examples of actual data.
|
||||
|
||||
[ios implementation](https://github.com/vector-im/element-x-ios/compare/develop...doug/oidc-temp)
|
||||
|
||||
Rust sdk branch: https://github.com/matrix-org/matrix-rust-sdk/tree/oidc-ffi
|
||||
|
||||
Figma https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?node-id=133-5426&t=yQXKeANatk6keoZF-0
|
||||
|
||||
Server list: https://github.com/vector-im/oidc-playground
|
||||
|
||||
Metadata iOS: (from https://github.com/vector-im/element-x-ios/blob/5f9d07377cebc4f21d9668b1a25f6e3bb22f64a1/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift#L28)
|
||||
|
||||
clientName: InfoPlistReader.main.bundleDisplayName,
|
||||
redirectUri: "io.element:/callback",
|
||||
clientUri: "https://element.io",
|
||||
tosUri: "https://element.io/user-terms-of-service",
|
||||
policyUri: "https://element.io/privacy"
|
||||
|
||||
|
||||
Android:
|
||||
clientName = "Element",
|
||||
redirectUri = "io.element:/callback",
|
||||
clientUri = "https://element.io",
|
||||
tosUri = "https://element.io/user-terms-of-service",
|
||||
policyUri = "https://element.io/privacy"
|
||||
|
||||
|
||||
Example of OidcData (from presentUrl callback):
|
||||
url: https://auth-oidc.lab.element.dev/authorize?response_type=code&client_id=01GYCAGG3PA70CJ97ZVP0WFJY3&redirect_uri=io.element%3A%2Fcallback&scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG&state=ex6mNJVFZ5jn9wL8&nonce=NZ93DOyIGQd9exPQ&code_challenge_method=S256&code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U&prompt=consent
|
||||
|
||||
Formatted url:
|
||||
https://auth-oidc.lab.element.dev/authorize?
|
||||
response_type=code&
|
||||
client_id=01GYCAGG3PA70CJ97ZVP0WFJY3&
|
||||
redirect_uri=io.element%3A%2Fcallback&
|
||||
scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG&
|
||||
state=ex6mNJVFZ5jn9wL8&
|
||||
nonce=NZ93DOyIGQd9exPQ&
|
||||
code_challenge_method=S256&
|
||||
code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U&
|
||||
prompt=consent
|
||||
|
||||
state: ex6mNJVFZ5jn9wL8
|
||||
|
||||
|
||||
Oidc client example: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/examples/oidc_cli/src/main.rs
|
||||
Oidc sdk doc: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/crates/matrix-sdk/src/oidc.rs
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.api.oidc
|
||||
|
||||
sealed interface OidcAction {
|
||||
object GoBack : OidcAction
|
||||
data class Success(val url: String) : OidcAction
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.api.oidc
|
||||
|
||||
interface OidcActionFlow {
|
||||
fun post(oidcAction: OidcAction)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.api.oidc
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
interface OidcIntentResolver {
|
||||
fun resolve(intent: Intent): OidcAction?
|
||||
}
|
||||
@@ -45,6 +45,7 @@ dependencies {
|
||||
implementation(projects.libraries.elementresources)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(libs.androidx.browser)
|
||||
api(projects.features.login.api)
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
|
||||
26
features/login/impl/src/main/AndroidManifest.xml
Normal file
26
features/login/impl/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright (c) 2022 New Vector Ltd
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<queries>
|
||||
<!-- To open URL in CustomTab (prefetch, etc.). It makes CustomTabsClient.getPackageName() work
|
||||
see https://developer.android.com/training/package-visibility/use-cases#open-urls-custom-tabs -->
|
||||
<intent>
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
</manifest>
|
||||
@@ -16,9 +16,13 @@
|
||||
|
||||
package io.element.android.features.login.impl
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
@@ -29,17 +33,24 @@ import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.login.impl.changeserver.ChangeServerNode
|
||||
import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker
|
||||
import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
|
||||
import io.element.android.features.login.impl.oidc.webview.OidcNode
|
||||
import io.element.android.features.login.impl.root.LoginRootNode
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.designsystem.theme.ElementTheme
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class LoginFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
|
||||
private val customTabHandler: CustomTabHandler,
|
||||
) : BackstackNode<LoginFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
@@ -48,6 +59,8 @@ class LoginFlowNode @AssistedInject constructor(
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) {
|
||||
private var activity: Activity? = null
|
||||
private var darkTheme: Boolean = false
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
@@ -55,6 +68,9 @@ class LoginFlowNode @AssistedInject constructor(
|
||||
|
||||
@Parcelize
|
||||
object ChangeServer : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
@@ -64,15 +80,37 @@ class LoginFlowNode @AssistedInject constructor(
|
||||
override fun onChangeHomeServer() {
|
||||
backstack.push(NavTarget.ChangeServer)
|
||||
}
|
||||
|
||||
override fun onOidcDetails(oidcDetails: OidcDetails) {
|
||||
if (customTabAvailabilityChecker.supportCustomTab()) {
|
||||
// In this case open a Chrome Custom tab
|
||||
activity?.let { customTabHandler.open(it, darkTheme, oidcDetails.url) }
|
||||
} else {
|
||||
// Fallback to WebView mode
|
||||
backstack.push(NavTarget.OidcView(oidcDetails))
|
||||
}
|
||||
}
|
||||
}
|
||||
createNode<LoginRootNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
|
||||
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
|
||||
is NavTarget.OidcView -> {
|
||||
val input = OidcNode.Inputs(navTarget.oidcDetails)
|
||||
createNode<OidcNode>(buildContext, plugins = listOf(input))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
activity = LocalContext.current as? Activity
|
||||
darkTheme = !ElementTheme.colors.isLight
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
activity = null
|
||||
}
|
||||
}
|
||||
Children(
|
||||
navModel = backstack,
|
||||
modifier = modifier,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc
|
||||
|
||||
import android.content.Intent
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.api.oidc.OidcIntentResolver
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultOidcIntentResolver @Inject constructor(
|
||||
private val oidcUrlParser: OidcUrlParser,
|
||||
) : OidcIntentResolver {
|
||||
override fun resolve(intent: Intent): OidcAction? {
|
||||
return oidcUrlParser.parse(intent.dataString.orEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc
|
||||
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.libraries.matrix.api.auth.OidcConfig
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Simple parser for oidc url interception.
|
||||
* TODO Find documentation about the format.
|
||||
*/
|
||||
class OidcUrlParser @Inject constructor() {
|
||||
|
||||
// When user press button "Cancel", we get the url:
|
||||
// `io.element:/callback?error=access_denied&state=IFF1UETGye2ZA8pO`
|
||||
// On success, we get:
|
||||
// `io.element:/callback?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB`
|
||||
/**
|
||||
* Return a OidcAction, or null if the url is not a OidcUrl.
|
||||
*/
|
||||
fun parse(url: String): OidcAction? {
|
||||
if (url.startsWith(OidcConfig.redirectUri).not()) return null
|
||||
if (url.contains("error=access_denied")) return OidcAction.GoBack
|
||||
if (url.contains("code=")) return OidcAction.Success(url)
|
||||
|
||||
// Other case not supported, let's crash the app for now
|
||||
error("Not supported: $url")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.customtab
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsClient
|
||||
import androidx.browser.customtabs.CustomTabsServiceConnection
|
||||
import androidx.browser.customtabs.CustomTabsSession
|
||||
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
|
||||
.also { it.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(Uri.parse(url), null, null)
|
||||
}
|
||||
|
||||
fun disposeCustomTab() {
|
||||
customTabsServiceConnection?.let { context.unbindService(it) }
|
||||
customTabsServiceConnection = null
|
||||
}
|
||||
|
||||
fun open(activity: Activity, darkTheme: Boolean, url: String) {
|
||||
activity.openUrlInChromeCustomTab(customTabsSession, darkTheme, url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.customtab
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.api.oidc.OidcActionFlow
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@SingleIn(AppScope::class)
|
||||
class DefaultOidcActionFlow @Inject constructor() : OidcActionFlow {
|
||||
private val mutableStateFlow = MutableStateFlow<OidcAction?>(null)
|
||||
|
||||
override fun post(oidcAction: OidcAction) {
|
||||
mutableStateFlow.value = oidcAction
|
||||
}
|
||||
|
||||
suspend fun collect(collector: FlowCollector<OidcAction?>) {
|
||||
mutableStateFlow.collect(collector)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
mutableStateFlow.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.customtab
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.browser.customtabs.CustomTabsSession
|
||||
|
||||
/**
|
||||
* Open url in custom tab or, if not available, in the default browser.
|
||||
* If several compatible browsers are installed, the user will be proposed to choose one.
|
||||
* Ref: https://developer.chrome.com/multidevice/android/customtabs.
|
||||
*/
|
||||
fun Activity.openUrlInChromeCustomTab(
|
||||
session: CustomTabsSession?,
|
||||
darkTheme: Boolean,
|
||||
url: String
|
||||
) {
|
||||
try {
|
||||
CustomTabsIntent.Builder()
|
||||
.setDefaultColorSchemeParams(
|
||||
CustomTabColorSchemeParams.Builder()
|
||||
// TODO .setToolbarColor(ThemeUtils.getColor(context, android.R.attr.colorBackground))
|
||||
// TODO .setNavigationBarColor(ThemeUtils.getColor(context, android.R.attr.colorBackground))
|
||||
.build()
|
||||
)
|
||||
.setColorScheme(
|
||||
when (darkTheme) {
|
||||
false -> CustomTabsIntent.COLOR_SCHEME_LIGHT
|
||||
true -> CustomTabsIntent.COLOR_SCHEME_DARK
|
||||
}
|
||||
)
|
||||
// Note: setting close button icon does not work
|
||||
// .setCloseButtonIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_back_24dp))
|
||||
// .setStartAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
|
||||
// .setExitAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
|
||||
.apply { session?.let { setSession(it) } }
|
||||
.build()
|
||||
.launchUrl(this, Uri.parse(url))
|
||||
} catch (activityNotFoundException: ActivityNotFoundException) {
|
||||
// TODO context.toast(R.string.error_no_external_application_found)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
|
||||
sealed interface OidcEvents {
|
||||
object Cancel : OidcEvents
|
||||
data class OidcActionEvent(val oidcAction: OidcAction): OidcEvents
|
||||
object ClearError : OidcEvents
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.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
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class OidcNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: OidcPresenter.Factory,
|
||||
) : 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,
|
||||
modifier = modifier,
|
||||
onNavigateBack = ::navigateUp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.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.features.login.api.oidc.OidcAction
|
||||
import io.element.android.libraries.architecture.Async
|
||||
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 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: Async<Unit> by remember {
|
||||
mutableStateOf(Async.Uninitialized)
|
||||
}
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun handleCancel() {
|
||||
requestState = Async.Loading()
|
||||
localCoroutineScope.launch {
|
||||
authenticationService.cancelOidcLogin()
|
||||
.fold(
|
||||
onSuccess = {
|
||||
// Then go back
|
||||
requestState = Async.Success(Unit)
|
||||
},
|
||||
onFailure = {
|
||||
requestState = Async.Failure(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSuccess(url: String) {
|
||||
requestState = Async.Loading()
|
||||
localCoroutineScope.launch {
|
||||
authenticationService.loginWithOidc(url)
|
||||
.onFailure {
|
||||
requestState = Async.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 = Async.Uninitialized
|
||||
}
|
||||
}
|
||||
|
||||
return OidcState(
|
||||
oidcDetails = oidcDetails,
|
||||
requestState = requestState,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
|
||||
data class OidcState(
|
||||
val oidcDetails: OidcDetails,
|
||||
val requestState: Async<Unit>,
|
||||
val eventSink: (OidcEvents) -> Unit
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.Async
|
||||
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 = Async.Loading()),
|
||||
)
|
||||
}
|
||||
|
||||
fun aOidcState() = OidcState(
|
||||
oidcDetails = aOidcDetails(),
|
||||
requestState = Async.Uninitialized,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
fun aOidcDetails() = OidcDetails(
|
||||
url = "aUrl",
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import io.element.android.features.login.impl.oidc.OidcUrlParser
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
|
||||
@Composable
|
||||
fun OidcView(
|
||||
state: OidcState,
|
||||
onNavigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val oidcUrlParser = remember { OidcUrlParser() }
|
||||
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)
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
if (webView?.canGoBack().orFalse()) {
|
||||
webView?.goBack()
|
||||
} else {
|
||||
// To properly cancel Oidc login
|
||||
state.eventSink.invoke(OidcEvents.Cancel)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.statusBarsPadding()) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
webViewClient = oidcWebViewClient
|
||||
loadUrl(state.oidcDetails.url)
|
||||
}.also {
|
||||
webView = it
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
when (state.requestState) {
|
||||
Async.Uninitialized -> Unit
|
||||
is Async.Failure -> {
|
||||
ErrorDialog(
|
||||
content = state.requestState.error.toString(),
|
||||
onDismiss = { state.eventSink(OidcEvents.ClearError) }
|
||||
)
|
||||
}
|
||||
is Async.Loading -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
is Async.Success -> onNavigateBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OidcViewLightPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) =
|
||||
ElementPreviewLight { ContentToPreview(state) }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OidcViewDarkPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) =
|
||||
ElementPreviewDark { ContentToPreview(state) }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: OidcState) {
|
||||
OidcView(
|
||||
state = state,
|
||||
onNavigateBack = { },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.annotation.TargetApi
|
||||
import android.os.Build
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
|
||||
class OidcWebViewClient(
|
||||
private val eventListener: WebViewEventListener,
|
||||
) : WebViewClient() {
|
||||
// We will revert to API 23, in the mean time ignore the warning here.
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
@TargetApi(Build.VERSION_CODES.N)
|
||||
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 {
|
||||
// Timber.d("shouldOverrideUrl: $url")
|
||||
return eventListener.shouldOverrideUrlLoading(url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.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
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.features.login.impl.root
|
||||
|
||||
sealed interface LoginRootEvents {
|
||||
object RetryFetchServerInfo : LoginRootEvents
|
||||
data class SetLogin(val login: String) : LoginRootEvents
|
||||
data class SetPassword(val password: String) : LoginRootEvents
|
||||
object Submit : LoginRootEvents
|
||||
|
||||
@@ -26,6 +26,7 @@ import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class LoginRootNode @AssistedInject constructor(
|
||||
@@ -36,20 +37,26 @@ class LoginRootNode @AssistedInject constructor(
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onChangeHomeServer()
|
||||
fun onOidcDetails(oidcDetails: OidcDetails)
|
||||
}
|
||||
|
||||
private fun onChangeHomeServer() {
|
||||
plugins<Callback>().forEach { it.onChangeHomeServer() }
|
||||
}
|
||||
|
||||
private fun onOidcDetails(oidcDetails: OidcDetails) {
|
||||
plugins<Callback>().forEach { it.onOidcDetails(oidcDetails) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
LoginRootView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onChangeServer = this::onChangeHomeServer,
|
||||
onBackPressed = this::navigateUp
|
||||
onChangeServer = ::onChangeHomeServer,
|
||||
onOidcDetails = ::onOidcDetails,
|
||||
onBackPressed = ::navigateUp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,28 +17,49 @@
|
||||
package io.element.android.features.login.impl.root
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
|
||||
import io.element.android.features.login.impl.util.LoginConstants
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.execute
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<LoginRootState> {
|
||||
|
||||
private val defaultHomeserver = MatrixHomeServerDetails(LoginConstants.DEFAULT_HOMESERVER_URL, true, null)
|
||||
class LoginRootPresenter @Inject constructor(
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val defaultOidcActionFlow: DefaultOidcActionFlow,
|
||||
) : Presenter<LoginRootState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): LoginRootState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val homeserver = authenticationService.getHomeserverDetails().collectAsState().value ?: defaultHomeserver
|
||||
val currentHomeServerDetails = authenticationService.getHomeserverDetails().collectAsState().value
|
||||
val homeserver = currentHomeServerDetails?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL
|
||||
val getHomeServerDetailsAction: MutableState<Async<MatrixHomeServerDetails>> = remember {
|
||||
if (currentHomeServerDetails != null) {
|
||||
mutableStateOf(Async.Success(currentHomeServerDetails))
|
||||
} else {
|
||||
mutableStateOf(Async.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (currentHomeServerDetails == null) {
|
||||
getHomeServerDetails(homeserver, getHomeServerDetailsAction)
|
||||
}
|
||||
}
|
||||
|
||||
val loggedInState: MutableState<LoggedInState> = remember {
|
||||
mutableStateOf(LoggedInState.NotLoggedIn)
|
||||
}
|
||||
@@ -46,31 +67,69 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
|
||||
mutableStateOf(LoginFormState.Default)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
defaultOidcActionFlow.collect {
|
||||
onOidcAction(it, loggedInState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvents(event: LoginRootEvents) {
|
||||
when (event) {
|
||||
LoginRootEvents.RetryFetchServerInfo -> localCoroutineScope.getHomeServerDetails(homeserver, getHomeServerDetailsAction)
|
||||
is LoginRootEvents.SetLogin -> updateFormState(formState) {
|
||||
copy(login = event.login)
|
||||
}
|
||||
is LoginRootEvents.SetPassword -> updateFormState(formState) {
|
||||
copy(password = event.password)
|
||||
}
|
||||
LoginRootEvents.Submit -> localCoroutineScope.submit(homeserver.url, formState.value, loggedInState)
|
||||
LoginRootEvents.Submit -> {
|
||||
val homeServerDetails = getHomeServerDetailsAction.value.dataOrNull() ?: return
|
||||
when {
|
||||
homeServerDetails.supportsOidcLogin -> localCoroutineScope.submitOidc(loggedInState)
|
||||
homeServerDetails.supportsPasswordLogin -> localCoroutineScope.submit(formState.value, loggedInState)
|
||||
}
|
||||
}
|
||||
LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn
|
||||
}
|
||||
}
|
||||
|
||||
return LoginRootState(
|
||||
homeserverDetails = homeserver,
|
||||
homeserverUrl = homeserver,
|
||||
homeserverDetails = getHomeServerDetailsAction.value,
|
||||
loggedInState = loggedInState.value,
|
||||
formState = formState.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
|
||||
private fun CoroutineScope.getHomeServerDetails(
|
||||
homeserver: String,
|
||||
state: MutableState<Async<MatrixHomeServerDetails>>,
|
||||
) = launch {
|
||||
suspend {
|
||||
authenticationService.setHomeserver(homeserver)
|
||||
.map {
|
||||
authenticationService.getHomeserverDetails().value!!
|
||||
}
|
||||
.getOrThrow()
|
||||
}.execute(state)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.submitOidc(loggedInState: MutableState<LoggedInState>) = launch {
|
||||
loggedInState.value = LoggedInState.LoggingIn
|
||||
authenticationService.getOidcUrl()
|
||||
.onSuccess {
|
||||
loggedInState.value = LoggedInState.OidcStarted(it)
|
||||
}
|
||||
.onFailure { failure ->
|
||||
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
|
||||
loggedInState.value = LoggedInState.LoggingIn
|
||||
//TODO rework the setHomeserver flow
|
||||
authenticationService.setHomeserver(homeserver)
|
||||
authenticationService.login(formState.login.trim(), formState.password)
|
||||
.onSuccess { sessionId ->
|
||||
loggedInState.value = LoggedInState.LoggedIn(sessionId)
|
||||
@@ -83,4 +142,30 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
|
||||
private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
|
||||
formState.value = updateLambda(formState.value)
|
||||
}
|
||||
|
||||
private suspend fun onOidcAction(oidcAction: OidcAction?, loggedInState: MutableState<LoggedInState>) {
|
||||
oidcAction ?: return
|
||||
loggedInState.value = LoggedInState.LoggingIn
|
||||
when (oidcAction) {
|
||||
OidcAction.GoBack -> {
|
||||
authenticationService.cancelOidcLogin()
|
||||
.onSuccess {
|
||||
loggedInState.value = LoggedInState.NotLoggedIn
|
||||
}
|
||||
.onFailure { failure ->
|
||||
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
|
||||
}
|
||||
}
|
||||
is OidcAction.Success -> {
|
||||
authenticationService.loginWithOidc(oidcAction.url)
|
||||
.onSuccess { sessionId ->
|
||||
loggedInState.value = LoggedInState.LoggedIn(sessionId)
|
||||
}
|
||||
.onFailure { failure ->
|
||||
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
defaultOidcActionFlow.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,23 +17,31 @@
|
||||
package io.element.android.features.login.impl.root
|
||||
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
data class LoginRootState(
|
||||
val homeserverDetails: MatrixHomeServerDetails,
|
||||
val homeserverUrl: String,
|
||||
val homeserverDetails: Async<MatrixHomeServerDetails>,
|
||||
val loggedInState: LoggedInState,
|
||||
val formState: LoginFormState,
|
||||
val eventSink: (LoginRootEvents) -> Unit
|
||||
) {
|
||||
val submitEnabled: Boolean get() =
|
||||
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInState !is LoggedInState.ErrorLoggingIn
|
||||
val supportPasswordLogin = (homeserverDetails as? Async.Success)?.state?.supportsPasswordLogin.orFalse()
|
||||
val supportOidcLogin = (homeserverDetails as? Async.Success)?.state?.supportsOidcLogin.orFalse()
|
||||
val submitEnabled: Boolean
|
||||
get() = loggedInState !is LoggedInState.ErrorLoggingIn &&
|
||||
((formState.login.isNotEmpty() && formState.password.isNotEmpty()) || supportOidcLogin)
|
||||
}
|
||||
|
||||
sealed interface LoggedInState {
|
||||
object NotLoggedIn : LoggedInState
|
||||
object LoggingIn : LoggedInState
|
||||
data class OidcStarted(val oidcDetail: OidcDetails) : LoggedInState
|
||||
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
|
||||
data class LoggedIn(val sessionId: SessionId) : LoggedInState
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.features.login.impl.root
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
@@ -24,16 +25,51 @@ open class LoginRootStateProvider : PreviewParameterProvider<LoginRootState> {
|
||||
override val values: Sequence<LoginRootState>
|
||||
get() = sequenceOf(
|
||||
aLoginRootState(),
|
||||
aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("some-custom-server.com", true, null)),
|
||||
aLoginRootState().copy(
|
||||
homeserverDetails = Async.Success(
|
||||
MatrixHomeServerDetails(
|
||||
"some-custom-server.com",
|
||||
supportsPasswordLogin = true,
|
||||
supportsOidcLogin = false
|
||||
)
|
||||
)
|
||||
),
|
||||
aLoginRootState().copy(formState = LoginFormState("user", "pass")),
|
||||
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn),
|
||||
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.ErrorLoggingIn(Throwable())),
|
||||
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))),
|
||||
// Oidc
|
||||
aLoginRootState().copy(
|
||||
homeserverUrl = "server-with-oidc.org",
|
||||
homeserverDetails = Async.Success(
|
||||
MatrixHomeServerDetails(
|
||||
"server-with-oidc.org",
|
||||
supportsPasswordLogin = false,
|
||||
supportsOidcLogin = true
|
||||
)
|
||||
)
|
||||
),
|
||||
// No password, no oidc support
|
||||
aLoginRootState().copy(
|
||||
homeserverUrl = "wrong.org",
|
||||
homeserverDetails = Async.Success(
|
||||
MatrixHomeServerDetails(
|
||||
"wrong.org",
|
||||
supportsPasswordLogin = false,
|
||||
supportsOidcLogin = false
|
||||
)
|
||||
)
|
||||
),
|
||||
// Loading
|
||||
aLoginRootState().copy(homeserverDetails = Async.Loading()),
|
||||
//Error
|
||||
aLoginRootState().copy(homeserverDetails = Async.Failure(Exception("An error occurred"))),
|
||||
)
|
||||
}
|
||||
|
||||
fun aLoginRootState() = LoginRootState(
|
||||
homeserverDetails = MatrixHomeServerDetails("matrix.org", true, null),
|
||||
homeserverUrl = "matrix.org",
|
||||
homeserverDetails = Async.Success(MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidcLogin = false)),
|
||||
loggedInState = LoggedInState.NotLoggedIn,
|
||||
formState = LoginFormState.Default,
|
||||
eventSink = {}
|
||||
|
||||
@@ -68,7 +68,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.login.impl.R
|
||||
import io.element.android.features.login.impl.error.loginError
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncLoading
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
@@ -83,7 +86,7 @@ import io.element.android.libraries.designsystem.theme.components.TextField
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.theme.components.autofill
|
||||
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
@@ -94,7 +97,7 @@ fun LoginRootView(
|
||||
state: LoginRootState,
|
||||
modifier: Modifier = Modifier,
|
||||
onChangeServer: () -> Unit = {},
|
||||
onLoginWithSuccess: (SessionId) -> Unit = {},
|
||||
onOidcDetails: (OidcDetails) -> Unit = {},
|
||||
onBackPressed: () -> Unit,
|
||||
) {
|
||||
val isLoading by remember(state.loggedInState) {
|
||||
@@ -102,6 +105,15 @@ fun LoginRootView(
|
||||
state.loggedInState == LoggedInState.LoggingIn
|
||||
}
|
||||
}
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
fun submit() {
|
||||
// Clear focus to prevent keyboard issues with textfields
|
||||
focusManager.clearFocus(force = true)
|
||||
|
||||
state.eventSink(LoginRootEvents.Submit)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -137,19 +149,26 @@ fun LoginRootView(
|
||||
|
||||
ChangeServerSection(
|
||||
interactionEnabled = !isLoading,
|
||||
homeserver = state.homeserverDetails.url,
|
||||
homeserver = state.homeserverUrl,
|
||||
onChangeServer = onChangeServer
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
LoginForm(state = state, isLoading = isLoading)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
when (state.homeserverDetails) {
|
||||
Async.Uninitialized,
|
||||
is Async.Loading -> AsyncLoading()
|
||||
is Async.Failure -> AsyncFailure(
|
||||
throwable = state.homeserverDetails.error,
|
||||
onRetry = {
|
||||
state.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo)
|
||||
}
|
||||
)
|
||||
is Async.Success -> ServerDetailForm(state, isLoading, ::submit)
|
||||
}
|
||||
}
|
||||
when (val loggedInState = state.loggedInState) {
|
||||
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
|
||||
is LoggedInState.OidcStarted -> onOidcDetails(loggedInState.oidcDetail)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -162,6 +181,43 @@ fun LoginRootView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerDetailForm(
|
||||
state: LoginRootState,
|
||||
isLoading: Boolean,
|
||||
submit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
state.supportOidcLogin -> {
|
||||
// Oidc, in this case, just display a Spacer and the submit button
|
||||
Spacer(modifier.height(28.dp))
|
||||
}
|
||||
state.supportPasswordLogin -> {
|
||||
LoginForm(state = state, isLoading = isLoading, onSubmit = submit, modifier = modifier)
|
||||
}
|
||||
else -> {
|
||||
Text(modifier = modifier, text = "No supported login flow")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
if (state.supportOidcLogin || state.supportPasswordLogin) {
|
||||
// Submit
|
||||
ButtonWithProgress(
|
||||
text = stringResource(R.string.screen_login_submit),
|
||||
showProgress = isLoading,
|
||||
onClick = submit,
|
||||
enabled = state.submitEnabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.loginContinue)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ChangeServerSection(
|
||||
interactionEnabled: Boolean,
|
||||
@@ -217,6 +273,7 @@ internal fun ChangeServerSection(
|
||||
internal fun LoginForm(
|
||||
state: LoginRootState,
|
||||
isLoading: Boolean,
|
||||
onSubmit: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var loginFieldState by textFieldState(stateValue = state.formState.login)
|
||||
@@ -225,13 +282,6 @@ internal fun LoginForm(
|
||||
val focusManager = LocalFocusManager.current
|
||||
val eventSink = state.eventSink
|
||||
|
||||
fun submit() {
|
||||
// Clear focus to prevent keyboard issues with textfields
|
||||
focusManager.clearFocus(force = true)
|
||||
|
||||
eventSink(LoginRootEvents.Submit)
|
||||
}
|
||||
|
||||
Column(modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.screen_login_form_header),
|
||||
@@ -318,23 +368,11 @@ internal fun LoginForm(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { submit() }
|
||||
onDone = { onSubmit() }
|
||||
),
|
||||
singleLine = true,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
// Submit
|
||||
ButtonWithProgress(
|
||||
text = stringResource(R.string.screen_login_submit),
|
||||
showProgress = isLoading,
|
||||
onClick = ::submit,
|
||||
enabled = state.submitEnabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.loginContinue)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ package io.element.android.features.login.impl.util
|
||||
|
||||
object LoginConstants {
|
||||
|
||||
const val DEFAULT_HOMESERVER_URL = "matrix.org"
|
||||
const val DEFAULT_HOMESERVER_URL = "matrix.org" // TODO Oidc "synapse-oidc.lab.element.dev"
|
||||
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
|
||||
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.login.impl.util.LoginConstants
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||
@@ -39,7 +39,7 @@ class ChangeServerPresenterTest {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER_URL)
|
||||
assertThat(initialState.homeserver).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
|
||||
assertThat(initialState.submitEnabled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.libraries.matrix.api.auth.OidcConfig
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
||||
class OidcUrlParserTest {
|
||||
@Test
|
||||
fun `test empty url`() {
|
||||
val sut = OidcUrlParser()
|
||||
assertThat(sut.parse("")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test regular url`() {
|
||||
val sut = OidcUrlParser()
|
||||
assertThat(sut.parse("https://matrix.org")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test cancel url`() {
|
||||
val sut = OidcUrlParser()
|
||||
val aCancelUrl = OidcConfig.redirectUri + "?error=access_denied&state=IFF1UETGye2ZA8pO"
|
||||
assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test success url`() {
|
||||
val sut = OidcUrlParser()
|
||||
val aSuccessUrl = OidcConfig.redirectUri + "?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
|
||||
assertThat(sut.parse(aSuccessUrl)).isEqualTo(OidcAction.Success(aSuccessUrl))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test unknown url`() {
|
||||
val sut = OidcUrlParser()
|
||||
val anUnknownUrl = OidcConfig.redirectUri + "?state=IFF1UETGye2ZA8pO&goat=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
|
||||
Assert.assertThrows(IllegalStateException::class.java) {
|
||||
assertThat(sut.parse(anUnknownUrl))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
|
||||
import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
|
||||
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class OidcPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = OidcPresenter(
|
||||
A_OIDC_DATA,
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.oidcDetails).isEqualTo(A_OIDC_DATA)
|
||||
assertThat(initialState.requestState).isEqualTo(Async.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - go back`() = runTest {
|
||||
val presenter = OidcPresenter(
|
||||
A_OIDC_DATA,
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(OidcEvents.Cancel)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.requestState).isEqualTo(Async.Success(Unit))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - go back with failure`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val presenter = OidcPresenter(
|
||||
A_OIDC_DATA,
|
||||
authenticationService,
|
||||
)
|
||||
authenticationService.givenOidcCancelError(A_THROWABLE)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(OidcEvents.Cancel)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.requestState).isEqualTo(Async.Failure<Unit>(A_THROWABLE))
|
||||
// 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,
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.GoBack))
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.requestState).isEqualTo(Async.Success(Unit))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - login success`() = runTest {
|
||||
val presenter = OidcPresenter(
|
||||
A_OIDC_DATA,
|
||||
FakeAuthenticationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.Success("A_URL")))
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
|
||||
// In this case, no success, the session is created and the node get destroyed.
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - login error`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val presenter = OidcPresenter(
|
||||
A_OIDC_DATA,
|
||||
authenticationService,
|
||||
)
|
||||
authenticationService.givenLoginError(A_THROWABLE)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.Success("A_URL")))
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
|
||||
val errorState = awaitItem()
|
||||
assertThat(errorState.requestState).isEqualTo(Async.Failure<Unit>(A_THROWABLE))
|
||||
errorState.eventSink.invoke(OidcEvents.ClearError)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.requestState).isEqualTo(Async.Uninitialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,18 @@ import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
|
||||
import io.element.android.features.login.impl.util.LoginConstants
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
|
||||
import io.element.android.libraries.matrix.test.A_PASSWORD
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
|
||||
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
@@ -34,23 +41,91 @@ class LoginRootPresenterTest {
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = LoginRootPresenter(
|
||||
FakeAuthenticationService(),
|
||||
DefaultOidcActionFlow(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.homeserverDetails).isEqualTo(A_HOMESERVER)
|
||||
assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
|
||||
assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
|
||||
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
||||
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
|
||||
assertThat(initialState.submitEnabled).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state server load`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val oidcActionFlow = DefaultOidcActionFlow()
|
||||
val presenter = LoginRootPresenter(
|
||||
authenticationService,
|
||||
oidcActionFlow,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
|
||||
assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
|
||||
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
||||
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
|
||||
assertThat(initialState.submitEnabled).isFalse()
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading<MatrixHomeServerDetails>())
|
||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||
skipItems(1)
|
||||
val loadedState = awaitItem()
|
||||
assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state server load error and retry`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val oidcActionFlow = DefaultOidcActionFlow()
|
||||
val presenter = LoginRootPresenter(
|
||||
authenticationService,
|
||||
oidcActionFlow,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
|
||||
assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
|
||||
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
||||
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
|
||||
assertThat(initialState.submitEnabled).isFalse()
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading<MatrixHomeServerDetails>())
|
||||
val aThrowable = Throwable("Error")
|
||||
authenticationService.givenChangeServerError(aThrowable)
|
||||
val errorState = awaitItem()
|
||||
assertThat(errorState.homeserverDetails).isEqualTo(Async.Failure<MatrixHomeServerDetails>(aThrowable))
|
||||
// Retry
|
||||
errorState.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo)
|
||||
val loadingState2 = awaitItem()
|
||||
assertThat(loadingState2.homeserverDetails).isEqualTo(Async.Loading<MatrixHomeServerDetails>())
|
||||
authenticationService.givenChangeServerError(null)
|
||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||
skipItems(1)
|
||||
val loadedState = awaitItem()
|
||||
assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - enter login and password`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val oidcActionFlow = DefaultOidcActionFlow()
|
||||
val presenter = LoginRootPresenter(
|
||||
FakeAuthenticationService(),
|
||||
authenticationService,
|
||||
oidcActionFlow,
|
||||
)
|
||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -67,10 +142,99 @@ class LoginRootPresenterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - submit`() = runTest {
|
||||
fun `present - oidc login`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val oidcActionFlow = DefaultOidcActionFlow()
|
||||
val presenter = LoginRootPresenter(
|
||||
FakeAuthenticationService(),
|
||||
authenticationService,
|
||||
oidcActionFlow,
|
||||
)
|
||||
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.submitEnabled).isTrue()
|
||||
initialState.eventSink.invoke(LoginRootEvents.Submit)
|
||||
val oidcState = awaitItem()
|
||||
assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.OidcStarted(A_OIDC_DATA))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - oidc login error`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val oidcActionFlow = DefaultOidcActionFlow()
|
||||
val presenter = LoginRootPresenter(
|
||||
authenticationService,
|
||||
oidcActionFlow,
|
||||
)
|
||||
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
|
||||
authenticationService.givenOidcError(A_THROWABLE)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.submitEnabled).isTrue()
|
||||
initialState.eventSink.invoke(LoginRootEvents.Submit)
|
||||
val oidcState = awaitItem()
|
||||
assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - oidc custom tab login`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val oidcActionFlow = DefaultOidcActionFlow()
|
||||
val presenter = LoginRootPresenter(
|
||||
authenticationService,
|
||||
oidcActionFlow,
|
||||
)
|
||||
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.submitEnabled).isTrue()
|
||||
initialState.eventSink.invoke(LoginRootEvents.Submit)
|
||||
val oidcState = awaitItem()
|
||||
assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.OidcStarted(A_OIDC_DATA))
|
||||
// Oidc cancel, sdk error
|
||||
authenticationService.givenOidcCancelError(A_THROWABLE)
|
||||
oidcActionFlow.post(OidcAction.GoBack)
|
||||
val stateCancelSdkError = awaitItem()
|
||||
assertThat(stateCancelSdkError.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
|
||||
// Oidc cancel, sdk OK
|
||||
authenticationService.givenOidcCancelError(null)
|
||||
oidcActionFlow.post(OidcAction.GoBack)
|
||||
val stateCancel = awaitItem()
|
||||
assertThat(stateCancel.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
||||
// Oidc success, sdk error
|
||||
authenticationService.givenLoginError(A_THROWABLE)
|
||||
oidcActionFlow.post(OidcAction.Success(A_OIDC_DATA.url))
|
||||
val stateSuccessSdkErrorLoading = awaitItem()
|
||||
assertThat(stateSuccessSdkErrorLoading.loggedInState).isEqualTo(LoggedInState.LoggingIn)
|
||||
val stateSuccessSdkError = awaitItem()
|
||||
assertThat(stateSuccessSdkError.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
|
||||
// Oidc success
|
||||
authenticationService.givenLoginError(null)
|
||||
oidcActionFlow.post(OidcAction.Success(A_OIDC_DATA.url))
|
||||
val stateSuccess = awaitItem()
|
||||
assertThat(stateSuccess.loggedInState).isEqualTo(LoggedInState.LoggingIn)
|
||||
val stateSuccessLoggedIn = awaitItem()
|
||||
assertThat(stateSuccessLoggedIn.loggedInState).isEqualTo(LoggedInState.LoggedIn(A_SESSION_ID))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - submit`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val oidcActionFlow = DefaultOidcActionFlow()
|
||||
val presenter = LoginRootPresenter(
|
||||
authenticationService,
|
||||
oidcActionFlow,
|
||||
)
|
||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -90,9 +254,12 @@ class LoginRootPresenterTest {
|
||||
@Test
|
||||
fun `present - submit with error`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val oidcActionFlow = DefaultOidcActionFlow()
|
||||
val presenter = LoginRootPresenter(
|
||||
authenticationService,
|
||||
oidcActionFlow,
|
||||
)
|
||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -113,14 +280,16 @@ class LoginRootPresenterTest {
|
||||
@Test
|
||||
fun `present - clear error`() = runTest {
|
||||
val authenticationService = FakeAuthenticationService()
|
||||
val oidcActionFlow = DefaultOidcActionFlow()
|
||||
val presenter = LoginRootPresenter(
|
||||
authenticationService,
|
||||
oidcActionFlow,
|
||||
)
|
||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
// Submit will return an error
|
||||
authenticationService.givenLoginError(A_THROWABLE)
|
||||
initialState.eventSink(LoginRootEvents.Submit)
|
||||
|
||||
@@ -18,6 +18,7 @@ lifecycle = "2.6.1"
|
||||
activity = "1.7.2"
|
||||
startup = "1.1.1"
|
||||
media3 = "1.0.2"
|
||||
browser = "1.5.0"
|
||||
|
||||
# Compose
|
||||
compose_bom = "2023.05.01"
|
||||
@@ -70,6 +71,7 @@ androidx_datastore_datastore = { module = "androidx.datastore:datastore", versio
|
||||
androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.6"
|
||||
androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
|
||||
androidx_recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
|
||||
androidx_browser = { module = "androidx.browser:browser", version.ref = "browser" }
|
||||
androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
|
||||
androidx_splash = "androidx.core:core-splashscreen:1.0.1"
|
||||
|
||||
@@ -31,6 +31,7 @@ dependencies {
|
||||
implementation(libs.dagger)
|
||||
implementation(libs.androidx.corektx)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.architecture)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components.async
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
@Composable
|
||||
fun AsyncFailure(
|
||||
throwable: Throwable,
|
||||
onRetry: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = throwable.message ?: "An error occurred")
|
||||
if (onRetry != null) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(onClick = onRetry) {
|
||||
Text(text = "Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun AsyncFailurePreviewLight() = ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun AsyncFailurePreviewDark() = ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
AsyncFailure(
|
||||
throwable = IllegalStateException("An error occurred"),
|
||||
onRetry = {}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components.async
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
|
||||
@Composable
|
||||
fun AsyncLoading(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun AsyncLoadingPreviewLight() = ElementPreviewLight { ContentToPreview() }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun AsyncLoadingPreviewDark() = ElementPreviewDark { ContentToPreview() }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview() {
|
||||
AsyncLoading()
|
||||
}
|
||||
@@ -22,4 +22,5 @@ sealed class AuthenticationException(message: String) : Exception(message) {
|
||||
class SlidingSyncNotAvailable(message: String) : AuthenticationException(message)
|
||||
class SessionMissing(message: String) : AuthenticationException(message)
|
||||
class Generic(message: String) : AuthenticationException(message)
|
||||
class OidcError(type: String, message: String) : AuthenticationException(message)
|
||||
}
|
||||
|
||||
@@ -28,4 +28,23 @@ interface MatrixAuthenticationService {
|
||||
fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?>
|
||||
suspend fun setHomeserver(homeserver: String): Result<Unit>
|
||||
suspend fun login(username: String, password: String): Result<SessionId>
|
||||
|
||||
/*
|
||||
* OIDC part.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the Oidc url to display to the user.
|
||||
*/
|
||||
suspend fun getOidcUrl(): Result<OidcDetails>
|
||||
|
||||
/**
|
||||
* Cancel Oidc login sequence.
|
||||
*/
|
||||
suspend fun cancelOidcLogin(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Attempt to login using the [callbackUrl] provided by the Oidc page.
|
||||
*/
|
||||
suspend fun loginWithOidc(callbackUrl: String): Result<SessionId>
|
||||
}
|
||||
|
||||
@@ -23,5 +23,5 @@ import kotlinx.parcelize.Parcelize
|
||||
data class MatrixHomeServerDetails(
|
||||
val url: String,
|
||||
val supportsPasswordLogin: Boolean,
|
||||
val authenticationIssuer: String?
|
||||
val supportsOidcLogin: Boolean,
|
||||
): Parcelable
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.auth
|
||||
|
||||
object OidcConfig {
|
||||
const val redirectUri = "io.element:/callback"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.auth
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class OidcDetails(
|
||||
val url: String,
|
||||
) : Parcelable
|
||||
@@ -26,6 +26,15 @@ fun Throwable.mapAuthenticationException(): Throwable {
|
||||
is RustAuthenticationException.InvalidServerName -> AuthenticationException.InvalidServerName(this.message!!)
|
||||
is RustAuthenticationException.SessionMissing -> AuthenticationException.SessionMissing(this.message!!)
|
||||
is RustAuthenticationException.SlidingSyncNotAvailable -> AuthenticationException.SlidingSyncNotAvailable(this.message!!)
|
||||
|
||||
/* TODO Oidc
|
||||
is RustAuthenticationException.OidcException -> AuthenticationException.OidcError("OidcException", message!!)
|
||||
is RustAuthenticationException.OidcMetadataInvalid -> AuthenticationException.OidcError("OidcMetadataInvalid", message!!)
|
||||
is RustAuthenticationException.OidcMetadataMissing -> AuthenticationException.OidcError("OidcMetadataMissing", message!!)
|
||||
is RustAuthenticationException.OidcNotStarted -> AuthenticationException.OidcError("OidcNotStarted", message!!)
|
||||
is RustAuthenticationException.OidcNotSupported -> AuthenticationException.OidcError("OidcNotSupported", message!!)
|
||||
*/
|
||||
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,6 @@ fun HomeserverLoginDetails.map(): MatrixHomeServerDetails = use {
|
||||
MatrixHomeServerDetails(
|
||||
url = url(),
|
||||
supportsPasswordLogin = supportsPasswordLogin(),
|
||||
authenticationIssuer = authenticationIssuer()
|
||||
supportsOidcLogin = false // TODO Oidc supportsOidcLogin(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.auth
|
||||
|
||||
import io.element.android.libraries.matrix.api.auth.OidcConfig
|
||||
// TODO Oidc
|
||||
// import org.matrix.rustcomponents.sdk.OidcClientMetadata
|
||||
|
||||
/*
|
||||
val oidcClientMetadata: OidcClientMetadata = OidcClientMetadata(
|
||||
clientName = "Element",
|
||||
redirectUri = OidcConfig.redirectUri,
|
||||
clientUri = "https://element.io",
|
||||
tosUri = "https://element.io/user-terms-of-service",
|
||||
policyUri = "https://element.io/privacy"
|
||||
)
|
||||
*/
|
||||
|
||||
@@ -24,8 +24,8 @@ import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.impl.RustMatrixClient
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
@@ -36,6 +36,8 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.ClientBuilder
|
||||
// TODO Oidc
|
||||
// import org.matrix.rustcomponents.sdk.OidcAuthenticationUrl
|
||||
import org.matrix.rustcomponents.sdk.Session
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import java.io.File
|
||||
@@ -51,7 +53,13 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||
private val sessionStore: SessionStore,
|
||||
) : MatrixAuthenticationService {
|
||||
|
||||
private val authService: RustAuthenticationService = RustAuthenticationService(baseDirectory.absolutePath, null, null)
|
||||
private val authService: RustAuthenticationService = RustAuthenticationService(
|
||||
basePath = baseDirectory.absolutePath,
|
||||
passphrase = null,
|
||||
// TODO Oidc
|
||||
// oidcClientMetadata = oidcClientMetadata,
|
||||
customSlidingSyncProxy = null
|
||||
)
|
||||
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
||||
|
||||
override fun isLoggedIn(): Flow<Boolean> {
|
||||
@@ -91,9 +99,9 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||
if (homeServerDetails != null) {
|
||||
currentHomeserver.value = homeServerDetails.copy(url = homeserver)
|
||||
}
|
||||
}.mapFailure { failure ->
|
||||
failure.mapAuthenticationException()
|
||||
}
|
||||
}.mapFailure { failure ->
|
||||
failure.mapAuthenticationException()
|
||||
}
|
||||
|
||||
override suspend fun login(username: String, password: String): Result<SessionId> =
|
||||
@@ -103,11 +111,65 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||
val sessionData = client.use { it.session().toSessionData() }
|
||||
sessionStore.storeData(sessionData)
|
||||
SessionId(sessionData.userId)
|
||||
}.mapFailure { failure ->
|
||||
failure.mapAuthenticationException()
|
||||
}
|
||||
}.mapFailure { failure ->
|
||||
failure.mapAuthenticationException()
|
||||
}
|
||||
|
||||
// TODO Oidc
|
||||
// private var pendingUrlForOidcLogin: OidcAuthenticationUrl? = null
|
||||
|
||||
override suspend fun getOidcUrl(): Result<OidcDetails> {
|
||||
TODO("Oidc")
|
||||
/*
|
||||
return withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
val urlForOidcLogin = authService.urlForOidcLogin()
|
||||
val url = urlForOidcLogin.loginUrl()
|
||||
pendingUrlForOidcLogin = urlForOidcLogin
|
||||
OidcDetails(url)
|
||||
}.mapFailure { failure ->
|
||||
failure.mapAuthenticationException()
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
override suspend fun cancelOidcLogin(): Result<Unit> {
|
||||
TODO("Oidc")
|
||||
/*
|
||||
return withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
pendingUrlForOidcLogin?.close()
|
||||
pendingUrlForOidcLogin = null
|
||||
}.mapFailure { failure ->
|
||||
failure.mapAuthenticationException()
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters).
|
||||
*/
|
||||
override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> {
|
||||
TODO("Oidc")
|
||||
/*
|
||||
return withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
val urlForOidcLogin = pendingUrlForOidcLogin ?: error("You need to call `getOidcUrl()` first")
|
||||
val client = authService.loginWithOidcCallback(urlForOidcLogin, callbackUrl)
|
||||
val sessionData = client.use { it.session().toSessionData() }
|
||||
pendingUrlForOidcLogin = null
|
||||
sessionStore.storeData(sessionData)
|
||||
SessionId(sessionData.userId)
|
||||
}.mapFailure { failure ->
|
||||
failure.mapAuthenticationException()
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
private fun createMatrixClient(client: Client): MatrixClient {
|
||||
return RustMatrixClient(
|
||||
client = client,
|
||||
|
||||
@@ -47,7 +47,8 @@ const val ANOTHER_MESSAGE = "Hello universe!"
|
||||
const val A_HOMESERVER_URL = "matrix.org"
|
||||
const val A_HOMESERVER_URL_2 = "matrix-client.org"
|
||||
|
||||
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, true, null)
|
||||
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidcLogin = false)
|
||||
val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true)
|
||||
|
||||
const val AN_AVATAR_URL = "mxc://data"
|
||||
|
||||
|
||||
@@ -19,16 +19,22 @@ package io.element.android.libraries.matrix.test.auth
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
val A_OIDC_DATA = OidcDetails(url = "a-url")
|
||||
|
||||
class FakeAuthenticationService : MatrixAuthenticationService {
|
||||
private var homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
||||
private var oidcError: Throwable? = null
|
||||
private var oidcCancelError: Throwable? = null
|
||||
private var loginError: Throwable? = null
|
||||
private var changeServerError: Throwable? = null
|
||||
|
||||
@@ -53,15 +59,36 @@ class FakeAuthenticationService : MatrixAuthenticationService {
|
||||
}
|
||||
|
||||
override suspend fun setHomeserver(homeserver: String): Result<Unit> {
|
||||
delay(100)
|
||||
delay(FAKE_DELAY_IN_MS)
|
||||
return changeServerError?.let { Result.failure(it) } ?: Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun login(username: String, password: String): Result<SessionId> {
|
||||
delay(100)
|
||||
delay(FAKE_DELAY_IN_MS)
|
||||
return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
|
||||
}
|
||||
|
||||
override suspend fun getOidcUrl(): Result<OidcDetails> {
|
||||
return oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA)
|
||||
}
|
||||
|
||||
override suspend fun cancelOidcLogin(): Result<Unit> {
|
||||
return oidcCancelError?.let { Result.failure(it) } ?: Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> {
|
||||
delay(FAKE_DELAY_IN_MS)
|
||||
return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
|
||||
}
|
||||
|
||||
fun givenOidcError(throwable: Throwable?) {
|
||||
oidcError = throwable
|
||||
}
|
||||
|
||||
fun givenOidcCancelError(throwable: Throwable?) {
|
||||
oidcCancelError = throwable
|
||||
}
|
||||
|
||||
fun givenLoginError(throwable: Throwable?) {
|
||||
loginError = throwable
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ package io.element.android.samples.minimal
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
|
||||
import io.element.android.features.login.impl.root.LoginRootPresenter
|
||||
import io.element.android.features.login.impl.root.LoginRootView
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
@@ -28,7 +29,10 @@ class LoginScreen(private val authenticationService: MatrixAuthenticationService
|
||||
@Composable
|
||||
fun Content(modifier: Modifier = Modifier) {
|
||||
val presenter = remember {
|
||||
LoginRootPresenter(authenticationService = authenticationService)
|
||||
LoginRootPresenter(
|
||||
authenticationService = authenticationService,
|
||||
DefaultOidcActionFlow()
|
||||
)
|
||||
}
|
||||
val state = presenter.present()
|
||||
LoginRootView(
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
24
tools/adb/oidc.sh
Executable file
24
tools/adb/oidc.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#! /bin/bash
|
||||
#
|
||||
# Copyright (c) 2023 New Vector Ltd
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
# Format is:
|
||||
|
||||
# Error
|
||||
# adb shell am start -a android.intent.action.VIEW -d "io.element:/callback?error=access_denied\\&state=IFF1UETGye2ZA8pO"
|
||||
|
||||
# Success
|
||||
adb shell am start -a android.intent.action.VIEW -d "io.element:/callback?state=IFF1UETGye2ZA8pO\\&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
|
||||
Reference in New Issue
Block a user