Merge remote-tracking branch 'origin/develop' into misc/cjs/create-join-design-feedback
This commit is contained in:
commit
fb147f78b0
200 changed files with 3116 additions and 353 deletions
8
.github/workflows/quality.yml
vendored
8
.github/workflows/quality.yml
vendored
|
|
@ -12,6 +12,14 @@ env:
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
checkScript:
|
||||||
|
name: Search for forbidden patterns
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Run code quality check suite
|
||||||
|
run: ./tools/check/check_code_quality.sh
|
||||||
|
|
||||||
check:
|
check:
|
||||||
name: Project Check Suite
|
name: Project Check Suite
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -48,7 +48,6 @@ captures/
|
||||||
.idea/navEditor.xml
|
.idea/navEditor.xml
|
||||||
.idea/tasks.xml
|
.idea/tasks.xml
|
||||||
.idea/workspace.xml
|
.idea/workspace.xml
|
||||||
.idea/dictionaries
|
|
||||||
.idea/libraries
|
.idea/libraries
|
||||||
# Android Studio 3 in .gitignore file.
|
# Android Studio 3 in .gitignore file.
|
||||||
.idea/caches
|
.idea/caches
|
||||||
|
|
|
||||||
124
.idea/codeStyles/Project.xml
generated
Normal file
124
.idea/codeStyles/Project.xml
generated
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<JetCodeStyleSettings>
|
||||||
|
<option name="LINE_BREAK_AFTER_MULTILINE_WHEN_ENTRY" value="false" />
|
||||||
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
|
</JetCodeStyleSettings>
|
||||||
|
<codeStyleSettings language="XML">
|
||||||
|
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||||
|
</indentOptions>
|
||||||
|
<arrangement>
|
||||||
|
<rules>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>xmlns:android</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>xmlns:.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*:id</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*:name</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>name</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>style</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
</rules>
|
||||||
|
</arrangement>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="kotlin">
|
||||||
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
|
</codeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
||||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
8
.idea/dictionaries/shared.xml
generated
Normal file
8
.idea/dictionaries/shared.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<component name="ProjectDictionaryState">
|
||||||
|
<dictionary name="shared">
|
||||||
|
<words>
|
||||||
|
<w>backstack</w>
|
||||||
|
<w>textfields</w>
|
||||||
|
</words>
|
||||||
|
</dictionary>
|
||||||
|
</component>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||||
|
|
@ -119,11 +120,20 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
override fun onRoomDetailsClicked() {
|
override fun onRoomDetailsClicked() {
|
||||||
backstack.push(NavTarget.RoomDetails)
|
backstack.push(NavTarget.RoomDetails)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onUserDataClicked(userId: UserId) {
|
||||||
|
backstack.push(NavTarget.RoomMemberDetails(userId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
messagesEntryPoint.createNode(this, buildContext, callback)
|
messagesEntryPoint.createNode(this, buildContext, callback)
|
||||||
}
|
}
|
||||||
NavTarget.RoomDetails -> {
|
NavTarget.RoomDetails -> {
|
||||||
roomDetailsEntryPoint.createNode(this, buildContext, emptyList())
|
val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomDetails)
|
||||||
|
roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
|
||||||
|
}
|
||||||
|
is NavTarget.RoomMemberDetails -> {
|
||||||
|
val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId))
|
||||||
|
roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +144,9 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object RoomDetails : NavTarget
|
object RoomDetails : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class RoomMemberDetails(val userId: UserId) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
private val timeline = inputs.room.timeline()
|
private val timeline = inputs.room.timeline()
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,17 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.appnav.di.MatrixClientsHolder
|
import io.element.android.appnav.di.MatrixClientsHolder
|
||||||
|
import io.element.android.appnav.intent.IntentResolver
|
||||||
|
import io.element.android.appnav.intent.ResolvedIntent
|
||||||
import io.element.android.appnav.root.RootPresenter
|
import io.element.android.appnav.root.RootPresenter
|
||||||
import io.element.android.appnav.root.RootView
|
import io.element.android.appnav.root.RootView
|
||||||
|
import io.element.android.features.login.api.oidc.OidcAction
|
||||||
|
import io.element.android.features.login.api.oidc.OidcActionFlow
|
||||||
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.deeplink.DeeplinkData
|
import io.element.android.libraries.deeplink.DeeplinkData
|
||||||
import io.element.android.libraries.deeplink.DeeplinkParser
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
|
@ -65,7 +68,8 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
private val matrixClientsHolder: MatrixClientsHolder,
|
private val matrixClientsHolder: MatrixClientsHolder,
|
||||||
private val presenter: RootPresenter,
|
private val presenter: RootPresenter,
|
||||||
private val bugReportEntryPoint: BugReportEntryPoint,
|
private val bugReportEntryPoint: BugReportEntryPoint,
|
||||||
private val deeplinkParser: DeeplinkParser,
|
private val intentResolver: IntentResolver,
|
||||||
|
private val oidcActionFlow: OidcActionFlow,
|
||||||
) :
|
) :
|
||||||
BackstackNode<RootFlowNode.NavTarget>(
|
BackstackNode<RootFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
|
|
@ -204,8 +208,11 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun handleIntent(intent: Intent) {
|
suspend fun handleIntent(intent: Intent) {
|
||||||
deeplinkParser.getFromIntent(intent)
|
val resolvedIntent = intentResolver.resolve(intent) ?: return
|
||||||
?.let { navigateTo(it) }
|
when (resolvedIntent) {
|
||||||
|
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
|
||||||
|
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
|
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
|
||||||
|
|
@ -223,6 +230,10 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onOidcAction(oidcAction: OidcAction) {
|
||||||
|
oidcActionFlow.post(oidcAction)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
|
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
|
||||||
return attachChild {
|
return attachChild {
|
||||||
backstack.newRoot(NavTarget.LoggedInFlow(sessionId))
|
backstack.newRoot(NavTarget.LoggedInFlow(sessionId))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.appnav.intent
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import io.element.android.features.login.api.oidc.OidcAction
|
||||||
|
import io.element.android.features.login.api.oidc.OidcIntentResolver
|
||||||
|
import io.element.android.libraries.deeplink.DeeplinkData
|
||||||
|
import io.element.android.libraries.deeplink.DeeplinkParser
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
sealed interface ResolvedIntent {
|
||||||
|
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
|
||||||
|
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
|
||||||
|
}
|
||||||
|
|
||||||
|
class IntentResolver @Inject constructor(
|
||||||
|
private val deeplinkParser: DeeplinkParser,
|
||||||
|
private val oidcIntentResolver: OidcIntentResolver
|
||||||
|
) {
|
||||||
|
fun resolve(intent: Intent): ResolvedIntent? {
|
||||||
|
val deepLinkData = deeplinkParser.getFromIntent(intent)
|
||||||
|
if (deepLinkData != null) return ResolvedIntent.Navigation(deepLinkData)
|
||||||
|
|
||||||
|
val oidcAction = oidcIntentResolver.resolve(intent)
|
||||||
|
if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
|
||||||
|
|
||||||
|
// Unknown intent
|
||||||
|
Timber.w("Unknown intent")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,7 +60,12 @@ class RoomFlowNodeTest {
|
||||||
|
|
||||||
var nodeId: String? = null
|
var nodeId: String? = null
|
||||||
|
|
||||||
override fun createNode(parentNode: Node, buildContext: BuildContext, plugins: List<Plugin>): Node {
|
override fun createNode(
|
||||||
|
parentNode: Node,
|
||||||
|
buildContext: BuildContext,
|
||||||
|
inputs: RoomDetailsEntryPoint.Inputs,
|
||||||
|
plugins: List<Plugin>
|
||||||
|
): Node {
|
||||||
return node(buildContext) {}.also {
|
return node(buildContext) {}.also {
|
||||||
nodeId = it.id
|
nodeId = it.id
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
changelog.d/424.feature
Normal file
1
changelog.d/424.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[Create and join rooms] Show a notice for MXIDs that don't resolve when searching for users to invite
|
||||||
1
changelog.d/480.feature
Normal file
1
changelog.d/480.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Open room member details when tapping on a user in the timeline
|
||||||
47
docs/oidc.md
Normal file
47
docs/oidc.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
This file contains some rough notes about Oidc implementation, with some examples of actual data.
|
||||||
|
|
||||||
|
[ios implementation](https://github.com/vector-im/element-x-ios/compare/develop...doug/oidc-temp)
|
||||||
|
|
||||||
|
Rust sdk branch: https://github.com/matrix-org/matrix-rust-sdk/tree/oidc-ffi
|
||||||
|
|
||||||
|
Figma https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?node-id=133-5426&t=yQXKeANatk6keoZF-0
|
||||||
|
|
||||||
|
Server list: https://github.com/vector-im/oidc-playground
|
||||||
|
|
||||||
|
Metadata iOS: (from https://github.com/vector-im/element-x-ios/blob/5f9d07377cebc4f21d9668b1a25f6e3bb22f64a1/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift#L28)
|
||||||
|
|
||||||
|
clientName: InfoPlistReader.main.bundleDisplayName,
|
||||||
|
redirectUri: "io.element:/callback",
|
||||||
|
clientUri: "https://element.io",
|
||||||
|
tosUri: "https://element.io/user-terms-of-service",
|
||||||
|
policyUri: "https://element.io/privacy"
|
||||||
|
|
||||||
|
|
||||||
|
Android:
|
||||||
|
clientName = "Element",
|
||||||
|
redirectUri = "io.element:/callback",
|
||||||
|
clientUri = "https://element.io",
|
||||||
|
tosUri = "https://element.io/user-terms-of-service",
|
||||||
|
policyUri = "https://element.io/privacy"
|
||||||
|
|
||||||
|
|
||||||
|
Example of OidcData (from presentUrl callback):
|
||||||
|
url: https://auth-oidc.lab.element.dev/authorize?response_type=code&client_id=01GYCAGG3PA70CJ97ZVP0WFJY3&redirect_uri=io.element%3A%2Fcallback&scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG&state=ex6mNJVFZ5jn9wL8&nonce=NZ93DOyIGQd9exPQ&code_challenge_method=S256&code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U&prompt=consent
|
||||||
|
|
||||||
|
Formatted url:
|
||||||
|
https://auth-oidc.lab.element.dev/authorize?
|
||||||
|
response_type=code&
|
||||||
|
client_id=01GYCAGG3PA70CJ97ZVP0WFJY3&
|
||||||
|
redirect_uri=io.element%3A%2Fcallback&
|
||||||
|
scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG&
|
||||||
|
state=ex6mNJVFZ5jn9wL8&
|
||||||
|
nonce=NZ93DOyIGQd9exPQ&
|
||||||
|
code_challenge_method=S256&
|
||||||
|
code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U&
|
||||||
|
prompt=consent
|
||||||
|
|
||||||
|
state: ex6mNJVFZ5jn9wL8
|
||||||
|
|
||||||
|
|
||||||
|
Oidc client example: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/examples/oidc_cli/src/main.rs
|
||||||
|
Oidc sdk doc: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/crates/matrix-sdk/src/oidc.rs
|
||||||
|
|
@ -22,40 +22,49 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
|
||||||
import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow
|
import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow
|
||||||
|
import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
|
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchMultipleUsersResultItem(
|
fun SearchMultipleUsersResultItem(
|
||||||
matrixUser: MatrixUser,
|
searchResult: UserSearchResult,
|
||||||
isUserSelected: Boolean,
|
isUserSelected: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onCheckedChange: (Boolean) -> Unit = {},
|
onCheckedChange: (Boolean) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
CheckableMatrixUserRow(
|
if (searchResult.isUnresolved) {
|
||||||
checked = isUserSelected,
|
CheckableUnresolvedUserRow(
|
||||||
modifier = modifier,
|
checked = isUserSelected,
|
||||||
matrixUser = matrixUser,
|
modifier = modifier,
|
||||||
avatarSize = AvatarSize.Custom(36.dp),
|
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.Custom(36.dp)),
|
||||||
onCheckedChange = onCheckedChange,
|
id = searchResult.matrixUser.userId.value,
|
||||||
)
|
onCheckedChange = onCheckedChange,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CheckableMatrixUserRow(
|
||||||
|
checked = isUserSelected,
|
||||||
|
modifier = modifier,
|
||||||
|
matrixUser = searchResult.matrixUser,
|
||||||
|
avatarSize = AvatarSize.Custom(36.dp),
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SearchMultipleUsersResultItemLightPreview() = ElementPreviewLight { ContentToPreview() }
|
internal fun SearchMultipleUsersResultItemPreview() = ElementThemedPreview { ContentToPreview() }
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
internal fun SearchMultipleUsersResultItemDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview() {
|
private fun ContentToPreview() {
|
||||||
Column {
|
Column {
|
||||||
SearchMultipleUsersResultItem(matrixUser = aMatrixUser(), isUserSelected = true)
|
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = false)
|
||||||
SearchMultipleUsersResultItem(matrixUser = aMatrixUser(), isUserSelected = false)
|
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = true)
|
||||||
|
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = false)
|
||||||
|
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,39 +17,48 @@
|
||||||
package io.element.android.features.createroom.impl.components
|
package io.element.android.features.createroom.impl.components
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
|
||||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||||
|
import io.element.android.libraries.matrix.ui.components.UnresolvedUserRow
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
|
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchSingleUserResultItem(
|
fun SearchSingleUserResultItem(
|
||||||
matrixUser: MatrixUser,
|
searchResult: UserSearchResult,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
MatrixUserRow(
|
if (searchResult.isUnresolved) {
|
||||||
modifier = modifier.clickable(onClick = onClick),
|
UnresolvedUserRow(
|
||||||
matrixUser = matrixUser,
|
modifier = modifier.clickable(onClick = onClick),
|
||||||
avatarSize = AvatarSize.Custom(36.dp),
|
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.Custom(36.dp)),
|
||||||
)
|
id = searchResult.matrixUser.userId.value,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
MatrixUserRow(
|
||||||
|
modifier = modifier.clickable(onClick = onClick),
|
||||||
|
matrixUser = searchResult.matrixUser,
|
||||||
|
avatarSize = AvatarSize.Custom(36.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SearchSingleUserResultItemLightPreview() = ElementPreviewLight { ContentToPreview() }
|
internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview{ ContentToPreview() }
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
internal fun SearchSingleUserResultItemDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContentToPreview() {
|
private fun ContentToPreview() {
|
||||||
SearchSingleUserResultItem(matrixUser = aMatrixUser())
|
Column {
|
||||||
|
SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false))
|
||||||
|
SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,13 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||||
import io.element.android.libraries.ui.strings.R
|
import io.element.android.libraries.ui.strings.R
|
||||||
|
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchUserBar(
|
fun SearchUserBar(
|
||||||
query: String,
|
query: String,
|
||||||
state: SearchBarResultState<ImmutableList<MatrixUser>>,
|
state: SearchBarResultState<ImmutableList<UserSearchResult>>,
|
||||||
selectedUsers: ImmutableList<MatrixUser>,
|
selectedUsers: ImmutableList<MatrixUser>,
|
||||||
active: Boolean,
|
active: Boolean,
|
||||||
isMultiSelectionEnabled: Boolean,
|
isMultiSelectionEnabled: Boolean,
|
||||||
|
|
@ -69,16 +70,16 @@ fun SearchUserBar(
|
||||||
resultHandler = { users ->
|
resultHandler = { users ->
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
if (isMultiSelectionEnabled) {
|
if (isMultiSelectionEnabled) {
|
||||||
itemsIndexed(users) { index, matrixUser ->
|
itemsIndexed(users) { index, searchResult ->
|
||||||
SearchMultipleUsersResultItem(
|
SearchMultipleUsersResultItem(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
matrixUser = matrixUser,
|
searchResult = searchResult,
|
||||||
isUserSelected = selectedUsers.find { it.userId == matrixUser.userId } != null,
|
isUserSelected = selectedUsers.find { it.userId == searchResult.matrixUser.userId } != null,
|
||||||
onCheckedChange = { checked ->
|
onCheckedChange = { checked ->
|
||||||
if (checked) {
|
if (checked) {
|
||||||
onUserSelected(matrixUser)
|
onUserSelected(searchResult.matrixUser)
|
||||||
} else {
|
} else {
|
||||||
onUserDeselected(matrixUser)
|
onUserDeselected(searchResult.matrixUser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -87,11 +88,11 @@ fun SearchUserBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
itemsIndexed(users) { index, matrixUser ->
|
itemsIndexed(users) { index, searchResult ->
|
||||||
SearchSingleUserResultItem(
|
SearchSingleUserResultItem(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
matrixUser = matrixUser,
|
searchResult = searchResult,
|
||||||
onClick = { onUserSelected(matrixUser) }
|
onClick = { onUserSelected(searchResult.matrixUser) }
|
||||||
)
|
)
|
||||||
if (index < users.lastIndex) {
|
if (index < users.lastIndex) {
|
||||||
Divider()
|
Divider()
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
|
||||||
import io.element.android.libraries.usersearch.api.UserRepository
|
import io.element.android.libraries.usersearch.api.UserRepository
|
||||||
|
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
|
|
@ -56,7 +56,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
||||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
|
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
|
||||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
var searchResults: SearchBarResultState<ImmutableList<MatrixUser>> by remember {
|
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
|
||||||
mutableStateOf(SearchBarResultState.NotSearching())
|
mutableStateOf(SearchBarResultState.NotSearching())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,12 @@ package io.element.android.features.createroom.impl.userlist
|
||||||
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
data class UserListState(
|
data class UserListState(
|
||||||
val searchQuery: String,
|
val searchQuery: String,
|
||||||
val searchResults: SearchBarResultState<ImmutableList<MatrixUser>>,
|
val searchResults: SearchBarResultState<ImmutableList<UserSearchResult>>,
|
||||||
val selectedUsers: ImmutableList<MatrixUser>,
|
val selectedUsers: ImmutableList<MatrixUser>,
|
||||||
val isSearchActive: Boolean,
|
val isSearchActive: Boolean,
|
||||||
val selectionMode: SelectionMode,
|
val selectionMode: SelectionMode,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.createroom.impl.userlist
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
|
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
|
|
@ -38,14 +39,14 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
|
||||||
isSearchActive = true,
|
isSearchActive = true,
|
||||||
searchQuery = "@someone:matrix.org",
|
searchQuery = "@someone:matrix.org",
|
||||||
selectedUsers = aListOfSelectedUsers(),
|
selectedUsers = aListOfSelectedUsers(),
|
||||||
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
|
searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()),
|
||||||
),
|
),
|
||||||
aUserListState().copy(
|
aUserListState().copy(
|
||||||
isSearchActive = true,
|
isSearchActive = true,
|
||||||
searchQuery = "@someone:matrix.org",
|
searchQuery = "@someone:matrix.org",
|
||||||
selectionMode = SelectionMode.Multiple,
|
selectionMode = SelectionMode.Multiple,
|
||||||
selectedUsers = aListOfSelectedUsers(),
|
selectedUsers = aListOfSelectedUsers(),
|
||||||
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
|
searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()),
|
||||||
),
|
),
|
||||||
aUserListState().copy(
|
aUserListState().copy(
|
||||||
isSearchActive = true,
|
isSearchActive = true,
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,4 @@
|
||||||
<string name="screen_create_room_add_people_title">"Añadir personas"</string>
|
<string name="screen_create_room_add_people_title">"Añadir personas"</string>
|
||||||
<string name="screen_start_chat_error_starting_chat">"Se ha producido un error al intentar iniciar un chat"</string>
|
<string name="screen_start_chat_error_starting_chat">"Se ha producido un error al intentar iniciar un chat"</string>
|
||||||
<string name="screen_create_room_title">"Crear una sala"</string>
|
<string name="screen_create_room_title">"Crear una sala"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,4 @@
|
||||||
<string name="screen_create_room_add_people_title">"Aggiungi persone"</string>
|
<string name="screen_create_room_add_people_title">"Aggiungi persone"</string>
|
||||||
<string name="screen_start_chat_error_starting_chat">"Si è verificato un errore durante il tentativo di avviare una chat"</string>
|
<string name="screen_start_chat_error_starting_chat">"Si è verificato un errore durante il tentativo di avviare una chat"</string>
|
||||||
<string name="screen_create_room_title">"Crea una stanza"</string>
|
<string name="screen_create_room_title">"Crea una stanza"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,4 @@
|
||||||
<string name="screen_create_room_topic_placeholder">"Despre ce este această cameră?"</string>
|
<string name="screen_create_room_topic_placeholder">"Despre ce este această cameră?"</string>
|
||||||
<string name="screen_start_chat_error_starting_chat">"A apărut o eroare la încercarea începerii conversației"</string>
|
<string name="screen_start_chat_error_starting_chat">"A apărut o eroare la încercarea începerii conversației"</string>
|
||||||
<string name="screen_create_room_title">"Creați o cameră"</string>
|
<string name="screen_create_room_title">"Creați o cameră"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
|
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||||
import io.element.android.libraries.usersearch.test.FakeUserRepository
|
import io.element.android.libraries.usersearch.test.FakeUserRepository
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -130,18 +131,18 @@ class DefaultUserListPresenterTests {
|
||||||
skipItems(2)
|
skipItems(2)
|
||||||
|
|
||||||
// When the user repository emits a result, it's copied to the state
|
// When the user repository emits a result, it's copied to the state
|
||||||
userRepository.emitResult(listOf(aMatrixUser()))
|
userRepository.emitResult(listOf(UserSearchResult(aMatrixUser())))
|
||||||
assertThat(awaitItem().searchResults).isEqualTo(
|
assertThat(awaitItem().searchResults).isEqualTo(
|
||||||
SearchBarResultState.Results(
|
SearchBarResultState.Results(
|
||||||
persistentListOf(aMatrixUser())
|
persistentListOf(UserSearchResult(aMatrixUser()))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// When the user repository emits another result, it replaces the previous value
|
// When the user repository emits another result, it replaces the previous value
|
||||||
userRepository.emitResult(aMatrixUserList())
|
userRepository.emitResult(aMatrixUserList().map { UserSearchResult(it) })
|
||||||
assertThat(awaitItem().searchResults).isEqualTo(
|
assertThat(awaitItem().searchResults).isEqualTo(
|
||||||
SearchBarResultState.Results(
|
SearchBarResultState.Results(
|
||||||
aMatrixUserList()
|
aMatrixUserList().map { UserSearchResult(it) }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.api.oidc
|
||||||
|
|
||||||
|
sealed interface OidcAction {
|
||||||
|
object GoBack : OidcAction
|
||||||
|
data class Success(val url: String) : OidcAction
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.api.oidc
|
||||||
|
|
||||||
|
interface OidcActionFlow {
|
||||||
|
fun post(oidcAction: OidcAction)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.api.oidc
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
interface OidcIntentResolver {
|
||||||
|
fun resolve(intent: Intent): OidcAction?
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,7 @@ dependencies {
|
||||||
implementation(projects.libraries.elementresources)
|
implementation(projects.libraries.elementresources)
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
|
implementation(libs.androidx.browser)
|
||||||
api(projects.features.login.api)
|
api(projects.features.login.api)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
||||||
|
|
|
||||||
26
features/login/impl/src/main/AndroidManifest.xml
Normal file
26
features/login/impl/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
~ Copyright (c) 2022 New Vector Ltd
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<!-- To open URL in CustomTab (prefetch, etc.). It makes CustomTabsClient.getPackageName() work
|
||||||
|
see https://developer.android.com/training/package-visibility/use-cases#open-urls-custom-tabs -->
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
|
@ -16,9 +16,13 @@
|
||||||
|
|
||||||
package io.element.android.features.login.impl
|
package io.element.android.features.login.impl
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.bumble.appyx.core.composable.Children
|
import com.bumble.appyx.core.composable.Children
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
|
|
@ -29,17 +33,24 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.features.login.impl.changeserver.ChangeServerNode
|
import io.element.android.features.login.impl.changeserver.ChangeServerNode
|
||||||
|
import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker
|
||||||
|
import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
|
||||||
|
import io.element.android.features.login.impl.oidc.webview.OidcNode
|
||||||
import io.element.android.features.login.impl.root.LoginRootNode
|
import io.element.android.features.login.impl.root.LoginRootNode
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
|
import io.element.android.libraries.designsystem.theme.ElementTheme
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
class LoginFlowNode @AssistedInject constructor(
|
class LoginFlowNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
|
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
|
||||||
|
private val customTabHandler: CustomTabHandler,
|
||||||
) : BackstackNode<LoginFlowNode.NavTarget>(
|
) : BackstackNode<LoginFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Root,
|
initialElement = NavTarget.Root,
|
||||||
|
|
@ -48,6 +59,8 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
) {
|
) {
|
||||||
|
private var activity: Activity? = null
|
||||||
|
private var darkTheme: Boolean = false
|
||||||
|
|
||||||
sealed interface NavTarget : Parcelable {
|
sealed interface NavTarget : Parcelable {
|
||||||
@Parcelize
|
@Parcelize
|
||||||
|
|
@ -55,6 +68,9 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object ChangeServer : NavTarget
|
object ChangeServer : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
@ -64,15 +80,37 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
override fun onChangeHomeServer() {
|
override fun onChangeHomeServer() {
|
||||||
backstack.push(NavTarget.ChangeServer)
|
backstack.push(NavTarget.ChangeServer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onOidcDetails(oidcDetails: OidcDetails) {
|
||||||
|
if (customTabAvailabilityChecker.supportCustomTab()) {
|
||||||
|
// In this case open a Chrome Custom tab
|
||||||
|
activity?.let { customTabHandler.open(it, darkTheme, oidcDetails.url) }
|
||||||
|
} else {
|
||||||
|
// Fallback to WebView mode
|
||||||
|
backstack.push(NavTarget.OidcView(oidcDetails))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createNode<LoginRootNode>(buildContext, plugins = listOf(callback))
|
createNode<LoginRootNode>(buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
|
|
||||||
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
|
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
|
||||||
|
is NavTarget.OidcView -> {
|
||||||
|
val input = OidcNode.Inputs(navTarget.oidcDetails)
|
||||||
|
createNode<OidcNode>(buildContext, plugins = listOf(input))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
|
activity = LocalContext.current as? Activity
|
||||||
|
darkTheme = !ElementTheme.colors.isLight
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
activity = null
|
||||||
|
}
|
||||||
|
}
|
||||||
Children(
|
Children(
|
||||||
navModel = backstack,
|
navModel = backstack,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.browser.customtabs.CustomTabsClient
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CustomTabAvailabilityChecker @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Return true if the device supports Custom tab, i.e. there is an third party app with
|
||||||
|
* CustomTab support (ex: Chrome, Firefox, etc.).
|
||||||
|
*/
|
||||||
|
fun supportCustomTab(): Boolean {
|
||||||
|
val packageName = CustomTabsClient.getPackageName(context, null)
|
||||||
|
return packageName != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.login.api.oidc.OidcAction
|
||||||
|
import io.element.android.features.login.api.oidc.OidcIntentResolver
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultOidcIntentResolver @Inject constructor(
|
||||||
|
private val oidcUrlParser: OidcUrlParser,
|
||||||
|
) : OidcIntentResolver {
|
||||||
|
override fun resolve(intent: Intent): OidcAction? {
|
||||||
|
return oidcUrlParser.parse(intent.dataString.orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc
|
||||||
|
|
||||||
|
import io.element.android.features.login.api.oidc.OidcAction
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcConfig
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple parser for oidc url interception.
|
||||||
|
* TODO Find documentation about the format.
|
||||||
|
*/
|
||||||
|
class OidcUrlParser @Inject constructor() {
|
||||||
|
|
||||||
|
// When user press button "Cancel", we get the url:
|
||||||
|
// `io.element:/callback?error=access_denied&state=IFF1UETGye2ZA8pO`
|
||||||
|
// On success, we get:
|
||||||
|
// `io.element:/callback?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB`
|
||||||
|
/**
|
||||||
|
* Return a OidcAction, or null if the url is not a OidcUrl.
|
||||||
|
*/
|
||||||
|
fun parse(url: String): OidcAction? {
|
||||||
|
if (url.startsWith(OidcConfig.redirectUri).not()) return null
|
||||||
|
if (url.contains("error=access_denied")) return OidcAction.GoBack
|
||||||
|
if (url.contains("code=")) return OidcAction.Success(url)
|
||||||
|
|
||||||
|
// Other case not supported, let's crash the app for now
|
||||||
|
error("Not supported: $url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc.customtab
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.browser.customtabs.CustomTabsClient
|
||||||
|
import androidx.browser.customtabs.CustomTabsServiceConnection
|
||||||
|
import androidx.browser.customtabs.CustomTabsSession
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CustomTabHandler @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private var customTabsSession: CustomTabsSession? = null
|
||||||
|
private var customTabsClient: CustomTabsClient? = null
|
||||||
|
private var customTabsServiceConnection: CustomTabsServiceConnection? = null
|
||||||
|
|
||||||
|
fun prepareCustomTab(url: String) {
|
||||||
|
val packageName = CustomTabsClient.getPackageName(context, null)
|
||||||
|
|
||||||
|
// packageName can be null if there are 0 or several CustomTabs compatible browsers installed on the device
|
||||||
|
if (packageName != null) {
|
||||||
|
customTabsServiceConnection = object : CustomTabsServiceConnection() {
|
||||||
|
override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) {
|
||||||
|
customTabsClient = client
|
||||||
|
.also { it.warmup(0L) }
|
||||||
|
prefetchUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.also {
|
||||||
|
CustomTabsClient.bindCustomTabsService(
|
||||||
|
context,
|
||||||
|
// Despite the API, packageName cannot be null
|
||||||
|
packageName,
|
||||||
|
it
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prefetchUrl(url: String) {
|
||||||
|
if (customTabsSession == null) {
|
||||||
|
customTabsSession = customTabsClient?.newSession(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
customTabsSession?.mayLaunchUrl(Uri.parse(url), null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disposeCustomTab() {
|
||||||
|
customTabsServiceConnection?.let { context.unbindService(it) }
|
||||||
|
customTabsServiceConnection = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun open(activity: Activity, darkTheme: Boolean, url: String) {
|
||||||
|
activity.openUrlInChromeCustomTab(customTabsSession, darkTheme, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc.customtab
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.login.api.oidc.OidcAction
|
||||||
|
import io.element.android.features.login.api.oidc.OidcActionFlow
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
class DefaultOidcActionFlow @Inject constructor() : OidcActionFlow {
|
||||||
|
private val mutableStateFlow = MutableStateFlow<OidcAction?>(null)
|
||||||
|
|
||||||
|
override fun post(oidcAction: OidcAction) {
|
||||||
|
mutableStateFlow.value = oidcAction
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun collect(collector: FlowCollector<OidcAction?>) {
|
||||||
|
mutableStateFlow.collect(collector)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
mutableStateFlow.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc.customtab
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||||
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
|
import androidx.browser.customtabs.CustomTabsSession
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open url in custom tab or, if not available, in the default browser.
|
||||||
|
* If several compatible browsers are installed, the user will be proposed to choose one.
|
||||||
|
* Ref: https://developer.chrome.com/multidevice/android/customtabs.
|
||||||
|
*/
|
||||||
|
fun Activity.openUrlInChromeCustomTab(
|
||||||
|
session: CustomTabsSession?,
|
||||||
|
darkTheme: Boolean,
|
||||||
|
url: String
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
CustomTabsIntent.Builder()
|
||||||
|
.setDefaultColorSchemeParams(
|
||||||
|
CustomTabColorSchemeParams.Builder()
|
||||||
|
// TODO .setToolbarColor(ThemeUtils.getColor(context, android.R.attr.colorBackground))
|
||||||
|
// TODO .setNavigationBarColor(ThemeUtils.getColor(context, android.R.attr.colorBackground))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.setColorScheme(
|
||||||
|
when (darkTheme) {
|
||||||
|
false -> CustomTabsIntent.COLOR_SCHEME_LIGHT
|
||||||
|
true -> CustomTabsIntent.COLOR_SCHEME_DARK
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Note: setting close button icon does not work
|
||||||
|
// .setCloseButtonIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_back_24dp))
|
||||||
|
// .setStartAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
|
||||||
|
// .setExitAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
|
||||||
|
.apply { session?.let { setSession(it) } }
|
||||||
|
.build()
|
||||||
|
.launchUrl(this, Uri.parse(url))
|
||||||
|
} catch (activityNotFoundException: ActivityNotFoundException) {
|
||||||
|
// TODO context.toast(R.string.error_no_external_application_found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc.webview
|
||||||
|
|
||||||
|
import io.element.android.features.login.api.oidc.OidcAction
|
||||||
|
|
||||||
|
sealed interface OidcEvents {
|
||||||
|
object Cancel : OidcEvents
|
||||||
|
data class OidcActionEvent(val oidcAction: OidcAction): OidcEvents
|
||||||
|
object ClearError : OidcEvents
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc.webview
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
import io.element.android.libraries.architecture.inputs
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
class OidcNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
presenterFactory: OidcPresenter.Factory,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
data class Inputs(
|
||||||
|
val oidcDetails: OidcDetails,
|
||||||
|
) : NodeInputs
|
||||||
|
|
||||||
|
private val inputs: Inputs = inputs()
|
||||||
|
private val presenter = presenterFactory.create(inputs.oidcDetails)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
OidcView(
|
||||||
|
state = state,
|
||||||
|
modifier = modifier,
|
||||||
|
onNavigateBack = ::navigateUp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc.webview
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.features.login.api.oidc.OidcAction
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class OidcPresenter @AssistedInject constructor(
|
||||||
|
@Assisted private val oidcDetails: OidcDetails,
|
||||||
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
|
) : Presenter<OidcState> {
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(oidcDetails: OidcDetails): OidcPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): OidcState {
|
||||||
|
var requestState: Async<Unit> by remember {
|
||||||
|
mutableStateOf(Async.Uninitialized)
|
||||||
|
}
|
||||||
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
fun handleCancel() {
|
||||||
|
requestState = Async.Loading()
|
||||||
|
localCoroutineScope.launch {
|
||||||
|
authenticationService.cancelOidcLogin()
|
||||||
|
.fold(
|
||||||
|
onSuccess = {
|
||||||
|
// Then go back
|
||||||
|
requestState = Async.Success(Unit)
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
|
requestState = Async.Failure(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleSuccess(url: String) {
|
||||||
|
requestState = Async.Loading()
|
||||||
|
localCoroutineScope.launch {
|
||||||
|
authenticationService.loginWithOidc(url)
|
||||||
|
.onFailure {
|
||||||
|
requestState = Async.Failure(it)
|
||||||
|
}
|
||||||
|
// On success, the node tree will be updated, there is nothing to do
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleAction(action: OidcAction) {
|
||||||
|
when (action) {
|
||||||
|
OidcAction.GoBack -> handleCancel()
|
||||||
|
is OidcAction.Success -> handleSuccess(action.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleEvents(event: OidcEvents) {
|
||||||
|
when (event) {
|
||||||
|
OidcEvents.Cancel -> handleCancel()
|
||||||
|
is OidcEvents.OidcActionEvent -> handleAction(event.oidcAction)
|
||||||
|
OidcEvents.ClearError -> requestState = Async.Uninitialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return OidcState(
|
||||||
|
oidcDetails = oidcDetails,
|
||||||
|
requestState = requestState,
|
||||||
|
eventSink = ::handleEvents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc.webview
|
||||||
|
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
|
||||||
|
data class OidcState(
|
||||||
|
val oidcDetails: OidcDetails,
|
||||||
|
val requestState: Async<Unit>,
|
||||||
|
val eventSink: (OidcEvents) -> Unit
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc.webview
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
|
||||||
|
open class OidcStateProvider : PreviewParameterProvider<OidcState> {
|
||||||
|
override val values: Sequence<OidcState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aOidcState(),
|
||||||
|
aOidcState().copy(requestState = Async.Loading()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aOidcState() = OidcState(
|
||||||
|
oidcDetails = aOidcDetails(),
|
||||||
|
requestState = Async.Uninitialized,
|
||||||
|
eventSink = {}
|
||||||
|
)
|
||||||
|
|
||||||
|
fun aOidcDetails() = OidcDetails(
|
||||||
|
url = "aUrl",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc.webview
|
||||||
|
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import io.element.android.features.login.impl.oidc.OidcUrlParser
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OidcView(
|
||||||
|
state: OidcState,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val oidcUrlParser = remember { OidcUrlParser() }
|
||||||
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
fun shouldOverrideUrl(url: String): Boolean {
|
||||||
|
val action = oidcUrlParser.parse(url)
|
||||||
|
if (action != null) {
|
||||||
|
state.eventSink.invoke(OidcEvents.OidcActionEvent(action))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val oidcWebViewClient = remember {
|
||||||
|
OidcWebViewClient(::shouldOverrideUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler {
|
||||||
|
if (webView?.canGoBack().orFalse()) {
|
||||||
|
webView?.goBack()
|
||||||
|
} else {
|
||||||
|
// To properly cancel Oidc login
|
||||||
|
state.eventSink.invoke(OidcEvents.Cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier.statusBarsPadding()) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
WebView(context).apply {
|
||||||
|
webViewClient = oidcWebViewClient
|
||||||
|
loadUrl(state.oidcDetails.url)
|
||||||
|
}.also {
|
||||||
|
webView = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
when (state.requestState) {
|
||||||
|
Async.Uninitialized -> Unit
|
||||||
|
is Async.Failure -> {
|
||||||
|
ErrorDialog(
|
||||||
|
content = state.requestState.error.toString(),
|
||||||
|
onDismiss = { state.eventSink(OidcEvents.ClearError) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Async.Loading -> {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is Async.Success -> onNavigateBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun OidcViewLightPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) =
|
||||||
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun OidcViewDarkPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: OidcState) {
|
||||||
|
OidcView(
|
||||||
|
state = state,
|
||||||
|
onNavigateBack = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc.webview
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.annotation.TargetApi
|
||||||
|
import android.os.Build
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
|
||||||
|
class OidcWebViewClient(
|
||||||
|
private val eventListener: WebViewEventListener,
|
||||||
|
) : WebViewClient() {
|
||||||
|
// We will revert to API 23, in the mean time ignore the warning here.
|
||||||
|
@SuppressLint("ObsoleteSdkInt")
|
||||||
|
@TargetApi(Build.VERSION_CODES.N)
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||||
|
return shouldOverrideUrl(request.url.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||||
|
return shouldOverrideUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldOverrideUrl(url: String): Boolean {
|
||||||
|
// Timber.d("shouldOverrideUrl: $url")
|
||||||
|
return eventListener.shouldOverrideUrlLoading(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc.webview
|
||||||
|
|
||||||
|
fun interface WebViewEventListener {
|
||||||
|
/**
|
||||||
|
* Triggered when a Webview loads an url.
|
||||||
|
*
|
||||||
|
* @param url The url about to be rendered.
|
||||||
|
* @return true if the method needs to manage some custom handling
|
||||||
|
*/
|
||||||
|
fun shouldOverrideUrlLoading(url: String): Boolean
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.login.impl.root
|
package io.element.android.features.login.impl.root
|
||||||
|
|
||||||
sealed interface LoginRootEvents {
|
sealed interface LoginRootEvents {
|
||||||
|
object RetryFetchServerInfo : LoginRootEvents
|
||||||
data class SetLogin(val login: String) : LoginRootEvents
|
data class SetLogin(val login: String) : LoginRootEvents
|
||||||
data class SetPassword(val password: String) : LoginRootEvents
|
data class SetPassword(val password: String) : LoginRootEvents
|
||||||
object Submit : LoginRootEvents
|
object Submit : LoginRootEvents
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
class LoginRootNode @AssistedInject constructor(
|
class LoginRootNode @AssistedInject constructor(
|
||||||
|
|
@ -36,20 +37,26 @@ class LoginRootNode @AssistedInject constructor(
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onChangeHomeServer()
|
fun onChangeHomeServer()
|
||||||
|
fun onOidcDetails(oidcDetails: OidcDetails)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onChangeHomeServer() {
|
private fun onChangeHomeServer() {
|
||||||
plugins<Callback>().forEach { it.onChangeHomeServer() }
|
plugins<Callback>().forEach { it.onChangeHomeServer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onOidcDetails(oidcDetails: OidcDetails) {
|
||||||
|
plugins<Callback>().forEach { it.onOidcDetails(oidcDetails) }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
LoginRootView(
|
LoginRootView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onChangeServer = this::onChangeHomeServer,
|
onChangeServer = ::onChangeHomeServer,
|
||||||
onBackPressed = this::navigateUp
|
onOidcDetails = ::onOidcDetails,
|
||||||
|
onBackPressed = ::navigateUp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,28 +17,49 @@
|
||||||
package io.element.android.features.login.impl.root
|
package io.element.android.features.login.impl.root
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import io.element.android.features.login.api.oidc.OidcAction
|
||||||
|
import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
|
||||||
import io.element.android.features.login.impl.util.LoginConstants
|
import io.element.android.features.login.impl.util.LoginConstants
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.architecture.execute
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<LoginRootState> {
|
class LoginRootPresenter @Inject constructor(
|
||||||
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
private val defaultHomeserver = MatrixHomeServerDetails(LoginConstants.DEFAULT_HOMESERVER_URL, true, null)
|
private val defaultOidcActionFlow: DefaultOidcActionFlow,
|
||||||
|
) : Presenter<LoginRootState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): LoginRootState {
|
override fun present(): LoginRootState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val homeserver = authenticationService.getHomeserverDetails().collectAsState().value ?: defaultHomeserver
|
val currentHomeServerDetails = authenticationService.getHomeserverDetails().collectAsState().value
|
||||||
|
val homeserver = currentHomeServerDetails?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL
|
||||||
|
val getHomeServerDetailsAction: MutableState<Async<MatrixHomeServerDetails>> = remember {
|
||||||
|
if (currentHomeServerDetails != null) {
|
||||||
|
mutableStateOf(Async.Success(currentHomeServerDetails))
|
||||||
|
} else {
|
||||||
|
mutableStateOf(Async.Uninitialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (currentHomeServerDetails == null) {
|
||||||
|
getHomeServerDetails(homeserver, getHomeServerDetailsAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val loggedInState: MutableState<LoggedInState> = remember {
|
val loggedInState: MutableState<LoggedInState> = remember {
|
||||||
mutableStateOf(LoggedInState.NotLoggedIn)
|
mutableStateOf(LoggedInState.NotLoggedIn)
|
||||||
}
|
}
|
||||||
|
|
@ -46,31 +67,69 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
|
||||||
mutableStateOf(LoginFormState.Default)
|
mutableStateOf(LoginFormState.Default)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
launch {
|
||||||
|
defaultOidcActionFlow.collect {
|
||||||
|
onOidcAction(it, loggedInState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: LoginRootEvents) {
|
fun handleEvents(event: LoginRootEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
LoginRootEvents.RetryFetchServerInfo -> localCoroutineScope.getHomeServerDetails(homeserver, getHomeServerDetailsAction)
|
||||||
is LoginRootEvents.SetLogin -> updateFormState(formState) {
|
is LoginRootEvents.SetLogin -> updateFormState(formState) {
|
||||||
copy(login = event.login)
|
copy(login = event.login)
|
||||||
}
|
}
|
||||||
is LoginRootEvents.SetPassword -> updateFormState(formState) {
|
is LoginRootEvents.SetPassword -> updateFormState(formState) {
|
||||||
copy(password = event.password)
|
copy(password = event.password)
|
||||||
}
|
}
|
||||||
LoginRootEvents.Submit -> localCoroutineScope.submit(homeserver.url, formState.value, loggedInState)
|
LoginRootEvents.Submit -> {
|
||||||
|
val homeServerDetails = getHomeServerDetailsAction.value.dataOrNull() ?: return
|
||||||
|
when {
|
||||||
|
homeServerDetails.supportsOidcLogin -> localCoroutineScope.submitOidc(loggedInState)
|
||||||
|
homeServerDetails.supportsPasswordLogin -> localCoroutineScope.submit(formState.value, loggedInState)
|
||||||
|
}
|
||||||
|
}
|
||||||
LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn
|
LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return LoginRootState(
|
return LoginRootState(
|
||||||
homeserverDetails = homeserver,
|
homeserverUrl = homeserver,
|
||||||
|
homeserverDetails = getHomeServerDetailsAction.value,
|
||||||
loggedInState = loggedInState.value,
|
loggedInState = loggedInState.value,
|
||||||
formState = formState.value,
|
formState = formState.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
|
private fun CoroutineScope.getHomeServerDetails(
|
||||||
|
homeserver: String,
|
||||||
|
state: MutableState<Async<MatrixHomeServerDetails>>,
|
||||||
|
) = launch {
|
||||||
|
suspend {
|
||||||
|
authenticationService.setHomeserver(homeserver)
|
||||||
|
.map {
|
||||||
|
authenticationService.getHomeserverDetails().value!!
|
||||||
|
}
|
||||||
|
.getOrThrow()
|
||||||
|
}.execute(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.submitOidc(loggedInState: MutableState<LoggedInState>) = launch {
|
||||||
|
loggedInState.value = LoggedInState.LoggingIn
|
||||||
|
authenticationService.getOidcUrl()
|
||||||
|
.onSuccess {
|
||||||
|
loggedInState.value = LoggedInState.OidcStarted(it)
|
||||||
|
}
|
||||||
|
.onFailure { failure ->
|
||||||
|
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
|
||||||
loggedInState.value = LoggedInState.LoggingIn
|
loggedInState.value = LoggedInState.LoggingIn
|
||||||
//TODO rework the setHomeserver flow
|
|
||||||
authenticationService.setHomeserver(homeserver)
|
|
||||||
authenticationService.login(formState.login.trim(), formState.password)
|
authenticationService.login(formState.login.trim(), formState.password)
|
||||||
.onSuccess { sessionId ->
|
.onSuccess { sessionId ->
|
||||||
loggedInState.value = LoggedInState.LoggedIn(sessionId)
|
loggedInState.value = LoggedInState.LoggedIn(sessionId)
|
||||||
|
|
@ -83,4 +142,30 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
|
||||||
private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
|
private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
|
||||||
formState.value = updateLambda(formState.value)
|
formState.value = updateLambda(formState.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun onOidcAction(oidcAction: OidcAction?, loggedInState: MutableState<LoggedInState>) {
|
||||||
|
oidcAction ?: return
|
||||||
|
loggedInState.value = LoggedInState.LoggingIn
|
||||||
|
when (oidcAction) {
|
||||||
|
OidcAction.GoBack -> {
|
||||||
|
authenticationService.cancelOidcLogin()
|
||||||
|
.onSuccess {
|
||||||
|
loggedInState.value = LoggedInState.NotLoggedIn
|
||||||
|
}
|
||||||
|
.onFailure { failure ->
|
||||||
|
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is OidcAction.Success -> {
|
||||||
|
authenticationService.loginWithOidc(oidcAction.url)
|
||||||
|
.onSuccess { sessionId ->
|
||||||
|
loggedInState.value = LoggedInState.LoggedIn(sessionId)
|
||||||
|
}
|
||||||
|
.onFailure { failure ->
|
||||||
|
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaultOidcActionFlow.reset()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,23 +17,31 @@
|
||||||
package io.element.android.features.login.impl.root
|
package io.element.android.features.login.impl.root
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
data class LoginRootState(
|
data class LoginRootState(
|
||||||
val homeserverDetails: MatrixHomeServerDetails,
|
val homeserverUrl: String,
|
||||||
|
val homeserverDetails: Async<MatrixHomeServerDetails>,
|
||||||
val loggedInState: LoggedInState,
|
val loggedInState: LoggedInState,
|
||||||
val formState: LoginFormState,
|
val formState: LoginFormState,
|
||||||
val eventSink: (LoginRootEvents) -> Unit
|
val eventSink: (LoginRootEvents) -> Unit
|
||||||
) {
|
) {
|
||||||
val submitEnabled: Boolean get() =
|
val supportPasswordLogin = (homeserverDetails as? Async.Success)?.state?.supportsPasswordLogin.orFalse()
|
||||||
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInState !is LoggedInState.ErrorLoggingIn
|
val supportOidcLogin = (homeserverDetails as? Async.Success)?.state?.supportsOidcLogin.orFalse()
|
||||||
|
val submitEnabled: Boolean
|
||||||
|
get() = loggedInState !is LoggedInState.ErrorLoggingIn &&
|
||||||
|
((formState.login.isNotEmpty() && formState.password.isNotEmpty()) || supportOidcLogin)
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface LoggedInState {
|
sealed interface LoggedInState {
|
||||||
object NotLoggedIn : LoggedInState
|
object NotLoggedIn : LoggedInState
|
||||||
object LoggingIn : LoggedInState
|
object LoggingIn : LoggedInState
|
||||||
|
data class OidcStarted(val oidcDetail: OidcDetails) : LoggedInState
|
||||||
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
|
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
|
||||||
data class LoggedIn(val sessionId: SessionId) : LoggedInState
|
data class LoggedIn(val sessionId: SessionId) : LoggedInState
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.login.impl.root
|
package io.element.android.features.login.impl.root
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
|
|
@ -24,16 +25,51 @@ open class LoginRootStateProvider : PreviewParameterProvider<LoginRootState> {
|
||||||
override val values: Sequence<LoginRootState>
|
override val values: Sequence<LoginRootState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aLoginRootState(),
|
aLoginRootState(),
|
||||||
aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("some-custom-server.com", true, null)),
|
aLoginRootState().copy(
|
||||||
|
homeserverDetails = Async.Success(
|
||||||
|
MatrixHomeServerDetails(
|
||||||
|
"some-custom-server.com",
|
||||||
|
supportsPasswordLogin = true,
|
||||||
|
supportsOidcLogin = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
aLoginRootState().copy(formState = LoginFormState("user", "pass")),
|
aLoginRootState().copy(formState = LoginFormState("user", "pass")),
|
||||||
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn),
|
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn),
|
||||||
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.ErrorLoggingIn(Throwable())),
|
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.ErrorLoggingIn(Throwable())),
|
||||||
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))),
|
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))),
|
||||||
|
// Oidc
|
||||||
|
aLoginRootState().copy(
|
||||||
|
homeserverUrl = "server-with-oidc.org",
|
||||||
|
homeserverDetails = Async.Success(
|
||||||
|
MatrixHomeServerDetails(
|
||||||
|
"server-with-oidc.org",
|
||||||
|
supportsPasswordLogin = false,
|
||||||
|
supportsOidcLogin = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// No password, no oidc support
|
||||||
|
aLoginRootState().copy(
|
||||||
|
homeserverUrl = "wrong.org",
|
||||||
|
homeserverDetails = Async.Success(
|
||||||
|
MatrixHomeServerDetails(
|
||||||
|
"wrong.org",
|
||||||
|
supportsPasswordLogin = false,
|
||||||
|
supportsOidcLogin = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Loading
|
||||||
|
aLoginRootState().copy(homeserverDetails = Async.Loading()),
|
||||||
|
//Error
|
||||||
|
aLoginRootState().copy(homeserverDetails = Async.Failure(Exception("An error occurred"))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aLoginRootState() = LoginRootState(
|
fun aLoginRootState() = LoginRootState(
|
||||||
homeserverDetails = MatrixHomeServerDetails("matrix.org", true, null),
|
homeserverUrl = "matrix.org",
|
||||||
|
homeserverDetails = Async.Success(MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidcLogin = false)),
|
||||||
loggedInState = LoggedInState.NotLoggedIn,
|
loggedInState = LoggedInState.NotLoggedIn,
|
||||||
formState = LoginFormState.Default,
|
formState = LoginFormState.Default,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
import io.element.android.features.login.impl.error.loginError
|
import io.element.android.features.login.impl.error.loginError
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncLoading
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
|
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
|
|
@ -83,7 +86,7 @@ import io.element.android.libraries.designsystem.theme.components.TextField
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.theme.components.autofill
|
import io.element.android.libraries.designsystem.theme.components.autofill
|
||||||
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
|
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
@ -94,7 +97,7 @@ fun LoginRootView(
|
||||||
state: LoginRootState,
|
state: LoginRootState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onChangeServer: () -> Unit = {},
|
onChangeServer: () -> Unit = {},
|
||||||
onLoginWithSuccess: (SessionId) -> Unit = {},
|
onOidcDetails: (OidcDetails) -> Unit = {},
|
||||||
onBackPressed: () -> Unit,
|
onBackPressed: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val isLoading by remember(state.loggedInState) {
|
val isLoading by remember(state.loggedInState) {
|
||||||
|
|
@ -102,6 +105,15 @@ fun LoginRootView(
|
||||||
state.loggedInState == LoggedInState.LoggingIn
|
state.loggedInState == LoggedInState.LoggingIn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
fun submit() {
|
||||||
|
// Clear focus to prevent keyboard issues with textfields
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
|
||||||
|
state.eventSink(LoginRootEvents.Submit)
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -137,19 +149,26 @@ fun LoginRootView(
|
||||||
|
|
||||||
ChangeServerSection(
|
ChangeServerSection(
|
||||||
interactionEnabled = !isLoading,
|
interactionEnabled = !isLoading,
|
||||||
homeserver = state.homeserverDetails.url,
|
homeserver = state.homeserverUrl,
|
||||||
onChangeServer = onChangeServer
|
onChangeServer = onChangeServer
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
LoginForm(state = state, isLoading = isLoading)
|
when (state.homeserverDetails) {
|
||||||
|
Async.Uninitialized,
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
is Async.Loading -> AsyncLoading()
|
||||||
|
is Async.Failure -> AsyncFailure(
|
||||||
|
throwable = state.homeserverDetails.error,
|
||||||
|
onRetry = {
|
||||||
|
state.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
is Async.Success -> ServerDetailForm(state, isLoading, ::submit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
when (val loggedInState = state.loggedInState) {
|
when (val loggedInState = state.loggedInState) {
|
||||||
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
|
is LoggedInState.OidcStarted -> onOidcDetails(loggedInState.oidcDetail)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -162,6 +181,43 @@ fun LoginRootView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ServerDetailForm(
|
||||||
|
state: LoginRootState,
|
||||||
|
isLoading: Boolean,
|
||||||
|
submit: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.supportOidcLogin -> {
|
||||||
|
// Oidc, in this case, just display a Spacer and the submit button
|
||||||
|
Spacer(modifier.height(28.dp))
|
||||||
|
}
|
||||||
|
state.supportPasswordLogin -> {
|
||||||
|
LoginForm(state = state, isLoading = isLoading, onSubmit = submit, modifier = modifier)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Text(modifier = modifier, text = "No supported login flow")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
|
||||||
|
if (state.supportOidcLogin || state.supportPasswordLogin) {
|
||||||
|
// Submit
|
||||||
|
ButtonWithProgress(
|
||||||
|
text = stringResource(R.string.screen_login_submit),
|
||||||
|
showProgress = isLoading,
|
||||||
|
onClick = submit,
|
||||||
|
enabled = state.submitEnabled,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(TestTags.loginContinue)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ChangeServerSection(
|
internal fun ChangeServerSection(
|
||||||
interactionEnabled: Boolean,
|
interactionEnabled: Boolean,
|
||||||
|
|
@ -217,6 +273,7 @@ internal fun ChangeServerSection(
|
||||||
internal fun LoginForm(
|
internal fun LoginForm(
|
||||||
state: LoginRootState,
|
state: LoginRootState,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
|
onSubmit: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var loginFieldState by textFieldState(stateValue = state.formState.login)
|
var loginFieldState by textFieldState(stateValue = state.formState.login)
|
||||||
|
|
@ -225,13 +282,6 @@ internal fun LoginForm(
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val eventSink = state.eventSink
|
val eventSink = state.eventSink
|
||||||
|
|
||||||
fun submit() {
|
|
||||||
// Clear focus to prevent keyboard issues with textfields
|
|
||||||
focusManager.clearFocus(force = true)
|
|
||||||
|
|
||||||
eventSink(LoginRootEvents.Submit)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.screen_login_form_header),
|
text = stringResource(R.string.screen_login_form_header),
|
||||||
|
|
@ -318,23 +368,11 @@ internal fun LoginForm(
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
),
|
),
|
||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(
|
||||||
onDone = { submit() }
|
onDone = { onSubmit() }
|
||||||
),
|
),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(28.dp))
|
|
||||||
|
|
||||||
// Submit
|
|
||||||
ButtonWithProgress(
|
|
||||||
text = stringResource(R.string.screen_login_submit),
|
|
||||||
showProgress = isLoading,
|
|
||||||
onClick = ::submit,
|
|
||||||
enabled = state.submitEnabled,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.testTag(TestTags.loginContinue)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ package io.element.android.features.login.impl.util
|
||||||
|
|
||||||
object LoginConstants {
|
object LoginConstants {
|
||||||
|
|
||||||
const val DEFAULT_HOMESERVER_URL = "matrix.org"
|
const val DEFAULT_HOMESERVER_URL = "matrix.org" // TODO Oidc "synapse-oidc.lab.element.dev"
|
||||||
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
|
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.login.impl.util.LoginConstants
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
|
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||||
|
|
@ -39,7 +39,7 @@ class ChangeServerPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER_URL)
|
assertThat(initialState.homeserver).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
|
||||||
assertThat(initialState.submitEnabled).isTrue()
|
assertThat(initialState.submitEnabled).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.login.api.oidc.OidcAction
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcConfig
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class OidcUrlParserTest {
|
||||||
|
@Test
|
||||||
|
fun `test empty url`() {
|
||||||
|
val sut = OidcUrlParser()
|
||||||
|
assertThat(sut.parse("")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test regular url`() {
|
||||||
|
val sut = OidcUrlParser()
|
||||||
|
assertThat(sut.parse("https://matrix.org")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test cancel url`() {
|
||||||
|
val sut = OidcUrlParser()
|
||||||
|
val aCancelUrl = OidcConfig.redirectUri + "?error=access_denied&state=IFF1UETGye2ZA8pO"
|
||||||
|
assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test success url`() {
|
||||||
|
val sut = OidcUrlParser()
|
||||||
|
val aSuccessUrl = OidcConfig.redirectUri + "?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
|
||||||
|
assertThat(sut.parse(aSuccessUrl)).isEqualTo(OidcAction.Success(aSuccessUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test unknown url`() {
|
||||||
|
val sut = OidcUrlParser()
|
||||||
|
val anUnknownUrl = OidcConfig.redirectUri + "?state=IFF1UETGye2ZA8pO&goat=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
|
||||||
|
Assert.assertThrows(IllegalStateException::class.java) {
|
||||||
|
assertThat(sut.parse(anUnknownUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.oidc.webview
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionClock
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.login.api.oidc.OidcAction
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||||
|
import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
|
||||||
|
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class OidcPresenterTest {
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val presenter = OidcPresenter(
|
||||||
|
A_OIDC_DATA,
|
||||||
|
FakeAuthenticationService(),
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.oidcDetails).isEqualTo(A_OIDC_DATA)
|
||||||
|
assertThat(initialState.requestState).isEqualTo(Async.Uninitialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - go back`() = runTest {
|
||||||
|
val presenter = OidcPresenter(
|
||||||
|
A_OIDC_DATA,
|
||||||
|
FakeAuthenticationService(),
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink.invoke(OidcEvents.Cancel)
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.requestState).isEqualTo(Async.Success(Unit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - go back with failure`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val presenter = OidcPresenter(
|
||||||
|
A_OIDC_DATA,
|
||||||
|
authenticationService,
|
||||||
|
)
|
||||||
|
authenticationService.givenOidcCancelError(A_THROWABLE)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink.invoke(OidcEvents.Cancel)
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.requestState).isEqualTo(Async.Failure<Unit>(A_THROWABLE))
|
||||||
|
// Note: in real life I do not think this can happen, and the app should not block the user.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - user cancels from webview`() = runTest {
|
||||||
|
val presenter = OidcPresenter(
|
||||||
|
A_OIDC_DATA,
|
||||||
|
FakeAuthenticationService(),
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.GoBack))
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.requestState).isEqualTo(Async.Success(Unit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - login success`() = runTest {
|
||||||
|
val presenter = OidcPresenter(
|
||||||
|
A_OIDC_DATA,
|
||||||
|
FakeAuthenticationService(),
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.Success("A_URL")))
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
|
||||||
|
// In this case, no success, the session is created and the node get destroyed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - login error`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val presenter = OidcPresenter(
|
||||||
|
A_OIDC_DATA,
|
||||||
|
authenticationService,
|
||||||
|
)
|
||||||
|
authenticationService.givenLoginError(A_THROWABLE)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.Success("A_URL")))
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
|
||||||
|
val errorState = awaitItem()
|
||||||
|
assertThat(errorState.requestState).isEqualTo(Async.Failure<Unit>(A_THROWABLE))
|
||||||
|
errorState.eventSink.invoke(OidcEvents.ClearError)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.requestState).isEqualTo(Async.Uninitialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,11 +20,18 @@ import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.login.api.oidc.OidcAction
|
||||||
|
import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
|
||||||
|
import io.element.android.features.login.impl.util.LoginConstants
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||||
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
||||||
|
import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
|
||||||
import io.element.android.libraries.matrix.test.A_PASSWORD
|
import io.element.android.libraries.matrix.test.A_PASSWORD
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
|
import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
|
||||||
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -34,23 +41,91 @@ class LoginRootPresenterTest {
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val presenter = LoginRootPresenter(
|
val presenter = LoginRootPresenter(
|
||||||
FakeAuthenticationService(),
|
FakeAuthenticationService(),
|
||||||
|
DefaultOidcActionFlow(),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.homeserverDetails).isEqualTo(A_HOMESERVER)
|
assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
|
||||||
|
assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
|
||||||
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
||||||
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
|
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
|
||||||
assertThat(initialState.submitEnabled).isFalse()
|
assertThat(initialState.submitEnabled).isFalse()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state server load`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val oidcActionFlow = DefaultOidcActionFlow()
|
||||||
|
val presenter = LoginRootPresenter(
|
||||||
|
authenticationService,
|
||||||
|
oidcActionFlow,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
|
||||||
|
assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
|
||||||
|
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
||||||
|
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
|
||||||
|
assertThat(initialState.submitEnabled).isFalse()
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading<MatrixHomeServerDetails>())
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
|
skipItems(1)
|
||||||
|
val loadedState = awaitItem()
|
||||||
|
assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - initial state server load error and retry`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val oidcActionFlow = DefaultOidcActionFlow()
|
||||||
|
val presenter = LoginRootPresenter(
|
||||||
|
authenticationService,
|
||||||
|
oidcActionFlow,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
|
||||||
|
assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
|
||||||
|
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
||||||
|
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
|
||||||
|
assertThat(initialState.submitEnabled).isFalse()
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading<MatrixHomeServerDetails>())
|
||||||
|
val aThrowable = Throwable("Error")
|
||||||
|
authenticationService.givenChangeServerError(aThrowable)
|
||||||
|
val errorState = awaitItem()
|
||||||
|
assertThat(errorState.homeserverDetails).isEqualTo(Async.Failure<MatrixHomeServerDetails>(aThrowable))
|
||||||
|
// Retry
|
||||||
|
errorState.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo)
|
||||||
|
val loadingState2 = awaitItem()
|
||||||
|
assertThat(loadingState2.homeserverDetails).isEqualTo(Async.Loading<MatrixHomeServerDetails>())
|
||||||
|
authenticationService.givenChangeServerError(null)
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
|
skipItems(1)
|
||||||
|
val loadedState = awaitItem()
|
||||||
|
assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - enter login and password`() = runTest {
|
fun `present - enter login and password`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val oidcActionFlow = DefaultOidcActionFlow()
|
||||||
val presenter = LoginRootPresenter(
|
val presenter = LoginRootPresenter(
|
||||||
FakeAuthenticationService(),
|
authenticationService,
|
||||||
|
oidcActionFlow,
|
||||||
)
|
)
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -67,10 +142,99 @@ class LoginRootPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - submit`() = runTest {
|
fun `present - oidc login`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val oidcActionFlow = DefaultOidcActionFlow()
|
||||||
val presenter = LoginRootPresenter(
|
val presenter = LoginRootPresenter(
|
||||||
FakeAuthenticationService(),
|
authenticationService,
|
||||||
|
oidcActionFlow,
|
||||||
)
|
)
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.submitEnabled).isTrue()
|
||||||
|
initialState.eventSink.invoke(LoginRootEvents.Submit)
|
||||||
|
val oidcState = awaitItem()
|
||||||
|
assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.OidcStarted(A_OIDC_DATA))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - oidc login error`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val oidcActionFlow = DefaultOidcActionFlow()
|
||||||
|
val presenter = LoginRootPresenter(
|
||||||
|
authenticationService,
|
||||||
|
oidcActionFlow,
|
||||||
|
)
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
|
||||||
|
authenticationService.givenOidcError(A_THROWABLE)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.submitEnabled).isTrue()
|
||||||
|
initialState.eventSink.invoke(LoginRootEvents.Submit)
|
||||||
|
val oidcState = awaitItem()
|
||||||
|
assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - oidc custom tab login`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val oidcActionFlow = DefaultOidcActionFlow()
|
||||||
|
val presenter = LoginRootPresenter(
|
||||||
|
authenticationService,
|
||||||
|
oidcActionFlow,
|
||||||
|
)
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.submitEnabled).isTrue()
|
||||||
|
initialState.eventSink.invoke(LoginRootEvents.Submit)
|
||||||
|
val oidcState = awaitItem()
|
||||||
|
assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.OidcStarted(A_OIDC_DATA))
|
||||||
|
// Oidc cancel, sdk error
|
||||||
|
authenticationService.givenOidcCancelError(A_THROWABLE)
|
||||||
|
oidcActionFlow.post(OidcAction.GoBack)
|
||||||
|
val stateCancelSdkError = awaitItem()
|
||||||
|
assertThat(stateCancelSdkError.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
|
||||||
|
// Oidc cancel, sdk OK
|
||||||
|
authenticationService.givenOidcCancelError(null)
|
||||||
|
oidcActionFlow.post(OidcAction.GoBack)
|
||||||
|
val stateCancel = awaitItem()
|
||||||
|
assertThat(stateCancel.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
|
||||||
|
// Oidc success, sdk error
|
||||||
|
authenticationService.givenLoginError(A_THROWABLE)
|
||||||
|
oidcActionFlow.post(OidcAction.Success(A_OIDC_DATA.url))
|
||||||
|
val stateSuccessSdkErrorLoading = awaitItem()
|
||||||
|
assertThat(stateSuccessSdkErrorLoading.loggedInState).isEqualTo(LoggedInState.LoggingIn)
|
||||||
|
val stateSuccessSdkError = awaitItem()
|
||||||
|
assertThat(stateSuccessSdkError.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
|
||||||
|
// Oidc success
|
||||||
|
authenticationService.givenLoginError(null)
|
||||||
|
oidcActionFlow.post(OidcAction.Success(A_OIDC_DATA.url))
|
||||||
|
val stateSuccess = awaitItem()
|
||||||
|
assertThat(stateSuccess.loggedInState).isEqualTo(LoggedInState.LoggingIn)
|
||||||
|
val stateSuccessLoggedIn = awaitItem()
|
||||||
|
assertThat(stateSuccessLoggedIn.loggedInState).isEqualTo(LoggedInState.LoggedIn(A_SESSION_ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - submit`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val oidcActionFlow = DefaultOidcActionFlow()
|
||||||
|
val presenter = LoginRootPresenter(
|
||||||
|
authenticationService,
|
||||||
|
oidcActionFlow,
|
||||||
|
)
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -90,9 +254,12 @@ class LoginRootPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - submit with error`() = runTest {
|
fun `present - submit with error`() = runTest {
|
||||||
val authenticationService = FakeAuthenticationService()
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val oidcActionFlow = DefaultOidcActionFlow()
|
||||||
val presenter = LoginRootPresenter(
|
val presenter = LoginRootPresenter(
|
||||||
authenticationService,
|
authenticationService,
|
||||||
|
oidcActionFlow,
|
||||||
)
|
)
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -113,14 +280,16 @@ class LoginRootPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - clear error`() = runTest {
|
fun `present - clear error`() = runTest {
|
||||||
val authenticationService = FakeAuthenticationService()
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val oidcActionFlow = DefaultOidcActionFlow()
|
||||||
val presenter = LoginRootPresenter(
|
val presenter = LoginRootPresenter(
|
||||||
authenticationService,
|
authenticationService,
|
||||||
|
oidcActionFlow,
|
||||||
)
|
)
|
||||||
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
||||||
// Submit will return an error
|
// Submit will return an error
|
||||||
authenticationService.givenLoginError(A_THROWABLE)
|
authenticationService.givenLoginError(A_THROWABLE)
|
||||||
initialState.eventSink(LoginRootEvents.Submit)
|
initialState.eventSink(LoginRootEvents.Submit)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
||||||
interface MessagesEntryPoint : FeatureEntryPoint {
|
interface MessagesEntryPoint : FeatureEntryPoint {
|
||||||
fun createNode(
|
fun createNode(
|
||||||
|
|
@ -30,5 +31,6 @@ interface MessagesEntryPoint : FeatureEntryPoint {
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onRoomDetailsClicked()
|
fun onRoomDetailsClicked()
|
||||||
|
fun onUserDataClicked(userId: UserId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
@ -89,6 +90,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
|
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
|
||||||
backstack.push(NavTarget.AttachmentPreview(attachments.first()))
|
backstack.push(NavTarget.AttachmentPreview(attachments.first()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onUserDataClicked(userId: UserId) {
|
||||||
|
callback?.onUserDataClicked(userId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createNode<MessagesNode>(buildContext, listOf(callback))
|
createNode<MessagesNode>(buildContext, listOf(callback))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@ContributesNode(RoomScope::class)
|
@ContributesNode(RoomScope::class)
|
||||||
|
|
@ -43,6 +44,7 @@ class MessagesNode @AssistedInject constructor(
|
||||||
fun onRoomDetailsClicked()
|
fun onRoomDetailsClicked()
|
||||||
fun onEventClicked(event: TimelineItem.Event)
|
fun onEventClicked(event: TimelineItem.Event)
|
||||||
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
|
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
|
||||||
|
fun onUserDataClicked(userId: UserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onRoomDetailsClicked() {
|
private fun onRoomDetailsClicked() {
|
||||||
|
|
@ -57,6 +59,10 @@ class MessagesNode @AssistedInject constructor(
|
||||||
callback?.onPreviewAttachments(attachments)
|
callback?.onPreviewAttachments(attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onUserDataClicked(userId: UserId) {
|
||||||
|
callback?.onUserDataClicked(userId)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
|
@ -66,6 +72,7 @@ class MessagesNode @AssistedInject constructor(
|
||||||
onRoomDetailsClicked = this::onRoomDetailsClicked,
|
onRoomDetailsClicked = this::onRoomDetailsClicked,
|
||||||
onEventClicked = this::onEventClicked,
|
onEventClicked = this::onEventClicked,
|
||||||
onPreviewAttachments = this::onPreviewAttachments,
|
onPreviewAttachments = this::onPreviewAttachments,
|
||||||
|
onUserDataClicked = this::onUserDataClicked,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -97,6 +98,7 @@ fun MessagesView(
|
||||||
onBackPressed: () -> Unit,
|
onBackPressed: () -> Unit,
|
||||||
onRoomDetailsClicked: () -> Unit,
|
onRoomDetailsClicked: () -> Unit,
|
||||||
onEventClicked: (event: TimelineItem.Event) -> Unit,
|
onEventClicked: (event: TimelineItem.Event) -> Unit,
|
||||||
|
onUserDataClicked: (UserId) -> Unit,
|
||||||
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -203,6 +205,7 @@ fun MessagesView(
|
||||||
.consumeWindowInsets(padding),
|
.consumeWindowInsets(padding),
|
||||||
onMessageClicked = ::onMessageClicked,
|
onMessageClicked = ::onMessageClicked,
|
||||||
onMessageLongClicked = ::onMessageLongClicked,
|
onMessageLongClicked = ::onMessageLongClicked,
|
||||||
|
onUserDataClicked = onUserDataClicked,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
snackbarHost = {
|
snackbarHost = {
|
||||||
|
|
@ -240,6 +243,7 @@ fun MessagesViewContent(
|
||||||
state: MessagesState,
|
state: MessagesState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onMessageClicked: (TimelineItem.Event) -> Unit = {},
|
onMessageClicked: (TimelineItem.Event) -> Unit = {},
|
||||||
|
onUserDataClicked: (UserId) -> Unit = {},
|
||||||
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
|
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -255,6 +259,7 @@ fun MessagesViewContent(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onMessageClicked = onMessageClicked,
|
onMessageClicked = onMessageClicked,
|
||||||
onMessageLongClicked = onMessageLongClicked,
|
onMessageLongClicked = onMessageLongClicked,
|
||||||
|
onUserDataClicked = onUserDataClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MessageComposerView(
|
MessageComposerView(
|
||||||
|
|
@ -354,6 +359,7 @@ private fun ContentToPreview(state: MessagesState) {
|
||||||
onBackPressed = {},
|
onBackPressed = {},
|
||||||
onRoomDetailsClicked = {},
|
onRoomDetailsClicked = {},
|
||||||
onEventClicked = {},
|
onEventClicked = {},
|
||||||
onPreviewAttachments = {}
|
onPreviewAttachments = {},
|
||||||
|
onUserDataClicked = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline
|
||||||
|
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
|
@ -87,6 +88,7 @@ import io.element.android.libraries.designsystem.theme.LocalColors
|
||||||
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -95,6 +97,7 @@ import kotlinx.coroutines.launch
|
||||||
fun TimelineView(
|
fun TimelineView(
|
||||||
state: TimelineState,
|
state: TimelineState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onUserDataClicked: (UserId) -> Unit = {},
|
||||||
onMessageClicked: (TimelineItem.Event) -> Unit = {},
|
onMessageClicked: (TimelineItem.Event) -> Unit = {},
|
||||||
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
|
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
@ -120,6 +123,7 @@ fun TimelineView(
|
||||||
highlightedItem = state.highlightedEventId?.value,
|
highlightedItem = state.highlightedEventId?.value,
|
||||||
onClick = onMessageClicked,
|
onClick = onMessageClicked,
|
||||||
onLongClick = onMessageLongClicked,
|
onLongClick = onMessageLongClicked,
|
||||||
|
onUserDataClick = onUserDataClicked,
|
||||||
)
|
)
|
||||||
if (index == state.timelineItems.lastIndex) {
|
if (index == state.timelineItems.lastIndex) {
|
||||||
onReachedLoadMore()
|
onReachedLoadMore()
|
||||||
|
|
@ -139,6 +143,7 @@ fun TimelineView(
|
||||||
fun TimelineItemRow(
|
fun TimelineItemRow(
|
||||||
timelineItem: TimelineItem,
|
timelineItem: TimelineItem,
|
||||||
highlightedItem: String?,
|
highlightedItem: String?,
|
||||||
|
onUserDataClick: (UserId) -> Unit,
|
||||||
onClick: (TimelineItem.Event) -> Unit,
|
onClick: (TimelineItem.Event) -> Unit,
|
||||||
onLongClick: (TimelineItem.Event) -> Unit,
|
onLongClick: (TimelineItem.Event) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
|
|
@ -173,6 +178,7 @@ fun TimelineItemRow(
|
||||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
isHighlighted = highlightedItem == timelineItem.identifier(),
|
||||||
onClick = ::onClick,
|
onClick = ::onClick,
|
||||||
onLongClick = ::onLongClick,
|
onLongClick = ::onLongClick,
|
||||||
|
onUserDataClick = onUserDataClick,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +209,7 @@ fun TimelineItemRow(
|
||||||
highlightedItem = highlightedItem,
|
highlightedItem = highlightedItem,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
onUserDataClick = onUserDataClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -230,15 +237,21 @@ fun TimelineItemEventRow(
|
||||||
isHighlighted: Boolean,
|
isHighlighted: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
|
onUserDataClick: (UserId) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
|
fun onUserDataClicked() {
|
||||||
|
onUserDataClick(event.senderId)
|
||||||
|
}
|
||||||
|
|
||||||
val (parentAlignment, contentAlignment) = if (event.isMine) {
|
val (parentAlignment, contentAlignment) = if (event.isMine) {
|
||||||
Pair(Alignment.CenterEnd, Alignment.End)
|
Pair(Alignment.CenterEnd, Alignment.End)
|
||||||
} else {
|
} else {
|
||||||
Pair(Alignment.CenterStart, Alignment.Start)
|
Pair(Alignment.CenterStart, Alignment.Start)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -257,6 +270,7 @@ fun TimelineItemEventRow(
|
||||||
Modifier
|
Modifier
|
||||||
.zIndex(1f)
|
.zIndex(1f)
|
||||||
.offset(y = 12.dp)
|
.offset(y = 12.dp)
|
||||||
|
.clickable(onClick = ::onUserDataClicked)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val bubbleState = BubbleState(
|
val bubbleState = BubbleState(
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-library")
|
id("io.element.android-library")
|
||||||
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,26 @@
|
||||||
|
|
||||||
package io.element.android.features.roomdetails.api
|
package io.element.android.features.roomdetails.api
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
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
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
interface RoomDetailsEntryPoint : FeatureEntryPoint {
|
interface RoomDetailsEntryPoint : FeatureEntryPoint {
|
||||||
fun createNode(parentNode: Node, buildContext: BuildContext, plugins: List<Plugin>): Node
|
|
||||||
|
sealed interface InitialTarget : Parcelable {
|
||||||
|
@Parcelize
|
||||||
|
object RoomDetails : InitialTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class RoomMemberDetails(val roomMemberId: UserId) : InitialTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Inputs(val initialElement: InitialTarget) : NodeInputs
|
||||||
|
|
||||||
|
fun createNode(parentNode: Node, buildContext: BuildContext, inputs: Inputs, plugins: List<Plugin>): Node
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,25 @@ import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||||
|
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint.InitialTarget
|
||||||
|
import io.element.android.features.roomdetails.impl.RoomDetailsFlowNode.NavTarget
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint {
|
class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint {
|
||||||
override fun createNode(parentNode: Node, buildContext: BuildContext, plugins: List<Plugin>): Node {
|
override fun createNode(
|
||||||
return parentNode.createNode<RoomDetailsFlowNode>(buildContext, plugins)
|
parentNode: Node,
|
||||||
|
buildContext: BuildContext,
|
||||||
|
inputs: RoomDetailsEntryPoint.Inputs,
|
||||||
|
plugins: List<Plugin>
|
||||||
|
): Node {
|
||||||
|
return parentNode.createNode<RoomDetailsFlowNode>(buildContext, plugins + inputs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun InitialTarget.toNavTarget() = when (this) {
|
||||||
|
is InitialTarget.RoomDetails -> NavTarget.RoomDetails
|
||||||
|
is InitialTarget.RoomMemberDetails -> NavTarget.RoomMemberDetails(roomMemberId)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import dagger.assisted.Assisted
|
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.roomdetails.api.RoomDetailsEntryPoint
|
||||||
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
|
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
|
||||||
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
|
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
|
||||||
|
|
@ -44,7 +45,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
) : BackstackNode<RoomDetailsFlowNode.NavTarget>(
|
) : BackstackNode<RoomDetailsFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.RoomDetails,
|
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Inputs>().first().initialElement.toNavTarget(),
|
||||||
savedStateMap = buildContext.savedStateMap,
|
savedStateMap = buildContext.savedStateMap,
|
||||||
),
|
),
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
|
|
@ -95,7 +96,8 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
createNode<RoomInviteMembersNode>(buildContext)
|
createNode<RoomInviteMembersNode>(buildContext)
|
||||||
}
|
}
|
||||||
is NavTarget.RoomMemberDetails -> {
|
is NavTarget.RoomMemberDetails -> {
|
||||||
createNode<RoomMemberDetailsNode>(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMemberId)))
|
val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId))
|
||||||
|
createNode<RoomMemberDetailsNode>(buildContext, plugins)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,9 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.features.leaveroom.api.LeaveRoomView
|
||||||
import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
|
import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
|
||||||
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
|
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
|
||||||
import io.element.android.features.leaveroom.api.LeaveRoomView
|
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
|
||||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
|
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
|
||||||
import io.element.android.libraries.architecture.isLoading
|
import io.element.android.libraries.architecture.isLoading
|
||||||
|
|
@ -67,6 +67,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -185,7 +186,7 @@ internal fun RoomHeaderSection(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
|
internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
|
||||||
PreferenceCategory(title = stringResource(R.string.screen_room_details_topic_title), modifier = modifier) {
|
PreferenceCategory(title = stringResource(StringR.string.common_topic), modifier = modifier) {
|
||||||
Text(
|
Text(
|
||||||
roomTopic,
|
roomTopic,
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
|
||||||
|
|
|
||||||
|
|
@ -126,15 +126,16 @@ class RoomInviteMembersPresenter @Inject constructor(
|
||||||
userRepository.search(searchQuery).collect {
|
userRepository.search(searchQuery).collect {
|
||||||
searchResults.value = when {
|
searchResults.value = when {
|
||||||
it.isEmpty() -> SearchBarResultState.NoResults()
|
it.isEmpty() -> SearchBarResultState.NoResults()
|
||||||
else -> SearchBarResultState.Results(it.map { user ->
|
else -> SearchBarResultState.Results(it.map { result ->
|
||||||
val existingMembership = joinedMembers.firstOrNull { j -> j.userId == user.userId }?.membership
|
val existingMembership = joinedMembers.firstOrNull { j -> j.userId == result.matrixUser.userId }?.membership
|
||||||
val isJoined = existingMembership == RoomMembershipState.JOIN
|
val isJoined = existingMembership == RoomMembershipState.JOIN
|
||||||
val isInvited = existingMembership == RoomMembershipState.INVITE
|
val isInvited = existingMembership == RoomMembershipState.INVITE
|
||||||
InvitableUser(
|
InvitableUser(
|
||||||
matrixUser = user,
|
matrixUser = result.matrixUser,
|
||||||
isSelected = selectedUsers.value.contains(user) || isJoined || isInvited,
|
isSelected = selectedUsers.value.contains(result.matrixUser) || isJoined || isInvited,
|
||||||
isAlreadyJoined = isJoined,
|
isAlreadyJoined = isJoined,
|
||||||
isAlreadyInvited = isInvited,
|
isAlreadyInvited = isInvited,
|
||||||
|
isUnresolved = result.isUnresolved,
|
||||||
)
|
)
|
||||||
}.toImmutableList())
|
}.toImmutableList())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,5 @@ data class InvitableUser(
|
||||||
val isSelected: Boolean = false,
|
val isSelected: Boolean = false,
|
||||||
val isAlreadyJoined: Boolean = false,
|
val isAlreadyJoined: Boolean = false,
|
||||||
val isAlreadyInvited: Boolean = false,
|
val isAlreadyInvited: Boolean = false,
|
||||||
|
val isUnresolved: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -48,5 +48,19 @@ internal class RoomInviteMembersStateProvider : PreviewParameterProvider<RoomInv
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
RoomInviteMembersState(
|
||||||
|
isSearchActive = true,
|
||||||
|
canInvite = true,
|
||||||
|
searchQuery = "@alice:server.org",
|
||||||
|
selectedUsers = persistentListOf(
|
||||||
|
aMatrixUser("@carol:server.org", "Carol")
|
||||||
|
),
|
||||||
|
searchResults = SearchBarResultState.Results(
|
||||||
|
persistentListOf(
|
||||||
|
InvitableUser(aMatrixUser("@alice:server.org"), isUnresolved = true),
|
||||||
|
InvitableUser(aMatrixUser("@bob:server.org", "Bob")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow
|
||||||
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
|
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
|
||||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
|
|
@ -180,22 +181,32 @@ private fun RoomInviteMembersSearchBar(
|
||||||
|
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(results) { invitableUser ->
|
items(results) { invitableUser ->
|
||||||
CheckableUserRow(
|
if (invitableUser.isUnresolved && !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined) {
|
||||||
checked = invitableUser.isSelected,
|
CheckableUnresolvedUserRow(
|
||||||
enabled = !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined,
|
checked = invitableUser.isSelected,
|
||||||
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM),
|
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM),
|
||||||
name = invitableUser.matrixUser.getBestName(),
|
id = invitableUser.matrixUser.userId.value,
|
||||||
subtext = when {
|
onCheckedChange = { onUserToggled(invitableUser.matrixUser) },
|
||||||
// If they're already invited or joined we show that information
|
modifier = Modifier.fillMaxWidth()
|
||||||
invitableUser.isAlreadyJoined -> stringResource(R.string.screen_room_details_already_a_member)
|
)
|
||||||
invitableUser.isAlreadyInvited -> stringResource(R.string.screen_room_details_already_invited)
|
} else {
|
||||||
// Otherwise show the ID, unless that's already used for their name
|
CheckableUserRow(
|
||||||
invitableUser.matrixUser.displayName.isNullOrEmpty().not() -> invitableUser.matrixUser.userId.value
|
checked = invitableUser.isSelected,
|
||||||
else -> null
|
enabled = !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined,
|
||||||
},
|
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM),
|
||||||
onCheckedChange = { onUserToggled(invitableUser.matrixUser) },
|
name = invitableUser.matrixUser.getBestName(),
|
||||||
modifier = Modifier.fillMaxWidth()
|
subtext = when {
|
||||||
)
|
// If they're already invited or joined we show that information
|
||||||
|
invitableUser.isAlreadyJoined -> stringResource(R.string.screen_room_details_already_a_member)
|
||||||
|
invitableUser.isAlreadyInvited -> stringResource(R.string.screen_room_details_already_invited)
|
||||||
|
// Otherwise show the ID, unless that's already used for their name
|
||||||
|
invitableUser.matrixUser.displayName.isNullOrEmpty().not() -> invitableUser.matrixUser.userId.value
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
onCheckedChange = { onUserToggled(invitableUser.matrixUser) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,11 @@ class RoomMemberDetailsNode @AssistedInject constructor(
|
||||||
presenterFactory: RoomMemberDetailsPresenter.Factory,
|
presenterFactory: RoomMemberDetailsPresenter.Factory,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
data class Inputs(
|
data class RoomMemberDetailsInput(
|
||||||
val roomMemberId: UserId,
|
val roomMemberId: UserId
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
private val inputs = inputs<Inputs>()
|
private val inputs = inputs<RoomMemberDetailsInput>()
|
||||||
private val presenter = presenterFactory.create(inputs.roomMemberId)
|
private val presenter = presenterFactory.create(inputs.roomMemberId)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.ui.room.getRoomMember
|
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -51,7 +51,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
||||||
override fun present(): RoomMemberDetailsState {
|
override fun present(): RoomMemberDetailsState {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
|
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
|
||||||
val roomMember by room.getRoomMember(roomMemberId)
|
val roomMember by room.getRoomMemberAsState(roomMemberId)
|
||||||
// the room member is not really live...
|
// the room member is not really live...
|
||||||
val isBlocked = remember {
|
val isBlocked = remember {
|
||||||
mutableStateOf(roomMember?.isIgnored.orFalse())
|
mutableStateOf(roomMember?.isIgnored.orFalse())
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,12 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
|
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||||
import io.element.android.libraries.usersearch.test.FakeUserRepository
|
import io.element.android.libraries.usersearch.test.FakeUserRepository
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
@ -130,7 +132,7 @@ internal class RoomInviteMembersPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
||||||
assertThat(repository.providedQuery).isEqualTo("some query")
|
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||||
repository.emitResult(aMatrixUserList())
|
repository.emitResult(aMatrixUserList().map { UserSearchResult(it) })
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
||||||
val resultState = awaitItem()
|
val resultState = awaitItem()
|
||||||
|
|
@ -175,7 +177,7 @@ internal class RoomInviteMembersPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
||||||
assertThat(repository.providedQuery).isEqualTo("some query")
|
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||||
repository.emitResult(aMatrixUserList())
|
repository.emitResult(aMatrixUserList().map { UserSearchResult(it) })
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
||||||
val resultState = awaitItem()
|
val resultState = awaitItem()
|
||||||
|
|
@ -202,6 +204,53 @@ internal class RoomInviteMembersPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - performs search and handles unresolved results`() = runTest {
|
||||||
|
val userList = aMatrixUserList()
|
||||||
|
val joinedUser = userList[0]
|
||||||
|
val invitedUser = userList[1]
|
||||||
|
|
||||||
|
val repository = FakeUserRepository()
|
||||||
|
val presenter = RoomInviteMembersPresenter(
|
||||||
|
userRepository = repository,
|
||||||
|
roomMemberListDataSource = createDataSource(FakeMatrixRoom().apply {
|
||||||
|
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(
|
||||||
|
aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN),
|
||||||
|
aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE),
|
||||||
|
)))
|
||||||
|
}),
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers()
|
||||||
|
)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||||
|
|
||||||
|
val unresolvedUser = UserSearchResult(aMatrixUser(id = A_USER_ID.value), isUnresolved = true)
|
||||||
|
repository.emitResult(listOf(unresolvedUser) + aMatrixUserList().map { UserSearchResult(it) })
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val resultState = awaitItem()
|
||||||
|
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||||
|
|
||||||
|
val users = resultState.searchResults.users()
|
||||||
|
|
||||||
|
val userWhoShouldBeUnresolved = users.first()
|
||||||
|
assertThat(userWhoShouldBeUnresolved.isUnresolved).isTrue()
|
||||||
|
|
||||||
|
// All other users are neither joined nor invited
|
||||||
|
val otherUsers = users.minus(userWhoShouldBeUnresolved)
|
||||||
|
assertThat(otherUsers.none { it.isUnresolved }).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - toggle users updates selected user state`() = runTest {
|
fun `present - toggle users updates selected user state`() = runTest {
|
||||||
val repository = FakeUserRepository()
|
val repository = FakeUserRepository()
|
||||||
|
|
@ -254,7 +303,7 @@ internal class RoomInviteMembersPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
||||||
assertThat(repository.providedQuery).isEqualTo("some query")
|
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||||
repository.emitResult(aMatrixUserList() + selectedUser)
|
repository.emitResult((aMatrixUserList() + selectedUser).map { UserSearchResult(it) })
|
||||||
skipItems(2)
|
skipItems(2)
|
||||||
|
|
||||||
val resultState = awaitItem()
|
val resultState = awaitItem()
|
||||||
|
|
@ -296,7 +345,7 @@ internal class RoomInviteMembersPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
||||||
assertThat(repository.providedQuery).isEqualTo("some query")
|
assertThat(repository.providedQuery).isEqualTo("some query")
|
||||||
repository.emitResult(aMatrixUserList() + selectedUser)
|
repository.emitResult((aMatrixUserList() + selectedUser).map { UserSearchResult(it) })
|
||||||
skipItems(2)
|
skipItems(2)
|
||||||
|
|
||||||
// And then a user is toggled
|
// And then a user is toggled
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -127,6 +129,7 @@ test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "
|
||||||
# Others
|
# Others
|
||||||
coil = { module = "io.coil-kt:coil", version.ref = "coil" }
|
coil = { module = "io.coil-kt:coil", version.ref = "coil" }
|
||||||
coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
|
coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
|
||||||
|
coil_gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }
|
||||||
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
|
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
|
||||||
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
|
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
|
||||||
showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
|
showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
|
||||||
|
|
@ -178,6 +181,6 @@ ktlint = "org.jlleitschuh.gradle.ktlint:11.3.2"
|
||||||
dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" }
|
dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" }
|
||||||
dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" }
|
dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" }
|
||||||
paparazzi = "app.cash.paparazzi:1.2.0"
|
paparazzi = "app.cash.paparazzi:1.2.0"
|
||||||
sonarqube = "org.sonarqube:4.1.0.3113"
|
sonarqube = "org.sonarqube:4.2.0.3129"
|
||||||
kover = "org.jetbrains.kotlinx.kover:0.6.1"
|
kover = "org.jetbrains.kotlinx.kover:0.6.1"
|
||||||
sqldelight = { id = "com.squareup.sqldelight", version.ref = "sqldelight" }
|
sqldelight = { id = "com.squareup.sqldelight", version.ref = "sqldelight" }
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ dependencies {
|
||||||
implementation(libs.dagger)
|
implementation(libs.dagger)
|
||||||
implementation(libs.androidx.corektx)
|
implementation(libs.androidx.corektx)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.architecture)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.designsystem.components.async
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AsyncFailure(
|
||||||
|
throwable: Throwable,
|
||||||
|
onRetry: (() -> Unit)?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(text = throwable.message ?: "An error occurred")
|
||||||
|
if (onRetry != null) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Button(onClick = onRetry) {
|
||||||
|
Text(text = "Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun AsyncFailurePreviewLight() = ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun AsyncFailurePreviewDark() = ElementPreviewDark { ContentToPreview() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview() {
|
||||||
|
AsyncFailure(
|
||||||
|
throwable = IllegalStateException("An error occurred"),
|
||||||
|
onRetry = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.designsystem.components.async
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AsyncLoading(modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun AsyncLoadingPreviewLight() = ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun AsyncLoadingPreviewDark() = ElementPreviewDark { ContentToPreview() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview() {
|
||||||
|
AsyncLoading()
|
||||||
|
}
|
||||||
|
|
@ -22,4 +22,5 @@ sealed class AuthenticationException(message: String) : Exception(message) {
|
||||||
class SlidingSyncNotAvailable(message: String) : AuthenticationException(message)
|
class SlidingSyncNotAvailable(message: String) : AuthenticationException(message)
|
||||||
class SessionMissing(message: String) : AuthenticationException(message)
|
class SessionMissing(message: String) : AuthenticationException(message)
|
||||||
class Generic(message: String) : AuthenticationException(message)
|
class Generic(message: String) : AuthenticationException(message)
|
||||||
|
class OidcError(type: String, message: String) : AuthenticationException(message)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,23 @@ interface MatrixAuthenticationService {
|
||||||
fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?>
|
fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?>
|
||||||
suspend fun setHomeserver(homeserver: String): Result<Unit>
|
suspend fun setHomeserver(homeserver: String): Result<Unit>
|
||||||
suspend fun login(username: String, password: String): Result<SessionId>
|
suspend fun login(username: String, password: String): Result<SessionId>
|
||||||
|
|
||||||
|
/*
|
||||||
|
* OIDC part.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Oidc url to display to the user.
|
||||||
|
*/
|
||||||
|
suspend fun getOidcUrl(): Result<OidcDetails>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel Oidc login sequence.
|
||||||
|
*/
|
||||||
|
suspend fun cancelOidcLogin(): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to login using the [callbackUrl] provided by the Oidc page.
|
||||||
|
*/
|
||||||
|
suspend fun loginWithOidc(callbackUrl: String): Result<SessionId>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,5 +23,5 @@ import kotlinx.parcelize.Parcelize
|
||||||
data class MatrixHomeServerDetails(
|
data class MatrixHomeServerDetails(
|
||||||
val url: String,
|
val url: String,
|
||||||
val supportsPasswordLogin: Boolean,
|
val supportsPasswordLogin: Boolean,
|
||||||
val authenticationIssuer: String?
|
val supportsOidcLogin: Boolean,
|
||||||
): Parcelable
|
): Parcelable
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.auth
|
||||||
|
|
||||||
|
object OidcConfig {
|
||||||
|
const val redirectUri = "io.element:/callback"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.auth
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class OidcDetails(
|
||||||
|
val url: String,
|
||||||
|
) : Parcelable
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
/*
|
||||||
|
* 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.timeline.item.event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants defining known event types from Matrix specifications.
|
||||||
|
*/
|
||||||
|
object EventType {
|
||||||
|
const val PRESENCE = "m.presence"
|
||||||
|
const val MESSAGE = "m.room.message"
|
||||||
|
const val STICKER = "m.sticker"
|
||||||
|
const val ENCRYPTED = "m.room.encrypted"
|
||||||
|
const val FEEDBACK = "m.room.message.feedback"
|
||||||
|
const val TYPING = "m.typing"
|
||||||
|
const val REDACTION = "m.room.redaction"
|
||||||
|
const val RECEIPT = "m.receipt"
|
||||||
|
const val ROOM_KEY = "m.room_key"
|
||||||
|
const val PLUMBING = "m.room.plumbing"
|
||||||
|
const val BOT_OPTIONS = "m.room.bot.options"
|
||||||
|
const val PREVIEW_URLS = "org.matrix.room.preview_urls"
|
||||||
|
|
||||||
|
// State Events
|
||||||
|
|
||||||
|
const val STATE_ROOM_WIDGET_LEGACY = "im.vector.modular.widgets"
|
||||||
|
const val STATE_ROOM_WIDGET = "m.widget"
|
||||||
|
const val STATE_ROOM_NAME = "m.room.name"
|
||||||
|
const val STATE_ROOM_TOPIC = "m.room.topic"
|
||||||
|
const val STATE_ROOM_AVATAR = "m.room.avatar"
|
||||||
|
const val STATE_ROOM_MEMBER = "m.room.member"
|
||||||
|
const val STATE_ROOM_THIRD_PARTY_INVITE = "m.room.third_party_invite"
|
||||||
|
const val STATE_ROOM_CREATE = "m.room.create"
|
||||||
|
const val STATE_ROOM_JOIN_RULES = "m.room.join_rules"
|
||||||
|
const val STATE_ROOM_GUEST_ACCESS = "m.room.guest_access"
|
||||||
|
const val STATE_ROOM_POWER_LEVELS = "m.room.power_levels"
|
||||||
|
|
||||||
|
const val STATE_SPACE_CHILD = "m.space.child"
|
||||||
|
const val STATE_SPACE_PARENT = "m.space.parent"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note that this Event has been deprecated, see
|
||||||
|
* - https://matrix.org/docs/spec/client_server/r0.6.1#historical-events
|
||||||
|
* - https://github.com/matrix-org/matrix-doc/pull/2432
|
||||||
|
*/
|
||||||
|
const val STATE_ROOM_ALIASES = "m.room.aliases"
|
||||||
|
const val STATE_ROOM_TOMBSTONE = "m.room.tombstone"
|
||||||
|
const val STATE_ROOM_CANONICAL_ALIAS = "m.room.canonical_alias"
|
||||||
|
const val STATE_ROOM_HISTORY_VISIBILITY = "m.room.history_visibility"
|
||||||
|
const val STATE_ROOM_RELATED_GROUPS = "m.room.related_groups"
|
||||||
|
const val STATE_ROOM_PINNED_EVENT = "m.room.pinned_events"
|
||||||
|
const val STATE_ROOM_ENCRYPTION = "m.room.encryption"
|
||||||
|
const val STATE_ROOM_SERVER_ACL = "m.room.server_acl"
|
||||||
|
|
||||||
|
// Call Events
|
||||||
|
const val CALL_INVITE = "m.call.invite"
|
||||||
|
const val CALL_CANDIDATES = "m.call.candidates"
|
||||||
|
const val CALL_ANSWER = "m.call.answer"
|
||||||
|
const val CALL_SELECT_ANSWER = "m.call.select_answer"
|
||||||
|
const val CALL_NEGOTIATE = "m.call.negotiate"
|
||||||
|
const val CALL_REJECT = "m.call.reject"
|
||||||
|
const val CALL_HANGUP = "m.call.hangup"
|
||||||
|
|
||||||
|
// This type is not processed by the client, just sent to the server
|
||||||
|
const val CALL_REPLACES = "m.call.replaces"
|
||||||
|
|
||||||
|
// Key share events
|
||||||
|
const val ROOM_KEY_REQUEST = "m.room_key_request"
|
||||||
|
const val FORWARDED_ROOM_KEY = "m.forwarded_room_key"
|
||||||
|
|
||||||
|
const val REQUEST_SECRET = "m.secret.request"
|
||||||
|
const val SEND_SECRET = "m.secret.send"
|
||||||
|
|
||||||
|
// Relation Events
|
||||||
|
const val REACTION = "m.reaction"
|
||||||
|
|
||||||
|
fun isCallEvent(type: String): Boolean {
|
||||||
|
return type == CALL_INVITE ||
|
||||||
|
type == CALL_CANDIDATES ||
|
||||||
|
type == CALL_ANSWER ||
|
||||||
|
type == CALL_HANGUP ||
|
||||||
|
type == CALL_SELECT_ANSWER ||
|
||||||
|
type == CALL_NEGOTIATE ||
|
||||||
|
type == CALL_REJECT ||
|
||||||
|
type == CALL_REPLACES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ dependencies {
|
||||||
// api(projects.libraries.rustsdk)
|
// api(projects.libraries.rustsdk)
|
||||||
implementation(libs.matrix.sdk)
|
implementation(libs.matrix.sdk)
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
|
implementation(projects.services.toolbox.api)
|
||||||
api(projects.libraries.matrix.api)
|
api(projects.libraries.matrix.api)
|
||||||
implementation(libs.dagger)
|
implementation(libs.dagger)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.pusher.PushersService
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
|
|
@ -44,6 +45,7 @@ import io.element.android.libraries.matrix.impl.usersearch.UserProfileMapper
|
||||||
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
|
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
|
||||||
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
|
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -77,6 +79,7 @@ class RustMatrixClient constructor(
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
private val baseDirectory: File,
|
private val baseDirectory: File,
|
||||||
|
private val clock: SystemClock,
|
||||||
) : MatrixClient {
|
) : MatrixClient {
|
||||||
|
|
||||||
override val sessionId: UserId = UserId(client.userId())
|
override val sessionId: UserId = UserId(client.userId())
|
||||||
|
|
@ -114,9 +117,9 @@ class RustMatrixClient constructor(
|
||||||
.timelineLimit(limit = 1u)
|
.timelineLimit(limit = 1u)
|
||||||
.requiredState(
|
.requiredState(
|
||||||
requiredState = listOf(
|
requiredState = listOf(
|
||||||
RequiredState(key = "m.room.avatar", value = ""),
|
RequiredState(key = EventType.STATE_ROOM_AVATAR, value = ""),
|
||||||
RequiredState(key = "m.room.encryption", value = ""),
|
RequiredState(key = EventType.STATE_ROOM_ENCRYPTION, value = ""),
|
||||||
RequiredState(key = "m.room.join_rules", value = ""),
|
RequiredState(key = EventType.STATE_ROOM_JOIN_RULES, value = ""),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.filters(visibleRoomsSlidingSyncFilters)
|
.filters(visibleRoomsSlidingSyncFilters)
|
||||||
|
|
@ -136,9 +139,9 @@ class RustMatrixClient constructor(
|
||||||
.timelineLimit(limit = 1u)
|
.timelineLimit(limit = 1u)
|
||||||
.requiredState(
|
.requiredState(
|
||||||
requiredState = listOf(
|
requiredState = listOf(
|
||||||
RequiredState(key = "m.room.avatar", value = ""),
|
RequiredState(key = EventType.STATE_ROOM_AVATAR, value = ""),
|
||||||
RequiredState(key = "m.room.encryption", value = ""),
|
RequiredState(key = EventType.STATE_ROOM_ENCRYPTION, value = ""),
|
||||||
RequiredState(key = "m.room.canonical_alias", value = ""),
|
RequiredState(key = EventType.STATE_ROOM_CANONICAL_ALIAS, value = ""),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.filters(invitesSlidingSyncFilters)
|
.filters(invitesSlidingSyncFilters)
|
||||||
|
|
@ -153,7 +156,7 @@ class RustMatrixClient constructor(
|
||||||
|
|
||||||
private val slidingSync = client
|
private val slidingSync = client
|
||||||
.slidingSync()
|
.slidingSync()
|
||||||
.homeserver("https://slidingsync.lab.matrix.org")
|
// .homeserver("https://slidingsync.lab.matrix.org")
|
||||||
.withCommonExtensions()
|
.withCommonExtensions()
|
||||||
.storageKey("ElementX")
|
.storageKey("ElementX")
|
||||||
.addList(visibleRoomsSlidingSyncListBuilder)
|
.addList(visibleRoomsSlidingSyncListBuilder)
|
||||||
|
|
@ -215,6 +218,7 @@ class RustMatrixClient constructor(
|
||||||
innerRoom = fullRoom,
|
innerRoom = fullRoom,
|
||||||
coroutineScope = coroutineScope,
|
coroutineScope = coroutineScope,
|
||||||
coroutineDispatchers = dispatchers,
|
coroutineDispatchers = dispatchers,
|
||||||
|
clock = clock,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,15 @@ fun Throwable.mapAuthenticationException(): Throwable {
|
||||||
is RustAuthenticationException.InvalidServerName -> AuthenticationException.InvalidServerName(this.message!!)
|
is RustAuthenticationException.InvalidServerName -> AuthenticationException.InvalidServerName(this.message!!)
|
||||||
is RustAuthenticationException.SessionMissing -> AuthenticationException.SessionMissing(this.message!!)
|
is RustAuthenticationException.SessionMissing -> AuthenticationException.SessionMissing(this.message!!)
|
||||||
is RustAuthenticationException.SlidingSyncNotAvailable -> AuthenticationException.SlidingSyncNotAvailable(this.message!!)
|
is RustAuthenticationException.SlidingSyncNotAvailable -> AuthenticationException.SlidingSyncNotAvailable(this.message!!)
|
||||||
|
|
||||||
|
/* TODO Oidc
|
||||||
|
is RustAuthenticationException.OidcException -> AuthenticationException.OidcError("OidcException", message!!)
|
||||||
|
is RustAuthenticationException.OidcMetadataInvalid -> AuthenticationException.OidcError("OidcMetadataInvalid", message!!)
|
||||||
|
is RustAuthenticationException.OidcMetadataMissing -> AuthenticationException.OidcError("OidcMetadataMissing", message!!)
|
||||||
|
is RustAuthenticationException.OidcNotStarted -> AuthenticationException.OidcError("OidcNotStarted", message!!)
|
||||||
|
is RustAuthenticationException.OidcNotSupported -> AuthenticationException.OidcError("OidcNotSupported", message!!)
|
||||||
|
*/
|
||||||
|
|
||||||
else -> this
|
else -> this
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,6 @@ fun HomeserverLoginDetails.map(): MatrixHomeServerDetails = use {
|
||||||
MatrixHomeServerDetails(
|
MatrixHomeServerDetails(
|
||||||
url = url(),
|
url = url(),
|
||||||
supportsPasswordLogin = supportsPasswordLogin(),
|
supportsPasswordLogin = supportsPasswordLogin(),
|
||||||
authenticationIssuer = authenticationIssuer()
|
supportsOidcLogin = false // TODO Oidc supportsOidcLogin(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.auth
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcConfig
|
||||||
|
// TODO Oidc
|
||||||
|
// import org.matrix.rustcomponents.sdk.OidcClientMetadata
|
||||||
|
|
||||||
|
/*
|
||||||
|
val oidcClientMetadata: OidcClientMetadata = OidcClientMetadata(
|
||||||
|
clientName = "Element",
|
||||||
|
redirectUri = OidcConfig.redirectUri,
|
||||||
|
clientUri = "https://element.io",
|
||||||
|
tosUri = "https://element.io/user-terms-of-service",
|
||||||
|
policyUri = "https://element.io/privacy"
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
@ -24,11 +24,12 @@ 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
|
||||||
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -36,6 +37,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
|
||||||
|
|
@ -49,9 +52,16 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
|
private val clock: SystemClock,
|
||||||
) : 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 +101,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 +113,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,
|
||||||
|
|
@ -115,6 +179,7 @@ class RustMatrixAuthenticationService @Inject constructor(
|
||||||
coroutineScope = coroutineScope,
|
coroutineScope = coroutineScope,
|
||||||
dispatchers = coroutineDispatchers,
|
dispatchers = coroutineDispatchers,
|
||||||
baseDirectory = baseDirectory,
|
baseDirectory = baseDirectory,
|
||||||
|
clock = clock,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.room.roomMembers
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
import io.element.android.libraries.matrix.impl.media.map
|
import io.element.android.libraries.matrix.impl.media.map
|
||||||
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
|
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
|
||||||
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -54,6 +55,7 @@ class RustMatrixRoom(
|
||||||
private val innerRoom: Room,
|
private val innerRoom: Room,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
|
private val clock: SystemClock,
|
||||||
) : MatrixRoom {
|
) : MatrixRoom {
|
||||||
|
|
||||||
override val membersStateFlow: StateFlow<MatrixRoomMembersState>
|
override val membersStateFlow: StateFlow<MatrixRoomMembersState>
|
||||||
|
|
@ -77,9 +79,9 @@ class RustMatrixRoom(
|
||||||
it.rooms.contains(roomId.value)
|
it.rooms.contains(roomId.value)
|
||||||
}
|
}
|
||||||
.map {
|
.map {
|
||||||
System.currentTimeMillis()
|
clock.epochMillis()
|
||||||
}
|
}
|
||||||
.onStart { emit(System.currentTimeMillis()) }
|
.onStart { emit(clock.epochMillis()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun timeline(): MatrixTimeline {
|
override fun timeline(): MatrixTimeline {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
|
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
|
||||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
|
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
|
||||||
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||||
|
|
@ -149,10 +150,10 @@ class RustMatrixTimeline(
|
||||||
runCatching {
|
runCatching {
|
||||||
val settings = RoomSubscription(
|
val settings = RoomSubscription(
|
||||||
requiredState = listOf(
|
requiredState = listOf(
|
||||||
RequiredState(key = "m.room.canonical_alias", value = ""),
|
RequiredState(key = EventType.STATE_ROOM_CANONICAL_ALIAS, value = ""),
|
||||||
RequiredState(key = "m.room.topic", value = ""),
|
RequiredState(key = EventType.STATE_ROOM_TOPIC, value = ""),
|
||||||
RequiredState(key = "m.room.join_rules", value = ""),
|
RequiredState(key = EventType.STATE_ROOM_JOIN_RULES, value = ""),
|
||||||
RequiredState(key = "m.room.power_levels", value = ""),
|
RequiredState(key = EventType.STATE_ROOM_POWER_LEVELS, value = ""),
|
||||||
),
|
),
|
||||||
timelineLimit = null
|
timelineLimit = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,8 @@ const val ANOTHER_MESSAGE = "Hello universe!"
|
||||||
const val A_HOMESERVER_URL = "matrix.org"
|
const val A_HOMESERVER_URL = "matrix.org"
|
||||||
const val A_HOMESERVER_URL_2 = "matrix-client.org"
|
const val A_HOMESERVER_URL_2 = "matrix-client.org"
|
||||||
|
|
||||||
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, true, null)
|
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidcLogin = false)
|
||||||
|
val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true)
|
||||||
|
|
||||||
const val AN_AVATAR_URL = "mxc://data"
|
const val AN_AVATAR_URL = "mxc://data"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,16 +19,22 @@ package io.element.android.libraries.matrix.test.auth
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||||
|
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
||||||
|
val A_OIDC_DATA = OidcDetails(url = "a-url")
|
||||||
|
|
||||||
class FakeAuthenticationService : MatrixAuthenticationService {
|
class FakeAuthenticationService : MatrixAuthenticationService {
|
||||||
private var homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
private var homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
|
||||||
|
private var oidcError: Throwable? = null
|
||||||
|
private var oidcCancelError: Throwable? = null
|
||||||
private var loginError: Throwable? = null
|
private var loginError: Throwable? = null
|
||||||
private var changeServerError: Throwable? = null
|
private var changeServerError: Throwable? = null
|
||||||
|
|
||||||
|
|
@ -53,15 +59,36 @@ class FakeAuthenticationService : MatrixAuthenticationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setHomeserver(homeserver: String): Result<Unit> {
|
override suspend fun setHomeserver(homeserver: String): Result<Unit> {
|
||||||
delay(100)
|
delay(FAKE_DELAY_IN_MS)
|
||||||
return changeServerError?.let { Result.failure(it) } ?: Result.success(Unit)
|
return changeServerError?.let { Result.failure(it) } ?: Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun login(username: String, password: String): Result<SessionId> {
|
override suspend fun login(username: String, password: String): Result<SessionId> {
|
||||||
delay(100)
|
delay(FAKE_DELAY_IN_MS)
|
||||||
return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
|
return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getOidcUrl(): Result<OidcDetails> {
|
||||||
|
return oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun cancelOidcLogin(): Result<Unit> {
|
||||||
|
return oidcCancelError?.let { Result.failure(it) } ?: Result.success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> {
|
||||||
|
delay(FAKE_DELAY_IN_MS)
|
||||||
|
return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun givenOidcError(throwable: Throwable?) {
|
||||||
|
oidcError = throwable
|
||||||
|
}
|
||||||
|
|
||||||
|
fun givenOidcCancelError(throwable: Throwable?) {
|
||||||
|
oidcCancelError = throwable
|
||||||
|
}
|
||||||
|
|
||||||
fun givenLoginError(throwable: Throwable?) {
|
fun givenLoginError(throwable: Throwable?) {
|
||||||
loginError = throwable
|
loginError = throwable
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ dependencies {
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.coil.gif)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
/*
|
||||||
|
* 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.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Checkbox
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||||
|
import io.element.android.libraries.ui.strings.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UnresolvedUserRow(
|
||||||
|
avatarData: AvatarData,
|
||||||
|
id: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp)
|
||||||
|
.height(IntrinsicSize.Min),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Avatar(avatarData)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 12.dp),
|
||||||
|
) {
|
||||||
|
// ID
|
||||||
|
Text(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
text = id,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Warning
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Error,
|
||||||
|
contentDescription = "",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(18.dp)
|
||||||
|
.align(Alignment.Top)
|
||||||
|
.padding(2.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.common_invite_unknown_profile),
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CheckableUnresolvedUserRow(
|
||||||
|
checked: Boolean,
|
||||||
|
avatarData: AvatarData,
|
||||||
|
id: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onCheckedChange: (Boolean) -> Unit = {},
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(role = Role.Checkbox, enabled = enabled) {
|
||||||
|
onCheckedChange(!checked)
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
UnresolvedUserRow(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
avatarData = avatarData,
|
||||||
|
id = id,
|
||||||
|
)
|
||||||
|
|
||||||
|
Checkbox(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun UnresolvedUserRowPreview() =
|
||||||
|
ElementThemedPreview {
|
||||||
|
val matrixUser = aMatrixUser()
|
||||||
|
UnresolvedUserRow(matrixUser.getAvatarData(), matrixUser.userId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun CheckableUnresolvedUserRowPreview() =
|
||||||
|
ElementThemedPreview {
|
||||||
|
val matrixUser = aMatrixUser()
|
||||||
|
Column {
|
||||||
|
CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(), matrixUser.userId.value)
|
||||||
|
CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(), matrixUser.userId.value)
|
||||||
|
CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(), matrixUser.userId.value, enabled = false)
|
||||||
|
CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(), matrixUser.userId.value, enabled = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,8 +17,11 @@
|
||||||
package io.element.android.libraries.matrix.ui.media
|
package io.element.android.libraries.matrix.ui.media
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.ImageLoaderFactory
|
import coil.ImageLoaderFactory
|
||||||
|
import coil.decode.GifDecoder
|
||||||
|
import coil.decode.ImageDecoderDecoder
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
@ -34,6 +37,12 @@ class LoggedInImageLoaderFactory @Inject constructor(
|
||||||
.Builder(context)
|
.Builder(context)
|
||||||
.okHttpClient(okHttpClient)
|
.okHttpClient(okHttpClient)
|
||||||
.components {
|
.components {
|
||||||
|
// Add gif support
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
add(ImageDecoderDecoder.Factory())
|
||||||
|
} else {
|
||||||
|
add(GifDecoder.Factory())
|
||||||
|
}
|
||||||
add(AvatarDataKeyer())
|
add(AvatarDataKeyer())
|
||||||
add(MediaRequestDataKeyer())
|
add(MediaRequestDataKeyer())
|
||||||
add(CoilMediaFetcher.AvatarFactory(matrixClient))
|
add(CoilMediaFetcher.AvatarFactory(matrixClient))
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,13 @@ import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MatrixRoom.getRoomMember(userId: UserId): State<RoomMember?> {
|
fun MatrixRoom.getRoomMemberAsState(userId: UserId): State<RoomMember?> {
|
||||||
val roomMembersState by membersStateFlow.collectAsState()
|
val roomMembersState by membersStateFlow.collectAsState()
|
||||||
return getRoomMember(roomMembersState = roomMembersState, userId = userId)
|
return getRoomMemberAsState(roomMembersState = roomMembersState, userId = userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun getRoomMember(roomMembersState: MatrixRoomMembersState, userId: UserId): State<RoomMember?> {
|
fun getRoomMemberAsState(roomMembersState: MatrixRoomMembersState, userId: UserId): State<RoomMember?> {
|
||||||
val roomMembers = roomMembersState.roomMembers()
|
val roomMembers = roomMembersState.roomMembers()
|
||||||
return remember(roomMembers) {
|
return remember(roomMembers) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,7 @@ class PushersManager @Inject constructor(
|
||||||
appDisplayName = appName,
|
appDisplayName = appName,
|
||||||
deviceDisplayName = currentSession.sessionParams.deviceId ?: "MOBILE"
|
deviceDisplayName = currentSession.sessionParams.deviceId ?: "MOBILE"
|
||||||
)
|
)
|
||||||
|
*/
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getPusherForCurrentSession() {}/*: Pusher? {
|
fun getPusherForCurrentSession() {}/*: Pusher? {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.libraries.push.impl.notifications
|
package io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
|
|
@ -49,7 +50,7 @@ class NotifiableEventProcessor @Inject constructor(
|
||||||
else -> ProcessedEvent.Type.KEEP
|
else -> ProcessedEvent.Type.KEEP
|
||||||
}
|
}
|
||||||
is SimpleNotifiableEvent -> when (it.type) {
|
is SimpleNotifiableEvent -> when (it.type) {
|
||||||
/*EventType.REDACTION*/ "m.room.redaction" -> ProcessedEvent.Type.REMOVE
|
EventType.REDACTION -> ProcessedEvent.Type.REMOVE
|
||||||
else -> ProcessedEvent.Type.KEEP
|
else -> ProcessedEvent.Type.KEEP
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,32 +71,32 @@ class NotifiableEventResolver @Inject constructor(
|
||||||
|
|
||||||
return notificationData.asNotifiableEvent(sessionId)
|
return notificationData.asNotifiableEvent(sessionId)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent {
|
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent {
|
||||||
return NotifiableMessageEvent(
|
return NotifiableMessageEvent(
|
||||||
sessionId = userId,
|
sessionId = userId,
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
editedEventId = null,
|
editedEventId = null,
|
||||||
canBeReplaced = true,
|
canBeReplaced = true,
|
||||||
noisy = isNoisy,
|
noisy = isNoisy,
|
||||||
timestamp = System.currentTimeMillis(),
|
timestamp = clock.epochMillis(),
|
||||||
senderName = senderDisplayName,
|
senderName = senderDisplayName,
|
||||||
senderId = senderId.value,
|
senderId = senderId.value,
|
||||||
body = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}…",
|
body = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}…",
|
||||||
imageUriString = null,
|
imageUriString = null,
|
||||||
threadId = null,
|
threadId = null,
|
||||||
roomName = null,
|
roomName = null,
|
||||||
roomIsDirect = false,
|
roomIsDirect = false,
|
||||||
roomAvatarPath = roomAvatarUrl,
|
roomAvatarPath = roomAvatarUrl,
|
||||||
senderAvatarPath = senderAvatarUrl,
|
senderAvatarPath = senderAvatarUrl,
|
||||||
soundName = null,
|
soundName = null,
|
||||||
outGoingMessage = false,
|
outGoingMessage = false,
|
||||||
outGoingMessageFailed = false,
|
outGoingMessageFailed = false,
|
||||||
isRedacted = false,
|
isRedacted = false,
|
||||||
isUpdated = false
|
isUpdated = false
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
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.ThreadId
|
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||||
import io.element.android.services.appnavstate.api.AppNavigationState
|
import io.element.android.services.appnavstate.api.AppNavigationState
|
||||||
import io.element.android.services.appnavstate.api.currentRoomId
|
import io.element.android.services.appnavstate.api.currentRoomId
|
||||||
import io.element.android.services.appnavstate.api.currentSessionId
|
import io.element.android.services.appnavstate.api.currentSessionId
|
||||||
|
|
@ -52,7 +53,7 @@ data class NotifiableMessageEvent(
|
||||||
override val isUpdated: Boolean = false
|
override val isUpdated: Boolean = false
|
||||||
) : NotifiableEvent {
|
) : NotifiableEvent {
|
||||||
|
|
||||||
val type: String = /* EventType.MESSAGE */ "m.room.message"
|
val type: String = EventType.MESSAGE
|
||||||
val description: String = body ?: ""
|
val description: String = body ?: ""
|
||||||
val title: String = senderName ?: ""
|
val title: String = senderName ?: ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.libraries.push.impl.notifications
|
package io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
|
@ -60,7 +61,7 @@ class NotifiableEventProcessorTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `given redacted simple event when processing then remove redaction event`() {
|
fun `given redacted simple event when processing then remove redaction event`() {
|
||||||
val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID, type = "m.room.redaction"))
|
val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID, type = EventType.REDACTION))
|
||||||
|
|
||||||
val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
|
val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue