Merge branch 'develop' into feature/fga/pdf_renderer

This commit is contained in:
ganfra 2023-06-02 16:43:55 +02:00
commit 26adc55ea9
435 changed files with 6832 additions and 1041 deletions

View file

@ -5,7 +5,7 @@ on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
name: Danger
name: Danger main check
steps:
- uses: actions/checkout@v3
- run: |

View file

@ -12,6 +12,14 @@ env:
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn
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:
name: Project Check Suite
runs-on: ubuntu-latest

View file

@ -43,9 +43,21 @@ jobs:
name: Add labelled issues to QA project
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'Team: QA')
contains(github.event.issue.labels.*.name, 'Team: QA') ||
contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
steps:
- uses: actions/add-to-project@main
with:
project-url: https://github.com/orgs/vector-im/projects/69
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
signoff:
name: Add labelled issues to signoff project
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
steps:
- uses: actions/add-to-project@main
with:
project-url: https://github.com/orgs/vector-im/projects/89
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}

1
.gitignore vendored
View file

@ -48,7 +48,6 @@ captures/
.idea/navEditor.xml
.idea/tasks.xml
.idea/workspace.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches

124
.idea/codeStyles/Project.xml generated Normal file
View 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
View 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
View file

@ -0,0 +1,8 @@
<component name="ProjectDictionaryState">
<dictionary name="shared">
<words>
<w>backstack</w>
<w>textfields</w>
</words>
</dictionary>
</component>

View file

@ -23,7 +23,7 @@ dependencies {
implementation(projects.anvilannotations)
api(libs.anvil.compiler.api)
implementation(libs.anvil.compiler.utils)
implementation("com.squareup:kotlinpoet:1.13.2")
implementation("com.squareup:kotlinpoet:1.14.2")
implementation(libs.dagger)
compileOnly("com.google.auto.service:auto-service-annotations:1.1.0")
kapt("com.google.auto.service:auto-service:1.1.0")

View file

@ -33,7 +33,7 @@
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:exported="true"
android:launchMode="singleInstance"
android:launchMode="singleTop"
android:theme="@style/Theme.ElementX.Splash"
android:windowSoftInputMode="adjustResize">
<intent-filter>
@ -49,6 +49,14 @@
android:host="open"
android:scheme="elementx" />
</intent-filter-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="io.element" />
</intent-filter>
</activity>
<provider

View file

@ -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.inputs
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.RoomMembershipObserver
import io.element.android.services.appnavstate.api.AppNavigationStateService
@ -119,11 +120,20 @@ class RoomFlowNode @AssistedInject constructor(
override fun onRoomDetailsClicked() {
backstack.push(NavTarget.RoomDetails)
}
override fun onUserDataClicked(userId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(userId))
}
}
messagesEntryPoint.createNode(this, buildContext, callback)
}
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
object RoomDetails : NavTarget
@Parcelize
data class RoomMemberDetails(val userId: UserId) : NavTarget
}
private val timeline = inputs.room.timeline()

View file

@ -38,14 +38,17 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.di.MatrixClientsHolder
import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@ -65,7 +68,8 @@ class RootFlowNode @AssistedInject constructor(
private val matrixClientsHolder: MatrixClientsHolder,
private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint,
private val deeplinkParser: DeeplinkParser,
private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow,
) :
BackstackNode<RootFlowNode.NavTarget>(
backstack = BackStack(
@ -204,8 +208,11 @@ class RootFlowNode @AssistedInject constructor(
}
suspend fun handleIntent(intent: Intent) {
deeplinkParser.getFromIntent(intent)
?.let { navigateTo(it) }
val resolvedIntent = intentResolver.resolve(intent) ?: return
when (resolvedIntent) {
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
}
}
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
@ -223,6 +230,10 @@ class RootFlowNode @AssistedInject constructor(
}
}
private fun onOidcAction(oidcAction: OidcAction) {
oidcActionFlow.post(oidcAction)
}
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
return attachChild {
backstack.newRoot(NavTarget.LoggedInFlow(sessionId))

View file

@ -0,0 +1,47 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.appnav.intent
import android.content.Intent
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcIntentResolver
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser
import timber.log.Timber
import javax.inject.Inject
sealed interface ResolvedIntent {
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
}
class IntentResolver @Inject constructor(
private val deeplinkParser: DeeplinkParser,
private val oidcIntentResolver: OidcIntentResolver
) {
fun resolve(intent: Intent): ResolvedIntent? {
val deepLinkData = deeplinkParser.getFromIntent(intent)
if (deepLinkData != null) return ResolvedIntent.Navigation(deepLinkData)
val oidcAction = oidcIntentResolver.resolve(intent)
if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
// Unknown intent
Timber.w("Unknown intent")
return null
}
}

View file

@ -60,7 +60,12 @@ class RoomFlowNodeTest {
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 {
nodeId = it.id
}

1
changelog.d/242.feature Normal file
View file

@ -0,0 +1 @@
[Create and join rooms] Update room properties from room details

1
changelog.d/424.feature Normal file
View 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
View file

@ -0,0 +1 @@
Open room member details when tapping on a user in the timeline

47
docs/oidc.md Normal file
View file

@ -0,0 +1,47 @@
This file contains some rough notes about Oidc implementation, with some examples of actual data.
[ios implementation](https://github.com/vector-im/element-x-ios/compare/develop...doug/oidc-temp)
Rust sdk branch: https://github.com/matrix-org/matrix-rust-sdk/tree/oidc-ffi
Figma https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?node-id=133-5426&t=yQXKeANatk6keoZF-0
Server list: https://github.com/vector-im/oidc-playground
Metadata iOS: (from https://github.com/vector-im/element-x-ios/blob/5f9d07377cebc4f21d9668b1a25f6e3bb22f64a1/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift#L28)
clientName: InfoPlistReader.main.bundleDisplayName,
redirectUri: "io.element:/callback",
clientUri: "https://element.io",
tosUri: "https://element.io/user-terms-of-service",
policyUri: "https://element.io/privacy"
Android:
clientName = "Element",
redirectUri = "io.element:/callback",
clientUri = "https://element.io",
tosUri = "https://element.io/user-terms-of-service",
policyUri = "https://element.io/privacy"
Example of OidcData (from presentUrl callback):
url: https://auth-oidc.lab.element.dev/authorize?response_type=code&client_id=01GYCAGG3PA70CJ97ZVP0WFJY3&redirect_uri=io.element%3A%2Fcallback&scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG&state=ex6mNJVFZ5jn9wL8&nonce=NZ93DOyIGQd9exPQ&code_challenge_method=S256&code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U&prompt=consent
Formatted url:
https://auth-oidc.lab.element.dev/authorize?
response_type=code&
client_id=01GYCAGG3PA70CJ97ZVP0WFJY3&
redirect_uri=io.element%3A%2Fcallback&
scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG&
state=ex6mNJVFZ5jn9wL8&
nonce=NZ93DOyIGQd9exPQ&
code_challenge_method=S256&
code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U&
prompt=consent
state: ex6mNJVFZ5jn9wL8
Oidc client example: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/examples/oidc_cli/src/main.rs
Oidc sdk doc: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/crates/matrix-sdk/src/oidc.rs

View file

@ -79,7 +79,7 @@ Exceptions can occur:
**Important notice 1:** Releases are created from the `develop` branch. So `develop` branch should always contain a "releasable" source code. So when a feature is being implemented with several PRs, it has to be disabled by default (using a feature flag for instance), until the feature is fully implemented. A last PR to enable the feature can then be created.
**Important notice 2:** Database migration: some developers and some people from the community are using the nightly build from `develop`. Multiple database migrations should be properly handled for them. This is OK to have multiple migrations between 2 releases, this is not OK to add steps to the pending database migration on `develop`. So for instance `develop` users will migrate from version 11 to version 12, then 13, then 14, and `main` users will do all those steps after they get the app upgrade.
**Important notice 2:** Database migration: some developers and some people from the community are using the nightly build from `develop`. Multiple database migrations should be properly handled for them. It is OK to have multiple migrations between 2 releases, It is not OK to add steps to existing database migrations on `develop`. So for instance `develop` users will migrate from version 11 to version 12, then 13, then 14, and `main` users will do all those steps after they get the app upgrade.
##### PR Review Assignment

View file

@ -48,8 +48,8 @@ dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(libs.coil.compose)
implementation(projects.libraries.usersearch.impl)
implementation(libs.coil.compose)
api(projects.features.createroom.api)
testImplementation(libs.test.junit)

View file

@ -22,40 +22,49 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
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.model.getAvatarData
import io.element.android.libraries.usersearch.api.UserSearchResult
@Composable
fun SearchMultipleUsersResultItem(
matrixUser: MatrixUser,
searchResult: UserSearchResult,
isUserSelected: Boolean,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {},
) {
CheckableMatrixUserRow(
checked = isUserSelected,
modifier = modifier,
matrixUser = matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
onCheckedChange = onCheckedChange,
)
if (searchResult.isUnresolved) {
CheckableUnresolvedUserRow(
checked = isUserSelected,
modifier = modifier,
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.Custom(36.dp)),
id = searchResult.matrixUser.userId.value,
onCheckedChange = onCheckedChange,
)
} else {
CheckableMatrixUserRow(
checked = isUserSelected,
modifier = modifier,
matrixUser = searchResult.matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
onCheckedChange = onCheckedChange,
)
}
}
@Preview
@Composable
internal fun SearchMultipleUsersResultItemLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun SearchMultipleUsersResultItemDarkPreview() = ElementPreviewDark { ContentToPreview() }
internal fun SearchMultipleUsersResultItemPreview() = ElementThemedPreview { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
SearchMultipleUsersResultItem(matrixUser = aMatrixUser(), isUserSelected = true)
SearchMultipleUsersResultItem(matrixUser = aMatrixUser(), isUserSelected = false)
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), 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)
}
}

View file

@ -17,39 +17,48 @@
package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
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.model.getAvatarData
import io.element.android.libraries.usersearch.api.UserSearchResult
@Composable
fun SearchSingleUserResultItem(
matrixUser: MatrixUser,
searchResult: UserSearchResult,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
MatrixUserRow(
modifier = modifier.clickable(onClick = onClick),
matrixUser = matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
)
if (searchResult.isUnresolved) {
UnresolvedUserRow(
modifier = modifier.clickable(onClick = onClick),
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
@Composable
internal fun SearchSingleUserResultItemLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun SearchSingleUserResultItemDarkPreview() = ElementPreviewDark { ContentToPreview() }
internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview{ ContentToPreview() }
@Composable
private fun ContentToPreview() {
SearchSingleUserResultItem(matrixUser = aMatrixUser())
Column {
SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false))
SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true))
}
}

View file

@ -29,12 +29,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.ui.components.SelectedUsersList
import io.element.android.libraries.ui.strings.R
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
@Composable
fun SearchUserBar(
query: String,
state: SearchBarResultState<ImmutableList<MatrixUser>>,
state: SearchBarResultState<ImmutableList<UserSearchResult>>,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
isMultiSelectionEnabled: Boolean,
@ -68,26 +69,26 @@ fun SearchUserBar(
resultHandler = { users ->
LazyColumn {
if (isMultiSelectionEnabled) {
items(users) { matrixUser ->
items(users) { searchResult ->
SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
isUserSelected = selectedUsers.find { it.userId == matrixUser.userId } != null,
searchResult = searchResult,
isUserSelected = selectedUsers.find { it.userId == searchResult.matrixUser.userId } != null,
onCheckedChange = { checked ->
if (checked) {
onUserSelected(matrixUser)
onUserSelected(searchResult.matrixUser)
} else {
onUserDeselected(matrixUser)
onUserDeselected(searchResult.matrixUser)
}
}
)
}
} else {
items(users) { matrixUser ->
items(users) { searchResult ->
SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
onClick = { onUserSelected(matrixUser) }
searchResult = searchResult,
onClick = { onUserSelected(searchResult.matrixUser) }
)
}
}

View file

@ -17,8 +17,8 @@
package io.element.android.features.createroom.impl.configureroom
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents

View file

@ -27,7 +27,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
@ -37,6 +36,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import kotlinx.collections.immutable.toImmutableList

View file

@ -16,8 +16,8 @@
package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList

View file

@ -17,6 +17,7 @@
package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -24,9 +25,11 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
@ -46,11 +49,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.Avatar
import io.element.android.features.createroom.impl.components.LabelledTextField
import io.element.android.features.createroom.impl.components.RoomPrivacyOption
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarActionListView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
@ -61,7 +62,9 @@ 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.TextButton
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
@ -105,54 +108,48 @@ fun ConfigureRoomView(
)
}
) { padding ->
LazyColumn(
Column(
modifier = Modifier
.padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item {
RoomNameWithAvatar(
modifier = Modifier.padding(horizontal = 16.dp),
avatarUri = state.config.avatarUri,
roomName = state.config.roomName.orEmpty(),
onAvatarClick = ::onAvatarClicked,
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
)
}
item {
RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp),
topic = state.config.topic.orEmpty(),
onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
}
RoomNameWithAvatar(
modifier = Modifier.padding(horizontal = 16.dp),
avatarUri = state.config.avatarUri,
roomName = state.config.roomName.orEmpty(),
onAvatarClick = ::onAvatarClicked,
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
)
RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp),
topic = state.config.topic.orEmpty(),
onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
if (state.config.invites.isNotEmpty()) {
item {
SelectedUsersList(
contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.config.invites,
onUserRemoved = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it))
},
)
}
}
item {
RoomPrivacyOptions(
modifier = Modifier.padding(bottom = 40.dp),
selected = state.config.privacy,
onOptionSelected = {
SelectedUsersList(
contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.config.invites,
onUserRemoved = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy))
state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it))
},
)
}
RoomPrivacyOptions(
modifier = Modifier.padding(bottom = 40.dp),
selected = state.config.privacy,
onOptionSelected = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy))
},
)
}
}
AvatarActionListView(
AvatarActionBottomSheet(
actions = state.avatarActions,
modalBottomSheetState = itemActionsBottomSheetState,
onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
@ -221,16 +218,17 @@ fun RoomNameWithAvatar(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
UnsavedAvatar(
avatarUri = avatarUri,
onClick = onAvatarClick,
modifier = Modifier.clickable(onClick = onAvatarClick),
)
LabelledTextField(
label = stringResource(R.string.screen_create_room_room_name_label),
value = roomName,
placeholder = stringResource(R.string.screen_create_room_room_name_placeholder),
onValueChange = onRoomNameChanged
singleLine = true,
onValueChange = onRoomNameChanged,
)
}
}
@ -269,6 +267,13 @@ fun RoomPrivacyOptions(
}
}
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})
}
@Preview
@Composable
fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
@ -286,10 +291,3 @@ private fun ContentToPreview(state: ConfigureRoomState) {
)
}
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})
}

View file

@ -30,8 +30,8 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
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.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@ -56,7 +56,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults: SearchBarResultState<ImmutableList<MatrixUser>> by remember {
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
mutableStateOf(SearchBarResultState.NotSearching())
}

View file

@ -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.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
data class UserListState(
val searchQuery: String,
val searchResults: SearchBarResultState<ImmutableList<MatrixUser>>,
val searchResults: SearchBarResultState<ImmutableList<UserSearchResult>>,
val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean,
val selectionMode: SelectionMode,

View file

@ -19,6 +19,7 @@ package io.element.android.features.createroom.impl.userlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
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.toImmutableList
@ -38,14 +39,14 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectedUsers = aListOfSelectedUsers(),
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()),
),
aUserListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple,
selectedUsers = aListOfSelectedUsers(),
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()),
),
aUserListState().copy(
isSearchActive = true,

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nová místnost"</string>
<string name="screen_create_room_action_invite_people">"Pozvat lidi"</string>
<string name="screen_create_room_add_people_title">"Přidat lidi"</string>
<string name="screen_create_room_error_creating_room">"Při vytváření místnosti došlo k chybě"</string>
<string name="screen_create_room_private_option_description">"Zprávy v této místnosti jsou šifrované. Šifrování nelze později vypnout."</string>
<string name="screen_create_room_private_option_title">"Soukromá místnost (jen pro pozvané)"</string>
<string name="screen_create_room_public_option_description">"Zprávy nejsou šifrované a může si je přečíst kdokoli. Šifrování můžete povolit později."</string>
<string name="screen_create_room_public_option_title">"Veřejná místnost (kdokoli)"</string>
<string name="screen_create_room_room_name_label">"Název místnosti"</string>
<string name="screen_create_room_room_name_placeholder">"např. Produktový sprint"</string>
<string name="screen_create_room_topic_label">"Téma (nepovinné)"</string>
<string name="screen_create_room_topic_placeholder">"O čem je tato místnost?"</string>
<string name="screen_start_chat_error_starting_chat">"Při pokusu o zahájení chatu došlo k chybě"</string>
<string name="screen_create_room_title">"Vytvořit místnost"</string>
</resources>

View file

@ -3,7 +3,15 @@
<string name="screen_create_room_action_create_room">"Neuer Raum"</string>
<string name="screen_create_room_action_invite_people">"Personen einladen"</string>
<string name="screen_create_room_add_people_title">"Personen hinzufügen"</string>
<string name="screen_create_room_error_creating_room">"Beim Erstellen des Raums ist ein Fehler aufgetreten"</string>
<string name="screen_create_room_private_option_description">"Die Nachrichten in diesem Raum sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."</string>
<string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string>
<string name="screen_create_room_public_option_description">"Nachrichten sind nicht verschlüsselt und jeder kann sie lesen. Du kannst die Verschlüsselung zu einem späteren Zeitpunkt aktivieren."</string>
<string name="screen_create_room_public_option_title">"Öffentlicher Raum (jeder)"</string>
<string name="screen_create_room_room_name_label">"Raumname"</string>
<string name="screen_create_room_room_name_placeholder">"z.B. Produkt-Sprint"</string>
<string name="screen_create_room_topic_label">"Thema (optional)"</string>
<string name="screen_create_room_topic_placeholder">"Worum geht es in diesem Raum?"</string>
<string name="screen_start_chat_error_starting_chat">"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"</string>
<string name="screen_create_room_title">"Raum erstellen"</string>
</resources>

View file

@ -4,6 +4,5 @@
<string name="screen_create_room_action_invite_people">"Invitar gente"</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_unknown_profile">"No podemos validar el ID de Matrix de este usuario. Es posible que no reciba la invitación."</string>
<string name="screen_create_room_title">"Crear una sala"</string>
</resources>
</resources>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nouvelle salle"</string>
<string name="screen_create_room_action_invite_people">"Inviter des personnes"</string>
<string name="screen_create_room_add_people_title">"Ajouter des personnes"</string>
<string name="screen_create_room_error_creating_room">"Une erreur s\'est produite lors de la création de la salle"</string>
<string name="screen_create_room_private_option_description">"Les messages dans cette salle sont chiffrés. Le chiffrement ne peut pas être désactivé par la suite."</string>
<string name="screen_create_room_private_option_title">"Salle privée (sur invitation uniquement)"</string>
<string name="screen_create_room_public_option_description">"Les messages ne sont pas chiffrés et n\'importe qui peut les lire. Vous pouvez activer le chiffrement ultérieurement."</string>
<string name="screen_create_room_public_option_title">"Salle publique (nimporte qui)"</string>
<string name="screen_create_room_room_name_label">"Nom de la salle"</string>
<string name="screen_create_room_room_name_placeholder">"Ex: Sprint Produit"</string>
<string name="screen_create_room_topic_label">"Sujet (optionnel)"</string>
<string name="screen_create_room_topic_placeholder">"De quoi parle cette salle ?"</string>
<string name="screen_start_chat_error_starting_chat">"Une erreur s\'est produite lors de la tentative de démarrage d\'une discussion"</string>
<string name="screen_create_room_title">"Créer une salle"</string>
</resources>

View file

@ -4,6 +4,5 @@
<string name="screen_create_room_action_invite_people">"Invita 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_unknown_profile">"Non possiamo convalidare l\'ID Matrix di questo utente. L\'invito potrebbe non essere ricevuto."</string>
<string name="screen_create_room_title">"Crea una stanza"</string>
</resources>
</resources>

View file

@ -13,6 +13,5 @@
<string name="screen_create_room_topic_label">"Subiect (opțional)"</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_unknown_profile">"Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost primită."</string>
<string name="screen_create_room_title">"Creați o cameră"</string>
</resources>
</resources>

View file

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"New room"</string>
<string name="screen_create_room_action_invite_people">"Invite people"</string>
<string name="screen_create_room_add_people_title">"Add people"</string>
<string name="screen_create_room_action_invite_people">"Invite friends to Element"</string>
<string name="screen_create_room_add_people_title">"Invite people"</string>
<string name="screen_create_room_error_creating_room">"An error occurred when creating the room"</string>
<string name="screen_create_room_private_option_description">"Messages in this room are encrypted. Encryption cant be disabled afterwards."</string>
<string name="screen_create_room_private_option_title">"Private room (invite only)"</string>
@ -13,6 +13,5 @@
<string name="screen_create_room_topic_label">"Topic (optional)"</string>
<string name="screen_create_room_topic_placeholder">"What is this room about?"</string>
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
<string name="screen_start_chat_unknown_profile">"We cant validate this users Matrix ID. The invite might not be received."</string>
<string name="screen_create_room_title">"Create a room"</string>
</resources>

View file

@ -21,9 +21,9 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId

View file

@ -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.matrix.ui.components.aMatrixUser
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 kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
@ -130,18 +131,18 @@ class DefaultUserListPresenterTests {
skipItems(2)
// 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(
SearchBarResultState.Results(
persistentListOf(aMatrixUser())
persistentListOf(UserSearchResult(aMatrixUser()))
)
)
// 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(
SearchBarResultState.Results(
aMatrixUserList()
aMatrixUserList().map { UserSearchResult(it) }
)
)
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_decline_chat_message">"Opravdu chcete odmítnout připojení k %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Odmítnout pozvání"</string>
<string name="screen_invites_decline_direct_chat_message">"Opravdu chcete odmítnout chat s %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Odmítnout chat"</string>
<string name="screen_invites_empty_list">"Žádné pozvánky"</string>
</resources>

View file

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_decline_chat_message">"Möchten Sie den Beitritt zu %1$s wirklich ablehnen?"</string>
<string name="screen_invites_decline_chat_title">"Einladung ablehnen"</string>
<string name="screen_invites_decline_direct_chat_message">"Möchten Sie den Chat mit %1$s wirklich ablehnen?"</string>
<string name="screen_invites_decline_direct_chat_title">"Chat ablehnen"</string>
<string name="screen_invites_empty_list">"Keine Einladungen"</string>
<string name="screen_invites_invited_you">"%1$s hat dich eingeladen"</string>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_empty_list">"Aucune invitation"</string>
<string name="screen_invites_invited_you">"%1$s vous a invité."</string>
</resources>

View file

@ -6,4 +6,4 @@
<string name="screen_invites_decline_direct_chat_title">"Decline chat"</string>
<string name="screen_invites_empty_list">"No Invites"</string>
<string name="screen_invites_invited_you">"%1$s invited you"</string>
</resources>
</resources>

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.api.oidc
sealed interface OidcAction {
object GoBack : OidcAction
data class Success(val url: String) : OidcAction
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.api.oidc
interface OidcActionFlow {
fun post(oidcAction: OidcAction)
}

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.api.oidc
import android.content.Intent
interface OidcIntentResolver {
fun resolve(intent: Intent): OidcAction?
}

View file

@ -45,6 +45,7 @@ dependencies {
implementation(projects.libraries.elementresources)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(libs.androidx.browser)
api(projects.features.login.api)
ksp(libs.showkase.processor)

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2022 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<!-- To open URL in CustomTab (prefetch, etc.). It makes CustomTabsClient.getPackageName() work
see https://developer.android.com/training/package-visibility/use-cases#open-urls-custom-tabs -->
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
</manifest>

View file

@ -16,9 +16,13 @@
package io.element.android.features.login.impl
import android.app.Activity
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -29,17 +33,24 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.changeserver.ChangeServerNode
import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker
import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
import io.element.android.features.login.impl.oidc.webview.OidcNode
import io.element.android.features.login.impl.root.LoginRootNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
class LoginFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
private val customTabHandler: CustomTabHandler,
) : BackstackNode<LoginFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@ -48,6 +59,8 @@ class LoginFlowNode @AssistedInject constructor(
buildContext = buildContext,
plugins = plugins,
) {
private var activity: Activity? = null
private var darkTheme: Boolean = false
sealed interface NavTarget : Parcelable {
@Parcelize
@ -55,6 +68,9 @@ class LoginFlowNode @AssistedInject constructor(
@Parcelize
object ChangeServer : NavTarget
@Parcelize
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -64,15 +80,37 @@ class LoginFlowNode @AssistedInject constructor(
override fun onChangeHomeServer() {
backstack.push(NavTarget.ChangeServer)
}
override fun onOidcDetails(oidcDetails: OidcDetails) {
if (customTabAvailabilityChecker.supportCustomTab()) {
// In this case open a Chrome Custom tab
activity?.let { customTabHandler.open(it, darkTheme, oidcDetails.url) }
} else {
// Fallback to WebView mode
backstack.push(NavTarget.OidcView(oidcDetails))
}
}
}
createNode<LoginRootNode>(buildContext, plugins = listOf(callback))
}
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext)
is NavTarget.OidcView -> {
val input = OidcNode.Inputs(navTarget.oidcDetails)
createNode<OidcNode>(buildContext, plugins = listOf(input))
}
}
}
@Composable
override fun View(modifier: Modifier) {
activity = LocalContext.current as? Activity
darkTheme = !ElementTheme.colors.isLight
DisposableEffect(Unit) {
onDispose {
activity = null
}
}
Children(
navModel = backstack,
modifier = modifier,

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc
import android.content.Context
import androidx.browser.customtabs.CustomTabsClient
import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject
class CustomTabAvailabilityChecker @Inject constructor(
@ApplicationContext private val context: Context,
) {
/**
* Return true if the device supports Custom tab, i.e. there is an third party app with
* CustomTab support (ex: Chrome, Firefox, etc.).
*/
fun supportCustomTab(): Boolean {
val packageName = CustomTabsClient.getPackageName(context, null)
return packageName != null
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc
import android.content.Intent
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcIntentResolver
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultOidcIntentResolver @Inject constructor(
private val oidcUrlParser: OidcUrlParser,
) : OidcIntentResolver {
override fun resolve(intent: Intent): OidcAction? {
return oidcUrlParser.parse(intent.dataString.orEmpty())
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.libraries.matrix.api.auth.OidcConfig
import javax.inject.Inject
/**
* Simple parser for oidc url interception.
* TODO Find documentation about the format.
*/
class OidcUrlParser @Inject constructor() {
// When user press button "Cancel", we get the url:
// `io.element:/callback?error=access_denied&state=IFF1UETGye2ZA8pO`
// On success, we get:
// `io.element:/callback?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB`
/**
* Return a OidcAction, or null if the url is not a OidcUrl.
*/
fun parse(url: String): OidcAction? {
if (url.startsWith(OidcConfig.redirectUri).not()) return null
if (url.contains("error=access_denied")) return OidcAction.GoBack
if (url.contains("code=")) return OidcAction.Success(url)
// Other case not supported, let's crash the app for now
error("Not supported: $url")
}
}

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc.customtab
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.net.Uri
import androidx.browser.customtabs.CustomTabsClient
import androidx.browser.customtabs.CustomTabsServiceConnection
import androidx.browser.customtabs.CustomTabsSession
import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject
class CustomTabHandler @Inject constructor(
@ApplicationContext private val context: Context,
) {
private var customTabsSession: CustomTabsSession? = null
private var customTabsClient: CustomTabsClient? = null
private var customTabsServiceConnection: CustomTabsServiceConnection? = null
fun prepareCustomTab(url: String) {
val packageName = CustomTabsClient.getPackageName(context, null)
// packageName can be null if there are 0 or several CustomTabs compatible browsers installed on the device
if (packageName != null) {
customTabsServiceConnection = object : CustomTabsServiceConnection() {
override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) {
customTabsClient = client
.also { it.warmup(0L) }
prefetchUrl(url)
}
override fun onServiceDisconnected(name: ComponentName?) {
}
}
.also {
CustomTabsClient.bindCustomTabsService(
context,
// Despite the API, packageName cannot be null
packageName,
it
)
}
}
}
private fun prefetchUrl(url: String) {
if (customTabsSession == null) {
customTabsSession = customTabsClient?.newSession(null)
}
customTabsSession?.mayLaunchUrl(Uri.parse(url), null, null)
}
fun disposeCustomTab() {
customTabsServiceConnection?.let { context.unbindService(it) }
customTabsServiceConnection = null
}
fun open(activity: Activity, darkTheme: Boolean, url: String) {
activity.openUrlInChromeCustomTab(customTabsSession, darkTheme, url)
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc.customtab
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultOidcActionFlow @Inject constructor() : OidcActionFlow {
private val mutableStateFlow = MutableStateFlow<OidcAction?>(null)
override fun post(oidcAction: OidcAction) {
mutableStateFlow.value = oidcAction
}
suspend fun collect(collector: FlowCollector<OidcAction?>) {
mutableStateFlow.collect(collector)
}
fun reset() {
mutableStateFlow.value = null
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc.customtab
import android.app.Activity
import android.content.ActivityNotFoundException
import android.net.Uri
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsSession
/**
* Open url in custom tab or, if not available, in the default browser.
* If several compatible browsers are installed, the user will be proposed to choose one.
* Ref: https://developer.chrome.com/multidevice/android/customtabs.
*/
fun Activity.openUrlInChromeCustomTab(
session: CustomTabsSession?,
darkTheme: Boolean,
url: String
) {
try {
CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
// TODO .setToolbarColor(ThemeUtils.getColor(context, android.R.attr.colorBackground))
// TODO .setNavigationBarColor(ThemeUtils.getColor(context, android.R.attr.colorBackground))
.build()
)
.setColorScheme(
when (darkTheme) {
false -> CustomTabsIntent.COLOR_SCHEME_LIGHT
true -> CustomTabsIntent.COLOR_SCHEME_DARK
}
)
// Note: setting close button icon does not work
// .setCloseButtonIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_back_24dp))
// .setStartAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
// .setExitAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
.apply { session?.let { setSession(it) } }
.build()
.launchUrl(this, Uri.parse(url))
} catch (activityNotFoundException: ActivityNotFoundException) {
// TODO context.toast(R.string.error_no_external_application_found)
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc.webview
import io.element.android.features.login.api.oidc.OidcAction
sealed interface OidcEvents {
object Cancel : OidcEvents
data class OidcActionEvent(val oidcAction: OidcAction): OidcEvents
object ClearError : OidcEvents
}

View file

@ -0,0 +1,55 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc.webview
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class)
class OidcNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: OidcPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val oidcDetails: OidcDetails,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.oidcDetails)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
OidcView(
state = state,
modifier = modifier,
onNavigateBack = ::navigateUp,
)
}
}

View file

@ -0,0 +1,100 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc.webview
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.OidcDetails
import kotlinx.coroutines.launch
class OidcPresenter @AssistedInject constructor(
@Assisted private val oidcDetails: OidcDetails,
private val authenticationService: MatrixAuthenticationService,
) : Presenter<OidcState> {
@AssistedFactory
interface Factory {
fun create(oidcDetails: OidcDetails): OidcPresenter
}
@Composable
override fun present(): OidcState {
var requestState: Async<Unit> by remember {
mutableStateOf(Async.Uninitialized)
}
val localCoroutineScope = rememberCoroutineScope()
fun handleCancel() {
requestState = Async.Loading()
localCoroutineScope.launch {
authenticationService.cancelOidcLogin()
.fold(
onSuccess = {
// Then go back
requestState = Async.Success(Unit)
},
onFailure = {
requestState = Async.Failure(it)
}
)
}
}
fun handleSuccess(url: String) {
requestState = Async.Loading()
localCoroutineScope.launch {
authenticationService.loginWithOidc(url)
.onFailure {
requestState = Async.Failure(it)
}
// On success, the node tree will be updated, there is nothing to do
}
}
fun handleAction(action: OidcAction) {
when (action) {
OidcAction.GoBack -> handleCancel()
is OidcAction.Success -> handleSuccess(action.url)
}
}
fun handleEvents(event: OidcEvents) {
when (event) {
OidcEvents.Cancel -> handleCancel()
is OidcEvents.OidcActionEvent -> handleAction(event.oidcAction)
OidcEvents.ClearError -> requestState = Async.Uninitialized
}
}
return OidcState(
oidcDetails = oidcDetails,
requestState = requestState,
eventSink = ::handleEvents
)
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc.webview
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.auth.OidcDetails
data class OidcState(
val oidcDetails: OidcDetails,
val requestState: Async<Unit>,
val eventSink: (OidcEvents) -> Unit
)

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc.webview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.auth.OidcDetails
open class OidcStateProvider : PreviewParameterProvider<OidcState> {
override val values: Sequence<OidcState>
get() = sequenceOf(
aOidcState(),
aOidcState().copy(requestState = Async.Loading()),
)
}
fun aOidcState() = OidcState(
oidcDetails = aOidcDetails(),
requestState = Async.Uninitialized,
eventSink = {}
)
fun aOidcDetails() = OidcDetails(
url = "aUrl",
)

View file

@ -0,0 +1,117 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc.webview
import android.webkit.WebView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.features.login.impl.oidc.OidcUrlParser
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
@Composable
fun OidcView(
state: OidcState,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val oidcUrlParser = remember { OidcUrlParser() }
var webView by remember { mutableStateOf<WebView?>(null) }
fun shouldOverrideUrl(url: String): Boolean {
val action = oidcUrlParser.parse(url)
if (action != null) {
state.eventSink.invoke(OidcEvents.OidcActionEvent(action))
return true
}
return false
}
val oidcWebViewClient = remember {
OidcWebViewClient(::shouldOverrideUrl)
}
BackHandler {
if (webView?.canGoBack().orFalse()) {
webView?.goBack()
} else {
// To properly cancel Oidc login
state.eventSink.invoke(OidcEvents.Cancel)
}
}
Box(modifier = modifier.statusBarsPadding()) {
AndroidView(
factory = { context ->
WebView(context).apply {
webViewClient = oidcWebViewClient
loadUrl(state.oidcDetails.url)
}.also {
webView = it
}
}
)
when (state.requestState) {
Async.Uninitialized -> Unit
is Async.Failure -> {
ErrorDialog(
content = state.requestState.error.toString(),
onDismiss = { state.eventSink(OidcEvents.ClearError) }
)
}
is Async.Loading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
is Async.Success -> onNavigateBack()
}
}
}
@Preview
@Composable
fun OidcViewLightPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun OidcViewDarkPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: OidcState) {
OidcView(
state = state,
onNavigateBack = { },
)
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc.webview
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.os.Build
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
class OidcWebViewClient(
private val eventListener: WebViewEventListener,
) : WebViewClient() {
// We will revert to API 23, in the mean time ignore the warning here.
@SuppressLint("ObsoleteSdkInt")
@TargetApi(Build.VERSION_CODES.N)
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
return shouldOverrideUrl(request.url.toString())
}
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
return shouldOverrideUrl(url)
}
private fun shouldOverrideUrl(url: String): Boolean {
// Timber.d("shouldOverrideUrl: $url")
return eventListener.shouldOverrideUrlLoading(url)
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc.webview
fun interface WebViewEventListener {
/**
* Triggered when a Webview loads an url.
*
* @param url The url about to be rendered.
* @return true if the method needs to manage some custom handling
*/
fun shouldOverrideUrlLoading(url: String): Boolean
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.login.impl.root
sealed interface LoginRootEvents {
object RetryFetchServerInfo : LoginRootEvents
data class SetLogin(val login: String) : LoginRootEvents
data class SetPassword(val password: String) : LoginRootEvents
object Submit : LoginRootEvents

View file

@ -26,6 +26,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class)
class LoginRootNode @AssistedInject constructor(
@ -36,20 +37,26 @@ class LoginRootNode @AssistedInject constructor(
interface Callback : Plugin {
fun onChangeHomeServer()
fun onOidcDetails(oidcDetails: OidcDetails)
}
private fun onChangeHomeServer() {
plugins<Callback>().forEach { it.onChangeHomeServer() }
}
private fun onOidcDetails(oidcDetails: OidcDetails) {
plugins<Callback>().forEach { it.onOidcDetails(oidcDetails) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
LoginRootView(
state = state,
modifier = modifier,
onChangeServer = this::onChangeHomeServer,
onBackPressed = this::navigateUp
onChangeServer = ::onChangeHomeServer,
onOidcDetails = ::onOidcDetails,
onBackPressed = ::navigateUp
)
}
}

View file

@ -17,28 +17,49 @@
package io.element.android.features.login.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class LoginRootPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<LoginRootState> {
private val defaultHomeserver = MatrixHomeServerDetails(LoginConstants.DEFAULT_HOMESERVER_URL, true, null)
class LoginRootPresenter @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val defaultOidcActionFlow: DefaultOidcActionFlow,
) : Presenter<LoginRootState> {
@Composable
override fun present(): LoginRootState {
val localCoroutineScope = rememberCoroutineScope()
val homeserver = authenticationService.getHomeserverDetails().collectAsState().value ?: defaultHomeserver
val currentHomeServerDetails = authenticationService.getHomeserverDetails().collectAsState().value
val homeserver = currentHomeServerDetails?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL
val getHomeServerDetailsAction: MutableState<Async<MatrixHomeServerDetails>> = remember {
if (currentHomeServerDetails != null) {
mutableStateOf(Async.Success(currentHomeServerDetails))
} else {
mutableStateOf(Async.Uninitialized)
}
}
LaunchedEffect(Unit) {
if (currentHomeServerDetails == null) {
getHomeServerDetails(homeserver, getHomeServerDetailsAction)
}
}
val loggedInState: MutableState<LoggedInState> = remember {
mutableStateOf(LoggedInState.NotLoggedIn)
}
@ -46,31 +67,69 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
mutableStateOf(LoginFormState.Default)
}
LaunchedEffect(Unit) {
launch {
defaultOidcActionFlow.collect {
onOidcAction(it, loggedInState)
}
}
}
fun handleEvents(event: LoginRootEvents) {
when (event) {
LoginRootEvents.RetryFetchServerInfo -> localCoroutineScope.getHomeServerDetails(homeserver, getHomeServerDetailsAction)
is LoginRootEvents.SetLogin -> updateFormState(formState) {
copy(login = event.login)
}
is LoginRootEvents.SetPassword -> updateFormState(formState) {
copy(password = event.password)
}
LoginRootEvents.Submit -> localCoroutineScope.submit(homeserver.url, formState.value, loggedInState)
LoginRootEvents.Submit -> {
val homeServerDetails = getHomeServerDetailsAction.value.dataOrNull() ?: return
when {
homeServerDetails.supportsOidcLogin -> localCoroutineScope.submitOidc(loggedInState)
homeServerDetails.supportsPasswordLogin -> localCoroutineScope.submit(formState.value, loggedInState)
}
}
LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn
}
}
return LoginRootState(
homeserverDetails = homeserver,
homeserverUrl = homeserver,
homeserverDetails = getHomeServerDetailsAction.value,
loggedInState = loggedInState.value,
formState = formState.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.submit(homeserver: String, formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
private fun CoroutineScope.getHomeServerDetails(
homeserver: String,
state: MutableState<Async<MatrixHomeServerDetails>>,
) = launch {
suspend {
authenticationService.setHomeserver(homeserver)
.map {
authenticationService.getHomeserverDetails().value!!
}
.getOrThrow()
}.execute(state)
}
private fun CoroutineScope.submitOidc(loggedInState: MutableState<LoggedInState>) = launch {
loggedInState.value = LoggedInState.LoggingIn
authenticationService.getOidcUrl()
.onSuccess {
loggedInState.value = LoggedInState.OidcStarted(it)
}
.onFailure { failure ->
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
}
}
private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
loggedInState.value = LoggedInState.LoggingIn
//TODO rework the setHomeserver flow
authenticationService.setHomeserver(homeserver)
authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId ->
loggedInState.value = LoggedInState.LoggedIn(sessionId)
@ -83,4 +142,30 @@ class LoginRootPresenter @Inject constructor(private val authenticationService:
private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
formState.value = updateLambda(formState.value)
}
private suspend fun onOidcAction(oidcAction: OidcAction?, loggedInState: MutableState<LoggedInState>) {
oidcAction ?: return
loggedInState.value = LoggedInState.LoggingIn
when (oidcAction) {
OidcAction.GoBack -> {
authenticationService.cancelOidcLogin()
.onSuccess {
loggedInState.value = LoggedInState.NotLoggedIn
}
.onFailure { failure ->
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
}
}
is OidcAction.Success -> {
authenticationService.loginWithOidc(oidcAction.url)
.onSuccess { sessionId ->
loggedInState.value = LoggedInState.LoggedIn(sessionId)
}
.onFailure { failure ->
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
}
}
}
defaultOidcActionFlow.reset()
}
}

View file

@ -17,23 +17,31 @@
package io.element.android.features.login.impl.root
import android.os.Parcelable
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize
data class LoginRootState(
val homeserverDetails: MatrixHomeServerDetails,
val homeserverUrl: String,
val homeserverDetails: Async<MatrixHomeServerDetails>,
val loggedInState: LoggedInState,
val formState: LoginFormState,
val eventSink: (LoginRootEvents) -> Unit
) {
val submitEnabled: Boolean get() =
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInState !is LoggedInState.ErrorLoggingIn
val supportPasswordLogin = (homeserverDetails as? Async.Success)?.state?.supportsPasswordLogin.orFalse()
val supportOidcLogin = (homeserverDetails as? Async.Success)?.state?.supportsOidcLogin.orFalse()
val submitEnabled: Boolean
get() = loggedInState !is LoggedInState.ErrorLoggingIn &&
((formState.login.isNotEmpty() && formState.password.isNotEmpty()) || supportOidcLogin)
}
sealed interface LoggedInState {
object NotLoggedIn : LoggedInState
object LoggingIn : LoggedInState
data class OidcStarted(val oidcDetail: OidcDetails) : LoggedInState
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
data class LoggedIn(val sessionId: SessionId) : LoggedInState
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.login.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.core.SessionId
@ -24,16 +25,51 @@ open class LoginRootStateProvider : PreviewParameterProvider<LoginRootState> {
override val values: Sequence<LoginRootState>
get() = sequenceOf(
aLoginRootState(),
aLoginRootState().copy(homeserverDetails = MatrixHomeServerDetails("some-custom-server.com", true, null)),
aLoginRootState().copy(
homeserverDetails = Async.Success(
MatrixHomeServerDetails(
"some-custom-server.com",
supportsPasswordLogin = true,
supportsOidcLogin = false
)
)
),
aLoginRootState().copy(formState = LoginFormState("user", "pass")),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.ErrorLoggingIn(Throwable())),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))),
// Oidc
aLoginRootState().copy(
homeserverUrl = "server-with-oidc.org",
homeserverDetails = Async.Success(
MatrixHomeServerDetails(
"server-with-oidc.org",
supportsPasswordLogin = false,
supportsOidcLogin = true
)
)
),
// No password, no oidc support
aLoginRootState().copy(
homeserverUrl = "wrong.org",
homeserverDetails = Async.Success(
MatrixHomeServerDetails(
"wrong.org",
supportsPasswordLogin = false,
supportsOidcLogin = false
)
)
),
// Loading
aLoginRootState().copy(homeserverDetails = Async.Loading()),
//Error
aLoginRootState().copy(homeserverDetails = Async.Failure(Exception("An error occurred"))),
)
}
fun aLoginRootState() = LoginRootState(
homeserverDetails = MatrixHomeServerDetails("matrix.org", true, null),
homeserverUrl = "matrix.org",
homeserverDetails = Async.Success(MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidcLogin = false)),
loggedInState = LoggedInState.NotLoggedIn,
formState = LoginFormState.Default,
eventSink = {}

View file

@ -68,7 +68,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.error.loginError
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
@ -83,7 +86,7 @@ import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.components.autofill
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR
@ -94,7 +97,7 @@ fun LoginRootView(
state: LoginRootState,
modifier: Modifier = Modifier,
onChangeServer: () -> Unit = {},
onLoginWithSuccess: (SessionId) -> Unit = {},
onOidcDetails: (OidcDetails) -> Unit = {},
onBackPressed: () -> Unit,
) {
val isLoading by remember(state.loggedInState) {
@ -102,6 +105,15 @@ fun LoginRootView(
state.loggedInState == LoggedInState.LoggingIn
}
}
val focusManager = LocalFocusManager.current
fun submit() {
// Clear focus to prevent keyboard issues with textfields
focusManager.clearFocus(force = true)
state.eventSink(LoginRootEvents.Submit)
}
Scaffold(
topBar = {
TopAppBar(
@ -137,19 +149,26 @@ fun LoginRootView(
ChangeServerSection(
interactionEnabled = !isLoading,
homeserver = state.homeserverDetails.url,
homeserver = state.homeserverUrl,
onChangeServer = onChangeServer
)
Spacer(Modifier.height(32.dp))
LoginForm(state = state, isLoading = isLoading)
Spacer(modifier = Modifier.height(32.dp))
when (state.homeserverDetails) {
Async.Uninitialized,
is Async.Loading -> AsyncLoading()
is Async.Failure -> AsyncFailure(
throwable = state.homeserverDetails.error,
onRetry = {
state.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo)
}
)
is Async.Success -> ServerDetailForm(state, isLoading, ::submit)
}
}
when (val loggedInState = state.loggedInState) {
is LoggedInState.LoggedIn -> onLoginWithSuccess(loggedInState.sessionId)
is LoggedInState.OidcStarted -> onOidcDetails(loggedInState.oidcDetail)
else -> Unit
}
}
@ -162,6 +181,43 @@ fun LoginRootView(
}
}
@Composable
fun ServerDetailForm(
state: LoginRootState,
isLoading: Boolean,
submit: () -> Unit,
modifier: Modifier = Modifier,
) {
when {
state.supportOidcLogin -> {
// Oidc, in this case, just display a Spacer and the submit button
Spacer(modifier.height(28.dp))
}
state.supportPasswordLogin -> {
LoginForm(state = state, isLoading = isLoading, onSubmit = submit, modifier = modifier)
}
else -> {
Text(modifier = modifier, text = "No supported login flow")
}
}
Spacer(Modifier.height(28.dp))
if (state.supportOidcLogin || state.supportPasswordLogin) {
// Submit
ButtonWithProgress(
text = stringResource(R.string.screen_login_submit),
showProgress = isLoading,
onClick = submit,
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
Spacer(modifier = Modifier.height(32.dp))
}
}
@Composable
internal fun ChangeServerSection(
interactionEnabled: Boolean,
@ -217,6 +273,7 @@ internal fun ChangeServerSection(
internal fun LoginForm(
state: LoginRootState,
isLoading: Boolean,
onSubmit: () -> Unit,
modifier: Modifier = Modifier
) {
var loginFieldState by textFieldState(stateValue = state.formState.login)
@ -225,13 +282,6 @@ internal fun LoginForm(
val focusManager = LocalFocusManager.current
val eventSink = state.eventSink
fun submit() {
// Clear focus to prevent keyboard issues with textfields
focusManager.clearFocus(force = true)
eventSink(LoginRootEvents.Submit)
}
Column(modifier) {
Text(
text = stringResource(R.string.screen_login_form_header),
@ -318,23 +368,11 @@ internal fun LoginForm(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { submit() }
onDone = { onSubmit() }
),
singleLine = true,
maxLines = 1,
)
Spacer(Modifier.height(28.dp))
// Submit
ButtonWithProgress(
text = stringResource(R.string.screen_login_submit),
showProgress = isLoading,
onClick = ::submit,
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
}
}

View file

@ -18,7 +18,6 @@ package io.element.android.features.login.impl.util
object LoginConstants {
const val DEFAULT_HOMESERVER_URL = "matrix.org"
const val DEFAULT_HOMESERVER_URL = "matrix.org" // TODO Oidc "synapse-oidc.lab.element.dev"
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
}

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_change_server_error_invalid_homeserver">"Nepodařilo se nám připojit k tomuto domovskému serveru. Zkontrolujte prosím, zda jste správně zadali adresu URL domovského serveru. Pokud je adresa URL správná, obraťte se na správce domovského serveru, který vám poskytne další pomoc."</string>
<string name="screen_change_server_form_header">"Adresa URL domovského serveru"</string>
<string name="screen_change_server_subtitle">"Jaká je adresa vašeho serveru?"</string>
<string name="screen_login_error_deactivated_account">"Tento účet byl deaktivován."</string>
<string name="screen_login_error_invalid_credentials">"Nesprávné uživatelské jméno nebo heslo"</string>
<string name="screen_login_error_invalid_user_id">"Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_unsupported_authentication">"Vybraný domovský server nepodporuje přihlášení pomocí hesla nebo OIDC. Kontaktujte prosím svého správce nebo vyberte jiný domovský server."</string>
<string name="screen_login_form_header">"Zadejte své údaje"</string>
<string name="screen_login_server_header">"Kde budou vaše konverzace probíhat"</string>
<string name="screen_login_title">"Vítejte zpět!"</string>
<string name="screen_change_server_submit">"Pokračovat"</string>
<string name="screen_change_server_title">"Vyberte svůj server"</string>
<string name="screen_login_password_hint">"Heslo"</string>
<string name="screen_login_submit">"Pokračovat"</string>
<string name="screen_login_username_hint">"Uživatelské jméno"</string>
</resources>

View file

@ -1,11 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_change_server_error_invalid_homeserver">"Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfen Sie, ob Sie die Homeserver-URL korrekt eingegeben haben. Wenn die URL korrekt ist, wenden Sie sich an Ihren Homeserver-Administrator, um weitere Hilfe zu erhalten."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Dieser Server unterstützt derzeit kein Sliding Sync."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Dieser Server unterstützt derzeit keine Sliding Sync."</string>
<string name="screen_change_server_form_header">"Homeserver-URL"</string>
<string name="screen_change_server_form_notice">"Sie können nur eine Verbindung zu einem vorhandenen Server herstellen, der Sliding Sync unterstützt. Ihr Homeserver-Administrator muss dies konfigurieren. %1$s"</string>
<string name="screen_change_server_form_notice">"Du kannst dich nur mit einem existierenden Server verbinden, der Sliding Sync unterstützt. Dein Homeserver-Administrator muss es konfigurieren. %1$s"</string>
<string name="screen_change_server_subtitle">"Wie lautet die Adresse deines Servers?"</string>
<string name="screen_login_error_deactivated_account">"Dieses Konto wurde deaktiviert."</string>
<string name="screen_login_error_invalid_credentials">"Falscher Benutzername und/oder Passwort"</string>
<string name="screen_login_error_invalid_user_id">"Dies ist kein gültiger Benutzeridentifikator. Erwartetes Format: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_unsupported_authentication">"Der ausgewählte Homeserver unterstützt kein Passwort- oder OIDC-Login. Bitte kontaktiere deinen Admin oder wähle einen anderen Homeserver."</string>
<string name="screen_login_form_header">"Gib deine Daten ein"</string>
<string name="screen_login_server_header">"Wo deine Gespräche leben"</string>
<string name="screen_login_title">"Willkommen zurück!"</string>
<string name="screen_change_server_submit">"Weiter"</string>
<string name="screen_change_server_title">"Wählen deinen Server"</string>
<string name="screen_login_password_hint">"Passwort"</string>
<string name="screen_login_submit">"Weiter"</string>
<string name="screen_login_username_hint">"Benutzername"</string>
</resources>

View file

@ -1,5 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_change_server_error_invalid_homeserver">"Nous n\'avons pas pu atteindre ce serveur domestique. Vérifiez que vous avez correctement saisi l\'URL du serveur d\'accueil. Si l\'URL est correcte, contactez l\'administrateur de votre serveur domestique pour obtenir de l\'aide."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Ce serveur ne prend actuellement pas en charge la synchronisation glissante."</string>
<string name="screen_change_server_form_header">"URL du serveur d\'accueil"</string>
<string name="screen_change_server_form_notice">"Vous ne pouvez vous connecter qu\'à un serveur existant qui prend en charge la synchronisation glissante. L\'administrateur de votre serveur domestique devra la configurer. %1$s"</string>
<string name="screen_change_server_subtitle">"Quelle est l\'adresse de votre serveur ?"</string>
<string name="screen_login_error_deactivated_account">"Ce compte a été désactivé."</string>
<string name="screen_login_error_invalid_credentials">"Nom d\'utilisateur et/ou mot de passe incorrect"</string>
<string name="screen_login_error_invalid_user_id">"Il ne s\'agit pas d\'un identifiant utilisateur valide. Format attendu : « @user:homeserver.org »"</string>
<string name="screen_login_error_unsupported_authentication">"Le serveur domestique sélectionné ne prend pas en charge le mot de passe ou la connexion OIDC. Contactez votre administrateur ou choisissez un autre serveur domestique."</string>
<string name="screen_login_form_header">"Saisir vos informations personnelles"</string>
<string name="screen_login_server_header">"Où se déroulent vos conversations"</string>
<string name="screen_login_title">"Heureux de vous revoir!"</string>
<string name="screen_change_server_submit">"Continuer"</string>
<string name="screen_change_server_title">"Sélectionnez votre serveur"</string>
<string name="screen_login_password_hint">"Mot de passe"</string>
<string name="screen_login_submit">"Continuer"</string>
<string name="screen_login_username_hint">"Nom d\'utilisateur"</string>
</resources>

View file

@ -12,6 +12,7 @@
<string name="screen_login_form_header">"Enter your details"</string>
<string name="screen_login_server_header">"Where your conversations live"</string>
<string name="screen_login_title">"Welcome back!"</string>
<string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string>
<string name="screen_change_server_submit">"Continue"</string>
<string name="screen_change_server_title">"Select your server"</string>
<string name="screen_login_password_hint">"Password"</string>

View file

@ -20,9 +20,9 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
@ -39,7 +39,7 @@ class ChangeServerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER_URL)
assertThat(initialState.homeserver).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
assertThat(initialState.submitEnabled).isTrue()
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.libraries.matrix.api.auth.OidcConfig
import org.junit.Assert
import org.junit.Test
class OidcUrlParserTest {
@Test
fun `test empty url`() {
val sut = OidcUrlParser()
assertThat(sut.parse("")).isNull()
}
@Test
fun `test regular url`() {
val sut = OidcUrlParser()
assertThat(sut.parse("https://matrix.org")).isNull()
}
@Test
fun `test cancel url`() {
val sut = OidcUrlParser()
val aCancelUrl = OidcConfig.redirectUri + "?error=access_denied&state=IFF1UETGye2ZA8pO"
assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack)
}
@Test
fun `test success url`() {
val sut = OidcUrlParser()
val aSuccessUrl = OidcConfig.redirectUri + "?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
assertThat(sut.parse(aSuccessUrl)).isEqualTo(OidcAction.Success(aSuccessUrl))
}
@Test
fun `test unknown url`() {
val sut = OidcUrlParser()
val anUnknownUrl = OidcConfig.redirectUri + "?state=IFF1UETGye2ZA8pO&goat=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
Assert.assertThrows(IllegalStateException::class.java) {
assertThat(sut.parse(anUnknownUrl))
}
}
}

View file

@ -0,0 +1,146 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.login.impl.oidc.webview
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class OidcPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = OidcPresenter(
A_OIDC_DATA,
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.oidcDetails).isEqualTo(A_OIDC_DATA)
assertThat(initialState.requestState).isEqualTo(Async.Uninitialized)
}
}
@Test
fun `present - go back`() = runTest {
val presenter = OidcPresenter(
A_OIDC_DATA,
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.Cancel)
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
val finalState = awaitItem()
assertThat(finalState.requestState).isEqualTo(Async.Success(Unit))
}
}
@Test
fun `present - go back with failure`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = OidcPresenter(
A_OIDC_DATA,
authenticationService,
)
authenticationService.givenOidcCancelError(A_THROWABLE)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.Cancel)
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
val finalState = awaitItem()
assertThat(finalState.requestState).isEqualTo(Async.Failure<Unit>(A_THROWABLE))
// Note: in real life I do not think this can happen, and the app should not block the user.
}
}
@Test
fun `present - user cancels from webview`() = runTest {
val presenter = OidcPresenter(
A_OIDC_DATA,
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.GoBack))
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
val finalState = awaitItem()
assertThat(finalState.requestState).isEqualTo(Async.Success(Unit))
}
}
@Test
fun `present - login success`() = runTest {
val presenter = OidcPresenter(
A_OIDC_DATA,
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.Success("A_URL")))
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
// In this case, no success, the session is created and the node get destroyed.
}
}
@Test
fun `present - login error`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = OidcPresenter(
A_OIDC_DATA,
authenticationService,
)
authenticationService.givenLoginError(A_THROWABLE)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.Success("A_URL")))
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
val errorState = awaitItem()
assertThat(errorState.requestState).isEqualTo(Async.Failure<Unit>(A_THROWABLE))
errorState.eventSink.invoke(OidcEvents.ClearError)
val finalState = awaitItem()
assertThat(finalState.requestState).isEqualTo(Async.Uninitialized)
}
}
}

View file

@ -20,11 +20,18 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
import io.element.android.libraries.matrix.test.A_PASSWORD
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -34,23 +41,91 @@ class LoginRootPresenterTest {
fun `present - initial state`() = runTest {
val presenter = LoginRootPresenter(
FakeAuthenticationService(),
DefaultOidcActionFlow(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserverDetails).isEqualTo(A_HOMESERVER)
assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
assertThat(initialState.submitEnabled).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - initial state server load`() = runTest {
val authenticationService = FakeAuthenticationService()
val oidcActionFlow = DefaultOidcActionFlow()
val presenter = LoginRootPresenter(
authenticationService,
oidcActionFlow,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
assertThat(initialState.submitEnabled).isFalse()
val loadingState = awaitItem()
assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading<MatrixHomeServerDetails>())
authenticationService.givenHomeserver(A_HOMESERVER)
skipItems(1)
val loadedState = awaitItem()
assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER))
}
}
@Test
fun `present - initial state server load error and retry`() = runTest {
val authenticationService = FakeAuthenticationService()
val oidcActionFlow = DefaultOidcActionFlow()
val presenter = LoginRootPresenter(
authenticationService,
oidcActionFlow,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
assertThat(initialState.submitEnabled).isFalse()
val loadingState = awaitItem()
assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading<MatrixHomeServerDetails>())
val aThrowable = Throwable("Error")
authenticationService.givenChangeServerError(aThrowable)
val errorState = awaitItem()
assertThat(errorState.homeserverDetails).isEqualTo(Async.Failure<MatrixHomeServerDetails>(aThrowable))
// Retry
errorState.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo)
val loadingState2 = awaitItem()
assertThat(loadingState2.homeserverDetails).isEqualTo(Async.Loading<MatrixHomeServerDetails>())
authenticationService.givenChangeServerError(null)
authenticationService.givenHomeserver(A_HOMESERVER)
skipItems(1)
val loadedState = awaitItem()
assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER))
}
}
@Test
fun `present - enter login and password`() = runTest {
val authenticationService = FakeAuthenticationService()
val oidcActionFlow = DefaultOidcActionFlow()
val presenter = LoginRootPresenter(
FakeAuthenticationService(),
authenticationService,
oidcActionFlow,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -67,10 +142,99 @@ class LoginRootPresenterTest {
}
@Test
fun `present - submit`() = runTest {
fun `present - oidc login`() = runTest {
val authenticationService = FakeAuthenticationService()
val oidcActionFlow = DefaultOidcActionFlow()
val presenter = LoginRootPresenter(
FakeAuthenticationService(),
authenticationService,
oidcActionFlow,
)
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.submitEnabled).isTrue()
initialState.eventSink.invoke(LoginRootEvents.Submit)
val oidcState = awaitItem()
assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.OidcStarted(A_OIDC_DATA))
}
}
@Test
fun `present - oidc login error`() = runTest {
val authenticationService = FakeAuthenticationService()
val oidcActionFlow = DefaultOidcActionFlow()
val presenter = LoginRootPresenter(
authenticationService,
oidcActionFlow,
)
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
authenticationService.givenOidcError(A_THROWABLE)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.submitEnabled).isTrue()
initialState.eventSink.invoke(LoginRootEvents.Submit)
val oidcState = awaitItem()
assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
}
}
@Test
fun `present - oidc custom tab login`() = runTest {
val authenticationService = FakeAuthenticationService()
val oidcActionFlow = DefaultOidcActionFlow()
val presenter = LoginRootPresenter(
authenticationService,
oidcActionFlow,
)
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.submitEnabled).isTrue()
initialState.eventSink.invoke(LoginRootEvents.Submit)
val oidcState = awaitItem()
assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.OidcStarted(A_OIDC_DATA))
// Oidc cancel, sdk error
authenticationService.givenOidcCancelError(A_THROWABLE)
oidcActionFlow.post(OidcAction.GoBack)
val stateCancelSdkError = awaitItem()
assertThat(stateCancelSdkError.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
// Oidc cancel, sdk OK
authenticationService.givenOidcCancelError(null)
oidcActionFlow.post(OidcAction.GoBack)
val stateCancel = awaitItem()
assertThat(stateCancel.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
// Oidc success, sdk error
authenticationService.givenLoginError(A_THROWABLE)
oidcActionFlow.post(OidcAction.Success(A_OIDC_DATA.url))
val stateSuccessSdkErrorLoading = awaitItem()
assertThat(stateSuccessSdkErrorLoading.loggedInState).isEqualTo(LoggedInState.LoggingIn)
val stateSuccessSdkError = awaitItem()
assertThat(stateSuccessSdkError.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
// Oidc success
authenticationService.givenLoginError(null)
oidcActionFlow.post(OidcAction.Success(A_OIDC_DATA.url))
val stateSuccess = awaitItem()
assertThat(stateSuccess.loggedInState).isEqualTo(LoggedInState.LoggingIn)
val stateSuccessLoggedIn = awaitItem()
assertThat(stateSuccessLoggedIn.loggedInState).isEqualTo(LoggedInState.LoggedIn(A_SESSION_ID))
}
}
@Test
fun `present - submit`() = runTest {
val authenticationService = FakeAuthenticationService()
val oidcActionFlow = DefaultOidcActionFlow()
val presenter = LoginRootPresenter(
authenticationService,
oidcActionFlow,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -90,9 +254,12 @@ class LoginRootPresenterTest {
@Test
fun `present - submit with error`() = runTest {
val authenticationService = FakeAuthenticationService()
val oidcActionFlow = DefaultOidcActionFlow()
val presenter = LoginRootPresenter(
authenticationService,
oidcActionFlow,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -113,14 +280,16 @@ class LoginRootPresenterTest {
@Test
fun `present - clear error`() = runTest {
val authenticationService = FakeAuthenticationService()
val oidcActionFlow = DefaultOidcActionFlow()
val presenter = LoginRootPresenter(
authenticationService,
oidcActionFlow,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Submit will return an error
authenticationService.givenLoginError(A_THROWABLE)
initialState.eventSink(LoginRootEvents.Submit)

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Opravdu se chcete odhlásit?"</string>
<string name="screen_signout_confirmation_dialog_title">"Odhlásit se"</string>
<string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Odhlásit se"</string>
<string name="screen_signout_preference_item">"Odhlásit se"</string>
</resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Möchtest du Dich wirklich abmelden?"</string>
<string name="screen_signout_confirmation_dialog_title">"Abmelden"</string>
<string name="screen_signout_in_progress_dialog_content">"Abmeldung läuft…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Abmelden"</string>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Êtes-vous sûr de vouloir vous déconnecter?"</string>
<string name="screen_signout_confirmation_dialog_title">"Se déconnecter"</string>
<string name="screen_signout_in_progress_dialog_content">"Déconnexion en cours…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Se déconnecter"</string>
<string name="screen_signout_preference_item">"Se déconnecter"</string>
</resources>

View file

@ -20,6 +20,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.UserId
interface MessagesEntryPoint : FeatureEntryPoint {
fun createNode(
@ -30,5 +31,6 @@ interface MessagesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onRoomDetailsClicked()
fun onUserDataClicked(userId: UserId)
}
}

View file

@ -40,6 +40,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.createNode
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 kotlinx.collections.immutable.ImmutableList
import kotlinx.parcelize.Parcelize
@ -90,6 +91,10 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) {
backstack.push(NavTarget.AttachmentPreview(attachments.first()))
}
override fun onUserDataClicked(userId: UserId) {
callback?.onUserDataClicked(userId)
}
}
createNode<MessagesNode>(buildContext, listOf(callback))
}

View file

@ -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.timeline.model.TimelineItem
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class)
@ -43,6 +44,7 @@ class MessagesNode @AssistedInject constructor(
fun onRoomDetailsClicked()
fun onEventClicked(event: TimelineItem.Event)
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
fun onUserDataClicked(userId: UserId)
}
private fun onRoomDetailsClicked() {
@ -57,6 +59,10 @@ class MessagesNode @AssistedInject constructor(
callback?.onPreviewAttachments(attachments)
}
private fun onUserDataClicked(userId: UserId) {
callback?.onUserDataClicked(userId)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@ -66,6 +72,7 @@ class MessagesNode @AssistedInject constructor(
onRoomDetailsClicked = this::onRoomDetailsClicked,
onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked,
modifier = modifier,
)
}

View file

@ -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.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
import timber.log.Timber
@ -97,6 +98,7 @@ fun MessagesView(
onBackPressed: () -> Unit,
onRoomDetailsClicked: () -> Unit,
onEventClicked: (event: TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
modifier: Modifier = Modifier,
) {
@ -153,11 +155,6 @@ fun MessagesView(
}
}
fun onExpandGroupClick(event: TimelineItem.GroupedEvents) {
Timber.v("onExpandGroupClick= ${event.id}")
state.timelineState.eventSink(TimelineEvents.ToggleExpandGroup(event))
}
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
state.eventSink(MessagesEvents.HandleAction(action, event))
}
@ -208,7 +205,7 @@ fun MessagesView(
.consumeWindowInsets(padding),
onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked,
onExpandGroupClick = ::onExpandGroupClick,
onUserDataClicked = onUserDataClicked,
)
},
snackbarHost = {
@ -246,8 +243,8 @@ fun MessagesViewContent(
state: MessagesState,
modifier: Modifier = Modifier,
onMessageClicked: (TimelineItem.Event) -> Unit = {},
onUserDataClicked: (UserId) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit = {},
) {
Column(
modifier = modifier
@ -262,7 +259,7 @@ fun MessagesViewContent(
modifier = Modifier.weight(1f),
onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked,
onExpandGroupClick = onExpandGroupClick,
onUserDataClicked = onUserDataClicked,
)
}
MessageComposerView(
@ -362,6 +359,7 @@ private fun ContentToPreview(state: MessagesState) {
onBackPressed = {},
onRoomDetailsClicked = {},
onEventClicked = {},
onPreviewAttachments = {}
onPreviewAttachments = {},
onUserDataClicked = {},
)
}

View file

@ -16,11 +16,9 @@
package io.element.android.features.messages.impl.timeline
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
sealed interface TimelineEvents {
object LoadMore : TimelineEvents
data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents
data class ToggleExpandGroup(val event: TimelineItem.GroupedEvents) : TimelineEvents
}

View file

@ -20,19 +20,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
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.timeline.MatrixTimeline
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -45,7 +40,6 @@ private const val backPaginationPageSize = 50
class TimelinePresenter @Inject constructor(
private val timelineItemsFactory: TimelineItemsFactory,
private val timelineItemGrouper: TimelineItemGrouper,
room: MatrixRoom,
) : Presenter<TimelineState> {
@ -57,7 +51,6 @@ class TimelinePresenter @Inject constructor(
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
mutableStateOf(null)
}
val expandedGroups = remember { mutableStateMapOf<String, Boolean>() }
val timelineItems = timelineItemsFactory
.flow()
@ -71,9 +64,6 @@ class TimelinePresenter @Inject constructor(
when (event) {
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState.value)
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
is TimelineEvents.ToggleExpandGroup -> {
expandedGroups[event.event.identifier()] = expandedGroups[event.event.identifier()].orFalse().not()
}
}
}
@ -92,7 +82,7 @@ class TimelinePresenter @Inject constructor(
return TimelineState(
highlightedEventId = highlightedEventId.value,
paginationState = paginationState.value,
timelineItems = timelineItemGrouper.group(timelineItems.value, expandedGroups).toImmutableList(),
timelineItems = timelineItems.value,
eventSink = ::handleEvents
)
}

View file

@ -94,7 +94,7 @@ internal fun aTimelineItemEvent(
eventId: EventId = EventId("\$" + Random.nextInt().toString()),
isMine: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First,
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
sendState: EventSendState = EventSendState.Sent(eventId),
): TimelineItem.Event {
return TimelineItem.Event(

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@ -47,8 +48,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -85,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.Icon
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.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@ -93,9 +97,9 @@ import kotlinx.coroutines.launch
fun TimelineView(
state: TimelineState,
modifier: Modifier = Modifier,
onUserDataClicked: (UserId) -> Unit = {},
onMessageClicked: (TimelineItem.Event) -> Unit = {},
onMessageLongClicked: (TimelineItem.Event) -> Unit = {},
onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit = {},
) {
fun onReachedLoadMore() {
@ -119,7 +123,7 @@ fun TimelineView(
highlightedItem = state.highlightedEventId?.value,
onClick = onMessageClicked,
onLongClick = onMessageLongClicked,
onExpandGroupClick = onExpandGroupClick,
onUserDataClick = onUserDataClicked,
)
if (index == state.timelineItems.lastIndex) {
onReachedLoadMore()
@ -139,9 +143,9 @@ fun TimelineView(
fun TimelineItemRow(
timelineItem: TimelineItem,
highlightedItem: String?,
onUserDataClick: (UserId) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
onExpandGroupClick: (TimelineItem.GroupedEvents) -> Unit,
modifier: Modifier = Modifier
) {
when (timelineItem) {
@ -174,13 +178,16 @@ fun TimelineItemRow(
isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = ::onClick,
onLongClick = ::onLongClick,
onUserDataClick = onUserDataClick,
modifier = modifier,
)
}
}
is TimelineItem.GroupedEvents -> {
val isExpanded = rememberSaveable(key = timelineItem.identifier()) { mutableStateOf(false) }
fun onExpandGroupClick() {
onExpandGroupClick(timelineItem)
isExpanded.value = !isExpanded.value
}
Column(modifier = modifier.animateContentSize()) {
@ -190,11 +197,11 @@ fun TimelineItemRow(
count = timelineItem.events.size,
timelineItem.events.size
),
isExpanded = timelineItem.expanded,
isHighlighted = !timelineItem.expanded && timelineItem.events.any { it.identifier() == highlightedItem },
isExpanded = isExpanded.value,
isHighlighted = !isExpanded.value && timelineItem.events.any { it.identifier() == highlightedItem },
onClick = ::onExpandGroupClick,
)
if (timelineItem.expanded) {
if (isExpanded.value) {
Column {
timelineItem.events.forEach { subGroupEvent ->
TimelineItemRow(
@ -202,7 +209,7 @@ fun TimelineItemRow(
highlightedItem = highlightedItem,
onClick = onClick,
onLongClick = onLongClick,
onExpandGroupClick = {}
onUserDataClick = onUserDataClick,
)
}
}
@ -230,15 +237,21 @@ fun TimelineItemEventRow(
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onUserDataClick: (UserId) -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
fun onUserDataClicked() {
onUserDataClick(event.senderId)
}
val (parentAlignment, contentAlignment) = if (event.isMine) {
Pair(Alignment.CenterEnd, Alignment.End)
} else {
Pair(Alignment.CenterStart, Alignment.Start)
}
Box(
modifier = modifier
.fillMaxWidth()
@ -247,14 +260,17 @@ fun TimelineItemEventRow(
) {
Row {
if (!event.isMine) {
Spacer(modifier = Modifier.width(16.dp))
Spacer(modifier = Modifier.width(4.dp))
}
Column(horizontalAlignment = contentAlignment) {
if (event.showSenderInformation) {
MessageSenderInformation(
event.safeSenderName,
event.senderAvatar,
Modifier.zIndex(1f)
Modifier
.zIndex(1f)
.offset(y = 12.dp)
.clickable(onClick = ::onUserDataClicked)
)
}
val bubbleState = BubbleState(
@ -282,7 +298,7 @@ fun TimelineItemEventRow(
reactionsState = event.reactionsState,
modifier = Modifier
.zIndex(1f)
.offset(x = if (event.isMine) 0.dp else 20.dp, y = -(16.dp))
.offset(x = if (event.isMine) 0.dp else 20.dp, y = -(4.dp))
)
}
if (event.isMine) {

View file

@ -20,7 +20,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -35,13 +34,16 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider
import io.element.android.libraries.core.extensions.to01
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
private val BUBBLE_RADIUS = 16.dp
@ -84,10 +86,9 @@ fun MessageEventBubble(
fun Modifier.offsetForItem(): Modifier {
return if (state.isMine) {
// FIXME setting y offset to -12.dp can overlap a state event displayed above.
offset(y = -(12.dp))
this
} else {
offset(x = 20.dp, y = -(12.dp))
offset(x = 20.dp)
}
}
@ -130,7 +131,7 @@ internal fun MessageEventBubbleDarkPreview(@PreviewParameter(BubbleStateProvider
@Composable
private fun ContentToPreview(state: BubbleState) {
// Due to y offset, surround with a Box
// Due to position offset, surround with a Box
Box(
modifier = Modifier
.size(width = 240.dp, height = 64.dp)
@ -141,7 +142,18 @@ private fun ContentToPreview(state: BubbleState) {
state = state,
interactionSource = MutableInteractionSource(),
) {
Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp))
// Render the state as a text to better understand the previews
Box(
modifier = Modifier
.size(width = 120.dp, height = 32.dp)
.padding(horizontal = 12.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text(
fontSize = 10.sp,
text = "${state.groupPosition.javaClass.simpleName} m:${state.isMine.to01()} h:${state.isHighlighted.to01()}"
)
}
}
}
}

View file

@ -25,13 +25,18 @@ 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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@ -48,7 +53,10 @@ fun TimelineEventTimestampView(
val hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null
Row(modifier = modifier.clickable(onClick = onClick)) {
Row(
modifier = modifier.clickable(onClick = onClick),
verticalAlignment = Alignment.CenterVertically,
) {
if (isMessageEdited) {
Text(
stringResource(R.string.common_edited_suffix),
@ -68,3 +76,21 @@ fun TimelineEventTimestampView(
}
}
}
@Preview
@Composable
internal fun TimelineEventTimestampViewLightPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) =
ElementPreviewLight { ContentToPreview(event) }
@Preview
@Composable
internal fun TimelineEventTimestampViewDarkPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) =
ElementPreviewDark { ContentToPreview(event) }
@Composable
private fun ContentToPreview(event: TimelineItem.Event) {
TimelineEventTimestampView(
event = event,
onClick = {}
)
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<TimelineItem.Event> {
override val values: Sequence<TimelineItem.Event>
get() = sequenceOf(
aTimelineItemEvent(),
// Sending failed
aTimelineItemEvent().copy(sendState = EventSendState.SendingFailed("AN_ERROR")),
// Edited
aTimelineItemEvent().copy(content = aTimelineItemTextContent().copy(isEdited = true)),
// Sending failed + Edited (not sure this is possible IRL, but should be covered by test)
aTimelineItemEvent().copy(
sendState = EventSendState.SendingFailed("AN_ERROR"),
content = aTimelineItemTextContent().copy(isEdited = true),
),
)
}

View file

@ -21,9 +21,13 @@ import io.element.android.features.messages.impl.timeline.diff.CacheInvalidator
import io.element.android.features.messages.impl.timeline.diff.MatrixTimelineItemsDiffCallback
import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -38,9 +42,10 @@ class TimelineItemsFactory @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val eventItemFactory: TimelineItemEventFactory,
private val virtualItemFactory: TimelineItemVirtualFactory,
private val timelineItemGrouper: TimelineItemGrouper,
) {
private val timelineItems = MutableStateFlow<List<TimelineItem>>(emptyList())
private val timelineItems = MutableStateFlow(emptyList<TimelineItem>().toImmutableList())
private val timelineItemsCache = arrayListOf<TimelineItem?>()
// Items from rust sdk, used for diffing
@ -49,7 +54,7 @@ class TimelineItemsFactory @Inject constructor(
private val lock = Mutex()
private val cacheInvalidator = CacheInvalidator(timelineItemsCache)
fun flow(): StateFlow<List<TimelineItem>> = timelineItems.asStateFlow()
fun flow(): StateFlow<ImmutableList<TimelineItem>> = timelineItems.asStateFlow()
suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>,
@ -72,7 +77,8 @@ class TimelineItemsFactory @Inject constructor(
newTimelineItemStates.add(cacheItem)
}
}
this.timelineItems.emit(newTimelineItemStates)
val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList()
this.timelineItems.emit(result)
}
private fun calculateAndApplyDiff(newTimelineItems: List<MatrixTimelineItem>) {

View file

@ -16,10 +16,12 @@
package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.messages.impl.timeline.groups.canBeDisplayedInBubbleBlock
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@ -102,10 +104,39 @@ class TimelineItemEventFactory @Inject constructor(
val previousSender = prevTimelineItem?.event?.sender
val nextSender = nextTimelineItem?.event?.sender
val previousIsGroupable = prevTimelineItem?.canBeDisplayedInBubbleBlock().orTrue()
val nextIsGroupable = nextTimelineItem?.canBeDisplayedInBubbleBlock().orTrue()
return when {
previousSender != currentSender && nextSender == currentSender -> TimelineItemGroupPosition.First
previousSender == currentSender && nextSender == currentSender -> TimelineItemGroupPosition.Middle
previousSender == currentSender && nextSender != currentSender -> TimelineItemGroupPosition.Last
previousSender != currentSender && nextSender == currentSender -> {
if (nextIsGroupable) {
TimelineItemGroupPosition.First
} else {
TimelineItemGroupPosition.None
}
}
previousSender == currentSender && nextSender == currentSender -> {
if (previousIsGroupable) {
if (nextIsGroupable) {
TimelineItemGroupPosition.Middle
} else {
TimelineItemGroupPosition.Last
}
} else {
if (nextIsGroupable) {
TimelineItemGroupPosition.First
} else {
TimelineItemGroupPosition.None
}
}
}
previousSender == currentSender /* && nextSender != currentSender (== true) */ -> {
if (previousIsGroupable) {
TimelineItemGroupPosition.Last
} else {
TimelineItemGroupPosition.None
}
}
else -> TimelineItemGroupPosition.None
}
}

View file

@ -0,0 +1,80 @@
/*
* 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.messages.impl.timeline.groups
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
/**
* Return true if the Event can be grouped in a collapse/expand block
* When [canBeGrouped] returns a value, [canBeDisplayedInBubbleBlock] MUST return the opposite value.
* Since the receiving type are not the same, the two functions exist.
*/
internal fun TimelineItem.Event.canBeGrouped(): Boolean {
return when (content) {
is TimelineItemTextBasedContent,
is TimelineItemEncryptedContent,
is TimelineItemImageContent,
is TimelineItemFileContent,
is TimelineItemVideoContent,
TimelineItemRedactedContent,
TimelineItemUnknownContent -> false
is TimelineItemProfileChangeContent,
is TimelineItemRoomMembershipContent,
is TimelineItemStateEventContent -> true
}
}
/**
* Return true if the Event can be grouped in a block of message bubbles.
* When [canBeDisplayedInBubbleBlock] returns a value, [canBeGrouped] MUST return the opposite value.
* Since the receiving type are not the same, the two functions exist.
*/
internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
return when (event.content) {
is FailedToParseMessageLikeContent,
is MessageContent,
RedactedContent,
is StickerContent,
is UnableToDecryptContent -> true
is FailedToParseStateContent,
is ProfileChangeContent,
is RoomMembershipContent,
UnknownContent,
is StateContent -> false
}
}

View file

@ -17,28 +17,14 @@
package io.element.android.features.messages.impl.timeline.groups
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.libraries.core.bool.orFalse
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
class TimelineItemGrouper @Inject constructor() {
/**
* Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents].
*/
fun group(from: List<TimelineItem>, expandedGroups: Map<String, Boolean>): List<TimelineItem> {
fun group(from: List<TimelineItem>): List<TimelineItem> {
val result = mutableListOf<TimelineItem>()
val currentGroup = mutableListOf<TimelineItem.Event>()
from.forEach { timelineItem ->
@ -48,42 +34,24 @@ class TimelineItemGrouper @Inject constructor() {
// timelineItem cannot be grouped
if (currentGroup.isNotEmpty()) {
// There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group.
result.addGroup(currentGroup, expandedGroups)
result.addGroup(currentGroup)
currentGroup.clear()
}
result.add(timelineItem)
}
}
if (currentGroup.isNotEmpty()) {
result.addGroup(currentGroup, expandedGroups)
result.addGroup(currentGroup)
}
return result
}
private fun TimelineItem.Event.canBeGrouped(): Boolean {
return when (content) {
is TimelineItemEncryptedContent,
is TimelineItemImageContent,
TimelineItemRedactedContent,
is TimelineItemEmoteContent,
is TimelineItemNoticeContent,
is TimelineItemTextContent,
is TimelineItemFileContent,
is TimelineItemVideoContent,
TimelineItemUnknownContent -> false
is TimelineItemProfileChangeContent,
is TimelineItemRoomMembershipContent,
is TimelineItemStateEventContent -> true
}
}
}
/**
* Will add a group if there is more than 1 item, else add the item to the list.
*/
private fun MutableList<TimelineItem>.addGroup(
group: MutableList<TimelineItem.Event>,
expandedGroups: Map<String, Boolean>,
group: MutableList<TimelineItem.Event>
) {
if (group.size == 1) {
// Do not create a group with just 1 item, just add the item to the result
@ -91,7 +59,6 @@ private fun MutableList<TimelineItem>.addGroup(
} else {
add(
TimelineItem.GroupedEvents(
expanded = expandedGroups[group.first().id + "_group"].orFalse(),
events = group.toImmutableList()
)
)

View file

@ -68,10 +68,9 @@ sealed interface TimelineItem {
@Immutable
data class GroupedEvents(
val expanded: Boolean,
val events: ImmutableList<Event>,
) : TimelineItem {
// use first id with a suffix
val id = events.first().id + "_group"
// use last id with a suffix. Last will not change in cas of new event from backpagination.
val id = events.last().id + "_group"
}
}

View file

@ -18,13 +18,48 @@ package io.element.android.features.messages.impl.timeline.model
import androidx.compose.runtime.Immutable
/**
* Attribute for a TimelineItem, used to render successive events from the same sender differently.
*
* Possible sequences in the timeline will be:
*
* Only one Event:
* - [None]
*
* Two Events
* - [First]
* - [Last]
*
* Many Events:
* - [First]
* - [Middle] (repeated if necessary)
* - [Last]
*/
@Immutable
sealed interface TimelineItemGroupPosition {
/**
* The event is part of a group of events from the same sender and is the first sent Event.
*/
object First : TimelineItemGroupPosition
/**
* The event is part of a group of events from the same sender and is neither the first nor the last sent Event.
*/
object Middle : TimelineItemGroupPosition
/**
* The event is part of a group of events from the same sender and is the last sent Event.
*/
object Last : TimelineItemGroupPosition
/**
* The event is not part of a group of events. Sender of previous event is different, and sender of next event is different.
*/
object None : TimelineItemGroupPosition
/**
* Return true if the previous sender of the event is a different sender.
*/
fun isNew(): Boolean = when (this) {
First, None -> true
else -> false

View file

@ -25,6 +25,7 @@ open class BubbleStateProvider : PreviewParameterProvider<BubbleState> {
TimelineItemGroupPosition.First,
TimelineItemGroupPosition.Middle,
TimelineItemGroupPosition.Last,
TimelineItemGroupPosition.None,
).map { groupPosition ->
sequenceOf(false, true).map { isMine ->
sequenceOf(false, true).map { isHighlighted ->

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_attachment_source_camera">"Fotoaparát"</string>
<string name="screen_room_attachment_source_camera_photo">"Vyfotit"</string>
<string name="screen_room_attachment_source_camera_video">"Natočit video"</string>
<string name="screen_room_attachment_source_files">"Příloha"</string>
<string name="screen_room_attachment_source_gallery">"Knihovna fotografií a videí"</string>
</resources>

View file

@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d Raumänderung"</item>
<item quantity="other">"%1$d Raumänderungen"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"Kamera"</string>
<string name="screen_room_attachment_source_camera_photo">"Foto aufnehmen"</string>
<string name="screen_room_attachment_source_camera_video">"Video aufnehmen"</string>

Some files were not shown because too many files have changed in this diff Show more