Merge pull request #347 from vector-im/feature/bma/oidc2
Add (disabled) Oidc support
This commit is contained in:
commit
0f27ad5a84
74 changed files with 2007 additions and 74 deletions
|
|
@ -33,7 +33,7 @@
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleInstance"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/Theme.ElementX.Splash"
|
android:theme="@style/Theme.ElementX.Splash"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|
@ -49,6 +49,14 @@
|
||||||
android:host="open"
|
android:host="open"
|
||||||
android:scheme="elementx" />
|
android:scheme="elementx" />
|
||||||
</intent-filter-->
|
</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>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,17 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.appnav.di.MatrixClientsHolder
|
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.RootPresenter
|
||||||
import io.element.android.appnav.root.RootView
|
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.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.deeplink.DeeplinkData
|
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.designsystem.theme.components.CircularProgressIndicator
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
|
@ -65,7 +68,8 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
private val matrixClientsHolder: MatrixClientsHolder,
|
private val matrixClientsHolder: MatrixClientsHolder,
|
||||||
private val presenter: RootPresenter,
|
private val presenter: RootPresenter,
|
||||||
private val bugReportEntryPoint: BugReportEntryPoint,
|
private val bugReportEntryPoint: BugReportEntryPoint,
|
||||||
private val deeplinkParser: DeeplinkParser,
|
private val intentResolver: IntentResolver,
|
||||||
|
private val oidcActionFlow: OidcActionFlow,
|
||||||
) :
|
) :
|
||||||
BackstackNode<RootFlowNode.NavTarget>(
|
BackstackNode<RootFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
|
|
@ -204,8 +208,11 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun handleIntent(intent: Intent) {
|
suspend fun handleIntent(intent: Intent) {
|
||||||
deeplinkParser.getFromIntent(intent)
|
val resolvedIntent = intentResolver.resolve(intent) ?: return
|
||||||
?.let { navigateTo(it) }
|
when (resolvedIntent) {
|
||||||
|
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
|
||||||
|
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
|
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 {
|
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
|
||||||
return attachChild {
|
return attachChild {
|
||||||
backstack.newRoot(NavTarget.LoggedInFlow(sessionId))
|
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.elementresources)
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
|
implementation(libs.androidx.browser)
|
||||||
api(projects.features.login.api)
|
api(projects.features.login.api)
|
||||||
ksp(libs.showkase.processor)
|
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
|
package io.element.android.features.login.impl
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.bumble.appyx.core.composable.Children
|
import com.bumble.appyx.core.composable.Children
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
|
|
@ -29,17 +33,24 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.features.login.impl.changeserver.ChangeServerNode
|
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.features.login.impl.root.LoginRootNode
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.createNode
|
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.di.AppScope
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
class LoginFlowNode @AssistedInject constructor(
|
class LoginFlowNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
|
||||||
|
private val customTabHandler: CustomTabHandler,
|
||||||
) : BackstackNode<LoginFlowNode.NavTarget>(
|
) : BackstackNode<LoginFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Root,
|
initialElement = NavTarget.Root,
|
||||||
|
|
@ -48,6 +59,8 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
) {
|
) {
|
||||||
|
private var activity: Activity? = null
|
||||||
|
private var darkTheme: Boolean = false
|
||||||
|
|
||||||
sealed interface NavTarget : Parcelable {
|
sealed interface NavTarget : Parcelable {
|
||||||
@Parcelize
|
@Parcelize
|
||||||
|
|
@ -55,6 +68,9 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object ChangeServer : NavTarget
|
object ChangeServer : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
@ -64,15 +80,37 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
override fun onChangeHomeServer() {
|
override fun onChangeHomeServer() {
|
||||||
backstack.push(NavTarget.ChangeServer)
|
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))
|
createNode<LoginRootNode>(buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
|
|
||||||
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
|
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
|
||||||
|
is NavTarget.OidcView -> {
|
||||||
|
val input = OidcNode.Inputs(navTarget.oidcDetails)
|
||||||
|
createNode<OidcNode>(buildContext, plugins = listOf(input))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
|
activity = LocalContext.current as? Activity
|
||||||
|
darkTheme = !ElementTheme.colors.isLight
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
activity = null
|
||||||
|
}
|
||||||
|
}
|
||||||
Children(
|
Children(
|
||||||
navModel = backstack,
|
navModel = backstack,
|
||||||
modifier = modifier,
|
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
|
package io.element.android.features.login.impl.root
|
||||||
|
|
||||||
sealed interface LoginRootEvents {
|
sealed interface LoginRootEvents {
|
||||||
|
object RetryFetchServerInfo : LoginRootEvents
|
||||||
data class SetLogin(val login: String) : LoginRootEvents
|
data class SetLogin(val login: String) : LoginRootEvents
|
||||||
data class SetPassword(val password: String) : LoginRootEvents
|
data class SetPassword(val password: String) : LoginRootEvents
|
||||||
object Submit : LoginRootEvents
|
object Submit : LoginRootEvents
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
class LoginRootNode @AssistedInject constructor(
|
class LoginRootNode @AssistedInject constructor(
|
||||||
|
|
@ -36,20 +37,26 @@ class LoginRootNode @AssistedInject constructor(
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onChangeHomeServer()
|
fun onChangeHomeServer()
|
||||||
|
fun onOidcDetails(oidcDetails: OidcDetails)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onChangeHomeServer() {
|
private fun onChangeHomeServer() {
|
||||||
plugins<Callback>().forEach { it.onChangeHomeServer() }
|
plugins<Callback>().forEach { it.onChangeHomeServer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onOidcDetails(oidcDetails: OidcDetails) {
|
||||||
|
plugins<Callback>().forEach { it.onOidcDetails(oidcDetails) }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
LoginRootView(
|
LoginRootView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onChangeServer = this::onChangeHomeServer,
|
onChangeServer = ::onChangeHomeServer,
|
||||||
onBackPressed = this::navigateUp
|
onOidcDetails = ::onOidcDetails,
|
||||||
|
onBackPressed = ::navigateUp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,28 +17,49 @@
|
||||||
package io.element.android.features.login.impl.root
|
package io.element.android.features.login.impl.root
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
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.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.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.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<LoginRootState> {
|
class LoginRootPresenter @Inject constructor(
|
||||||
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
private val defaultHomeserver = MatrixHomeServerDetails(LoginConstants.DEFAULT_HOMESERVER_URL, true, null)
|
private val defaultOidcActionFlow: DefaultOidcActionFlow,
|
||||||
|
) : Presenter<LoginRootState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): LoginRootState {
|
override fun present(): LoginRootState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
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 {
|
val loggedInState: MutableState<LoggedInState> = remember {
|
||||||
mutableStateOf(LoggedInState.NotLoggedIn)
|
mutableStateOf(LoggedInState.NotLoggedIn)
|
||||||
}
|
}
|
||||||
|
|
@ -46,31 +67,69 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
|
||||||
mutableStateOf(LoginFormState.Default)
|
mutableStateOf(LoginFormState.Default)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
launch {
|
||||||
|
defaultOidcActionFlow.collect {
|
||||||
|
onOidcAction(it, loggedInState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: LoginRootEvents) {
|
fun handleEvents(event: LoginRootEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
LoginRootEvents.RetryFetchServerInfo -> localCoroutineScope.getHomeServerDetails(homeserver, getHomeServerDetailsAction)
|
||||||
is LoginRootEvents.SetLogin -> updateFormState(formState) {
|
is LoginRootEvents.SetLogin -> updateFormState(formState) {
|
||||||
copy(login = event.login)
|
copy(login = event.login)
|
||||||
}
|
}
|
||||||
is LoginRootEvents.SetPassword -> updateFormState(formState) {
|
is LoginRootEvents.SetPassword -> updateFormState(formState) {
|
||||||
copy(password = event.password)
|
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
|
LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return LoginRootState(
|
return LoginRootState(
|
||||||
homeserverDetails = homeserver,
|
homeserverUrl = homeserver,
|
||||||
|
homeserverDetails = getHomeServerDetailsAction.value,
|
||||||
loggedInState = loggedInState.value,
|
loggedInState = loggedInState.value,
|
||||||
formState = formState.value,
|
formState = formState.value,
|
||||||
eventSink = ::handleEvents
|
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
|
loggedInState.value = LoggedInState.LoggingIn
|
||||||
//TODO rework the setHomeserver flow
|
|
||||||
authenticationService.setHomeserver(homeserver)
|
|
||||||
authenticationService.login(formState.login.trim(), formState.password)
|
authenticationService.login(formState.login.trim(), formState.password)
|
||||||
.onSuccess { sessionId ->
|
.onSuccess { sessionId ->
|
||||||
loggedInState.value = LoggedInState.LoggedIn(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) {
|
private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
|
||||||
formState.value = updateLambda(formState.value)
|
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
|
package io.element.android.features.login.impl.root
|
||||||
|
|
||||||
import android.os.Parcelable
|
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.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.SessionId
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
data class LoginRootState(
|
data class LoginRootState(
|
||||||
val homeserverDetails: MatrixHomeServerDetails,
|
val homeserverUrl: String,
|
||||||
|
val homeserverDetails: Async<MatrixHomeServerDetails>,
|
||||||
val loggedInState: LoggedInState,
|
val loggedInState: LoggedInState,
|
||||||
val formState: LoginFormState,
|
val formState: LoginFormState,
|
||||||
val eventSink: (LoginRootEvents) -> Unit
|
val eventSink: (LoginRootEvents) -> Unit
|
||||||
) {
|
) {
|
||||||
val submitEnabled: Boolean get() =
|
val supportPasswordLogin = (homeserverDetails as? Async.Success)?.state?.supportsPasswordLogin.orFalse()
|
||||||
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInState !is LoggedInState.ErrorLoggingIn
|
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 {
|
sealed interface LoggedInState {
|
||||||
object NotLoggedIn : LoggedInState
|
object NotLoggedIn : LoggedInState
|
||||||
object LoggingIn : LoggedInState
|
object LoggingIn : LoggedInState
|
||||||
|
data class OidcStarted(val oidcDetail: OidcDetails) : LoggedInState
|
||||||
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
|
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
|
||||||
data class LoggedIn(val sessionId: SessionId) : LoggedInState
|
data class LoggedIn(val sessionId: SessionId) : LoggedInState
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.login.impl.root
|
package io.element.android.features.login.impl.root
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
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.auth.MatrixHomeServerDetails
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
|
|
@ -24,16 +25,51 @@ open class LoginRootStateProvider : PreviewParameterProvider<LoginRootState> {
|
||||||
override val values: Sequence<LoginRootState>
|
override val values: Sequence<LoginRootState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aLoginRootState(),
|
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")),
|
||||||
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn),
|
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.ErrorLoggingIn(Throwable())),
|
||||||
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))),
|
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(
|
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,
|
loggedInState = LoggedInState.NotLoggedIn,
|
||||||
formState = LoginFormState.Default,
|
formState = LoginFormState.Default,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.error.loginError
|
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.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.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
|
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
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.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.theme.components.autofill
|
import io.element.android.libraries.designsystem.theme.components.autofill
|
||||||
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
|
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.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
@ -94,7 +97,7 @@ fun LoginRootView(
|
||||||
state: LoginRootState,
|
state: LoginRootState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onChangeServer: () -> Unit = {},
|
onChangeServer: () -> Unit = {},
|
||||||
onLoginWithSuccess: (SessionId) -> Unit = {},
|
onOidcDetails: (OidcDetails) -> Unit = {},
|
||||||
onBackPressed: () -> Unit,
|
onBackPressed: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val isLoading by remember(state.loggedInState) {
|
val isLoading by remember(state.loggedInState) {
|
||||||
|
|
@ -102,6 +105,15 @@ fun LoginRootView(
|
||||||
state.loggedInState == LoggedInState.LoggingIn
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -137,19 +149,26 @@ fun LoginRootView(
|
||||||
|
|
||||||
ChangeServerSection(
|
ChangeServerSection(
|
||||||
interactionEnabled = !isLoading,
|
interactionEnabled = !isLoading,
|
||||||
homeserver = state.homeserverDetails.url,
|
homeserver = state.homeserverUrl,
|
||||||
onChangeServer = onChangeServer
|
onChangeServer = onChangeServer
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
LoginForm(state = state, isLoading = isLoading)
|
when (state.homeserverDetails) {
|
||||||
|
Async.Uninitialized,
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
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) {
|
when (val loggedInState = state.loggedInState) {
|
||||||
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
|
is LoggedInState.OidcStarted -> onOidcDetails(loggedInState.oidcDetail)
|
||||||
else -> Unit
|
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
|
@Composable
|
||||||
internal fun ChangeServerSection(
|
internal fun ChangeServerSection(
|
||||||
interactionEnabled: Boolean,
|
interactionEnabled: Boolean,
|
||||||
|
|
@ -217,6 +273,7 @@ internal fun ChangeServerSection(
|
||||||
internal fun LoginForm(
|
internal fun LoginForm(
|
||||||
state: LoginRootState,
|
state: LoginRootState,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
|
onSubmit: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var loginFieldState by textFieldState(stateValue = state.formState.login)
|
var loginFieldState by textFieldState(stateValue = state.formState.login)
|
||||||
|
|
@ -225,13 +282,6 @@ internal fun LoginForm(
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val eventSink = state.eventSink
|
val eventSink = state.eventSink
|
||||||
|
|
||||||
fun submit() {
|
|
||||||
// Clear focus to prevent keyboard issues with textfields
|
|
||||||
focusManager.clearFocus(force = true)
|
|
||||||
|
|
||||||
eventSink(LoginRootEvents.Submit)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.screen_login_form_header),
|
text = stringResource(R.string.screen_login_form_header),
|
||||||
|
|
@ -318,23 +368,11 @@ internal fun LoginForm(
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
),
|
),
|
||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(
|
||||||
onDone = { submit() }
|
onDone = { onSubmit() }
|
||||||
),
|
),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
maxLines = 1,
|
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 {
|
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"
|
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.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.architecture.Async
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
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_HOMESERVER_URL_2
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||||
|
|
@ -39,7 +39,7 @@ class ChangeServerPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER_URL)
|
assertThat(initialState.homeserver).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
|
||||||
assertThat(initialState.submitEnabled).isTrue()
|
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.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
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
|
||||||
|
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_PASSWORD
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
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_THROWABLE
|
||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
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 io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -34,23 +41,91 @@ class LoginRootPresenterTest {
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val presenter = LoginRootPresenter(
|
val presenter = LoginRootPresenter(
|
||||||
FakeAuthenticationService(),
|
FakeAuthenticationService(),
|
||||||
|
DefaultOidcActionFlow(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
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.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
||||||
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
|
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
|
||||||
assertThat(initialState.submitEnabled).isFalse()
|
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
|
@Test
|
||||||
fun `present - enter login and password`() = runTest {
|
fun `present - enter login and password`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val oidcActionFlow = DefaultOidcActionFlow()
|
||||||
val presenter = LoginRootPresenter(
|
val presenter = LoginRootPresenter(
|
||||||
FakeAuthenticationService(),
|
authenticationService,
|
||||||
|
oidcActionFlow,
|
||||||
)
|
)
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -67,10 +142,99 @@ class LoginRootPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - submit`() = runTest {
|
fun `present - oidc login`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val oidcActionFlow = DefaultOidcActionFlow()
|
||||||
val presenter = LoginRootPresenter(
|
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) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -90,9 +254,12 @@ class LoginRootPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - submit with error`() = runTest {
|
fun `present - submit with error`() = runTest {
|
||||||
val authenticationService = FakeAuthenticationService()
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val oidcActionFlow = DefaultOidcActionFlow()
|
||||||
val presenter = LoginRootPresenter(
|
val presenter = LoginRootPresenter(
|
||||||
authenticationService,
|
authenticationService,
|
||||||
|
oidcActionFlow,
|
||||||
)
|
)
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -113,14 +280,16 @@ class LoginRootPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - clear error`() = runTest {
|
fun `present - clear error`() = runTest {
|
||||||
val authenticationService = FakeAuthenticationService()
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val oidcActionFlow = DefaultOidcActionFlow()
|
||||||
val presenter = LoginRootPresenter(
|
val presenter = LoginRootPresenter(
|
||||||
authenticationService,
|
authenticationService,
|
||||||
|
oidcActionFlow,
|
||||||
)
|
)
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
||||||
// Submit will return an error
|
// Submit will return an error
|
||||||
authenticationService.givenLoginError(A_THROWABLE)
|
authenticationService.givenLoginError(A_THROWABLE)
|
||||||
initialState.eventSink(LoginRootEvents.Submit)
|
initialState.eventSink(LoginRootEvents.Submit)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ lifecycle = "2.6.1"
|
||||||
activity = "1.7.2"
|
activity = "1.7.2"
|
||||||
startup = "1.1.1"
|
startup = "1.1.1"
|
||||||
media3 = "1.0.2"
|
media3 = "1.0.2"
|
||||||
|
browser = "1.5.0"
|
||||||
|
|
||||||
# Compose
|
# Compose
|
||||||
compose_bom = "2023.05.01"
|
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_exifinterface = "androidx.exifinterface:exifinterface:1.3.6"
|
||||||
androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
|
androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
|
||||||
androidx_recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
|
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_runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
|
androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
|
||||||
androidx_splash = "androidx.core:core-splashscreen:1.0.1"
|
androidx_splash = "androidx.core:core-splashscreen:1.0.1"
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ dependencies {
|
||||||
implementation(libs.dagger)
|
implementation(libs.dagger)
|
||||||
implementation(libs.androidx.corektx)
|
implementation(libs.androidx.corektx)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.architecture)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.test.truth)
|
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 SlidingSyncNotAvailable(message: String) : AuthenticationException(message)
|
||||||
class SessionMissing(message: String) : AuthenticationException(message)
|
class SessionMissing(message: String) : AuthenticationException(message)
|
||||||
class Generic(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?>
|
fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?>
|
||||||
suspend fun setHomeserver(homeserver: String): Result<Unit>
|
suspend fun setHomeserver(homeserver: String): Result<Unit>
|
||||||
suspend fun login(username: String, password: String): Result<SessionId>
|
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(
|
data class MatrixHomeServerDetails(
|
||||||
val url: String,
|
val url: String,
|
||||||
val supportsPasswordLogin: Boolean,
|
val supportsPasswordLogin: Boolean,
|
||||||
val authenticationIssuer: String?
|
val supportsOidcLogin: Boolean,
|
||||||
): Parcelable
|
): 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.InvalidServerName -> AuthenticationException.InvalidServerName(this.message!!)
|
||||||
is RustAuthenticationException.SessionMissing -> AuthenticationException.SessionMissing(this.message!!)
|
is RustAuthenticationException.SessionMissing -> AuthenticationException.SessionMissing(this.message!!)
|
||||||
is RustAuthenticationException.SlidingSyncNotAvailable -> AuthenticationException.SlidingSyncNotAvailable(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
|
else -> this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,6 @@ fun HomeserverLoginDetails.map(): MatrixHomeServerDetails = use {
|
||||||
MatrixHomeServerDetails(
|
MatrixHomeServerDetails(
|
||||||
url = url(),
|
url = url(),
|
||||||
supportsPasswordLogin = supportsPasswordLogin(),
|
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.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
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.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.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
|
||||||
import io.element.android.libraries.matrix.impl.RustMatrixClient
|
import io.element.android.libraries.matrix.impl.RustMatrixClient
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
|
|
@ -36,6 +36,8 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.matrix.rustcomponents.sdk.Client
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
import org.matrix.rustcomponents.sdk.ClientBuilder
|
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.Session
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -51,7 +53,13 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
) : MatrixAuthenticationService {
|
) : 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)
|
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
||||||
|
|
||||||
override fun isLoggedIn(): Flow<Boolean> {
|
override fun isLoggedIn(): Flow<Boolean> {
|
||||||
|
|
@ -91,9 +99,9 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
if (homeServerDetails != null) {
|
if (homeServerDetails != null) {
|
||||||
currentHomeserver.value = homeServerDetails.copy(url = homeserver)
|
currentHomeserver.value = homeServerDetails.copy(url = homeserver)
|
||||||
}
|
}
|
||||||
|
}.mapFailure { failure ->
|
||||||
|
failure.mapAuthenticationException()
|
||||||
}
|
}
|
||||||
}.mapFailure { failure ->
|
|
||||||
failure.mapAuthenticationException()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun login(username: String, password: String): Result<SessionId> =
|
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() }
|
val sessionData = client.use { it.session().toSessionData() }
|
||||||
sessionStore.storeData(sessionData)
|
sessionStore.storeData(sessionData)
|
||||||
SessionId(sessionData.userId)
|
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 {
|
private fun createMatrixClient(client: Client): MatrixClient {
|
||||||
return RustMatrixClient(
|
return RustMatrixClient(
|
||||||
client = client,
|
client = client,
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,8 @@ const val ANOTHER_MESSAGE = "Hello universe!"
|
||||||
const val A_HOMESERVER_URL = "matrix.org"
|
const val A_HOMESERVER_URL = "matrix.org"
|
||||||
const val A_HOMESERVER_URL_2 = "matrix-client.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"
|
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.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
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.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.SessionId
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
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.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
||||||
|
val A_OIDC_DATA = OidcDetails(url = "a-url")
|
||||||
|
|
||||||
class FakeAuthenticationService : MatrixAuthenticationService {
|
class FakeAuthenticationService : MatrixAuthenticationService {
|
||||||
private var homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
private var homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
||||||
|
private var oidcError: Throwable? = null
|
||||||
|
private var oidcCancelError: Throwable? = null
|
||||||
private var loginError: Throwable? = null
|
private var loginError: Throwable? = null
|
||||||
private var changeServerError: Throwable? = null
|
private var changeServerError: Throwable? = null
|
||||||
|
|
||||||
|
|
@ -53,15 +59,36 @@ class FakeAuthenticationService : MatrixAuthenticationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setHomeserver(homeserver: String): Result<Unit> {
|
override suspend fun setHomeserver(homeserver: String): Result<Unit> {
|
||||||
delay(100)
|
delay(FAKE_DELAY_IN_MS)
|
||||||
return changeServerError?.let { Result.failure(it) } ?: Result.success(Unit)
|
return changeServerError?.let { Result.failure(it) } ?: Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun login(username: String, password: String): Result<SessionId> {
|
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)
|
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?) {
|
fun givenLoginError(throwable: Throwable?) {
|
||||||
loginError = throwable
|
loginError = throwable
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ package io.element.android.samples.minimal
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
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.LoginRootPresenter
|
||||||
import io.element.android.features.login.impl.root.LoginRootView
|
import io.element.android.features.login.impl.root.LoginRootView
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
|
@ -28,7 +29,10 @@ class LoginScreen(private val authenticationService: MatrixAuthenticationService
|
||||||
@Composable
|
@Composable
|
||||||
fun Content(modifier: Modifier = Modifier) {
|
fun Content(modifier: Modifier = Modifier) {
|
||||||
val presenter = remember {
|
val presenter = remember {
|
||||||
LoginRootPresenter(authenticationService = authenticationService)
|
LoginRootPresenter(
|
||||||
|
authenticationService = authenticationService,
|
||||||
|
DefaultOidcActionFlow()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
LoginRootView(
|
LoginRootView(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:49e2768b2a111af737a30038913481113f759a20d3cf5df0166964c75926ec1f
|
||||||
|
size 6545
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:eac9030993d92ef7f57ae5e622d566cd88555a60c462c0299f2a16a0c3e5daa8
|
||||||
|
size 6853
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:49e2768b2a111af737a30038913481113f759a20d3cf5df0166964c75926ec1f
|
||||||
|
size 6545
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:74a1a3ac6ae62c3573ce5834f5ae17952049ee77a8be76bfcb99e78b8fc6cb97
|
||||||
|
size 6675
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:49e2768b2a111af737a30038913481113f759a20d3cf5df0166964c75926ec1f
|
||||||
|
size 6545
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:eac9030993d92ef7f57ae5e622d566cd88555a60c462c0299f2a16a0c3e5daa8
|
||||||
|
size 6853
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:49e2768b2a111af737a30038913481113f759a20d3cf5df0166964c75926ec1f
|
||||||
|
size 6545
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:74a1a3ac6ae62c3573ce5834f5ae17952049ee77a8be76bfcb99e78b8fc6cb97
|
||||||
|
size 6675
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9d4aefe56727b0752db5a45f3e4444be134ef0ae1dcbc833acd9c914e726421b
|
oid sha256:db157015f3b440738db961d5152c693383457562158563fb2beb8ebb6e41feba
|
||||||
size 34253
|
size 31570
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:03c71b78e0e64989d64526a245efbf7881a47c5d270e754811575e36d3520f33
|
||||||
|
size 25303
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:670769b82c2405be6b8360cf36ebab06551f6088899e9e0dc1e63d4102c904cd
|
||||||
|
size 24543
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:42121a3c5151b222724a05bbd6d2c6b624d5211a9365f4c7a2de21a31f0650de
|
||||||
|
size 20264
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ccf9cbb300a4d0c45c4be54124e6e506ca1f53e7fa9c33db3544ab85110d3d35
|
||||||
|
size 26052
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:65aad7d493d9bae55dda4ca2783c0f0a8ed5fbcf67835957b6ede1e8f2d8293e
|
oid sha256:e2ef182ba3721fa37c014dff57b49cbb7ee23d6becbc4ee60d81d44aff11a198
|
||||||
size 33210
|
size 30523
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5cda5716e850b31997b49dafb975112b3a0578391cb39044ca0057de6379530f
|
||||||
|
size 24636
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e338c8c2d3f89f3b7fdeafc5afffb36399855155e2b85231349e13e381e4cf1f
|
||||||
|
size 23617
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:dea12dfe1c1799b1d2d765214b1bd911154a19a7794bf37c6663d179cec76c81
|
||||||
|
size 19556
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1dc7d39a9b4e0f3eee8157b0290146b6bae5e6d0ea280296ddfc7051f31b95df
|
||||||
|
size 24786
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ee096c4234d5f983e84113058518af756ff03696ed5a733ec7540a842ff59ba4
|
||||||
|
size 11435
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2e2f7614737130cb1a23105faab7e46b39580eadcd05b6d7f811ddef525bef11
|
||||||
|
size 10640
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:226b5056efcf7371c76729caf4105d94f1d945f40f1d6715d538ed19154ea0f0
|
||||||
|
size 4971
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:59580eab9625ce67761ab8674f2adb0d9e4f0d2b27bbc132f60ae021828fd558
|
||||||
|
size 4644
|
||||||
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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue