Merge pull request #347 from vector-im/feature/bma/oidc2

Add (disabled) Oidc support
This commit is contained in:
Benoit Marty 2023-05-31 16:42:34 +02:00 committed by GitHub
commit 0f27ad5a84
74 changed files with 2007 additions and 74 deletions

View file

@ -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

View file

@ -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))

View file

@ -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
View 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

View file

@ -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
}

View file

@ -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)
}

View file

@ -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?
}

View file

@ -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)

View 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>

View file

@ -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,

View file

@ -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
}
}

View file

@ -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())
}
}

View file

@ -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")
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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,
)
}
}

View file

@ -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
)
}
}

View file

@ -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
)

View file

@ -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",
)

View file

@ -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 = { },
)
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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
) )
} }
} }

View file

@ -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()
}
} }

View file

@ -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
} }

View file

@ -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 = {}

View file

@ -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)
)
} }
} }

View file

@ -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"
} }

View file

@ -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()
} }
} }

View file

@ -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))
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)

View file

@ -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"

View file

@ -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)

View file

@ -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 = {}
)
}

View file

@ -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()
}

View file

@ -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)
} }

View file

@ -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>
} }

View file

@ -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

View file

@ -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"
}

View file

@ -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

View file

@ -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
} }
} }

View file

@ -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(),
) )
} }

View file

@ -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"
)
*/

View file

@ -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,

View file

@ -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"

View file

@ -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
} }

View file

@ -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(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

24
tools/adb/oidc.sh Executable file
View 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"