diff --git a/.github/renovate.json b/.github/renovate.json
index 11f993516f..f9e1469496 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -4,7 +4,6 @@
"config:base"
],
"labels": ["dependencies"],
- "reviewers": ["team:element-x-android-reviewers"],
"ignoreDeps": ["string:app_name"],
"packageRules": [
{
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 2f7c10a3d0..7d240080b0 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -1,6 +1,7 @@
name: Build and release nightly APK
on:
+ workflow_dispatch:
schedule:
# Every nights at 4
- cron: "0 4 * * *"
diff --git a/.github/workflows/nightly_manual.yml b/.github/workflows/nightly_manual.yml
deleted file mode 100644
index 6e8ef7c684..0000000000
--- a/.github/workflows/nightly_manual.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-name: Build and release nightly APK manually
-
-on:
- workflow_dispatch
-
-env:
- GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
- CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
-
-jobs:
- nightly:
- name: Build and publish nightly APK to Firebase
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v3
- - name: Use JDK 17
- uses: actions/setup-java@v3
- with:
- distribution: 'temurin' # See 'Supported distributions' for available options
- java-version: '17'
- - name: Install towncrier
- run: |
- python3 -m pip install towncrier
- - name: Prepare changelog file
- run: |
- mv towncrier.toml towncrier.toml.bak
- sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml
- rm towncrier.toml.bak
- yes n | towncrier build --version nightly
- - name: Build and upload Nightly APK
- run: |
- ./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES
- env:
- ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
- ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
- ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
- FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }}
- - name: Additionally upload Nightly APK to browserstack for testing
- continue-on-error: true # don't block anything by this upload failing (for now)
- run: curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/nightly/app-universal-nightly.apk" -F "custom_id=element-x-android-nightly"
- env:
- BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }}
- BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }}
diff --git a/.maestro/tests/assertions/assertRoomListSynced.yaml b/.maestro/tests/assertions/assertRoomListSynced.yaml
new file mode 100644
index 0000000000..2d13c17df9
--- /dev/null
+++ b/.maestro/tests/assertions/assertRoomListSynced.yaml
@@ -0,0 +1,5 @@
+appId: ${APP_ID}
+---
+- extendedWaitUntil:
+ visible: ${ROOM_NAME}
+ timeout: 10_000
diff --git a/.maestro/tests/init.yaml b/.maestro/tests/init.yaml
index 656c654fcb..acd5f86dfd 100644
--- a/.maestro/tests/init.yaml
+++ b/.maestro/tests/init.yaml
@@ -3,6 +3,5 @@ appId: ${APP_ID}
- clearState
- launchApp:
clearKeychain: true
-- tapOn: "Close showkase button"
- runFlow: ./assertions/assertInitDisplayed.yaml
- takeScreenshot: build/maestro/000-FirstScreen
diff --git a/.maestro/tests/roomList/searchRoomList.yaml b/.maestro/tests/roomList/searchRoomList.yaml
index fe859defbb..45138eb9aa 100644
--- a/.maestro/tests/roomList/searchRoomList.yaml
+++ b/.maestro/tests/roomList/searchRoomList.yaml
@@ -1,5 +1,6 @@
appId: ${APP_ID}
---
+- runFlow: ../assertions/assertRoomListSynced.yaml
- tapOn: "search"
- inputText: ${ROOM_NAME.substring(0, 3)}
- takeScreenshot: build/maestro/400-SearchRoom
diff --git a/CODEOWNERS b/CODEOWNERS
new file mode 100644
index 0000000000..9db04197d5
--- /dev/null
+++ b/CODEOWNERS
@@ -0,0 +1 @@
+* @vector-im/element-x-android-reviewers
diff --git a/anvilcodegen/build.gradle.kts b/anvilcodegen/build.gradle.kts
index 0edb358423..dd6033c7e4 100644
--- a/anvilcodegen/build.gradle.kts
+++ b/anvilcodegen/build.gradle.kts
@@ -23,7 +23,7 @@ dependencies {
implementation(projects.anvilannotations)
api(libs.anvil.compiler.api)
implementation(libs.anvil.compiler.utils)
- implementation("com.squareup:kotlinpoet:1.13.1")
+ implementation("com.squareup:kotlinpoet:1.13.2")
implementation(libs.dagger)
compileOnly("com.google.auto.service:auto-service-annotations:1.0.1")
kapt("com.google.auto.service:auto-service:1.0.1")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4a81d52d46..22a63b60c9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -29,15 +29,6 @@
android:theme="@style/Theme.ElementX"
tools:targetApi="33">
-
-
-
-
+
+
+
+
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
index 519c4e734b..32f45bd77d 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
@@ -16,7 +16,6 @@
package io.element.android.appnav
-import android.app.Activity
import android.content.Intent
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
@@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
@@ -53,7 +51,6 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
-import io.element.android.tests.uitests.openShowkase
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -140,17 +137,11 @@ class RootFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
- val activity = LocalContext.current as Activity
- fun openShowkase() {
- openShowkase(activity)
- }
-
val state = presenter.present()
RootView(
state = state,
modifier = modifier,
onOpenBugReport = this::onOpenBugReport,
- onOpenShowkase = ::openShowkase
) {
Children(
navModel = backstack,
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt
index a3b73bc4d9..9ac4d34b98 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt
@@ -17,8 +17,6 @@
package io.element.android.appnav.root
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter
import io.element.android.libraries.architecture.Presenter
@@ -31,23 +29,12 @@ class RootPresenter @Inject constructor(
@Composable
override fun present(): RootState {
- val isShowkaseButtonVisible = rememberSaveable {
- mutableStateOf(true)
- }
val rageshakeDetectionState = rageshakeDetectionPresenter.present()
val crashDetectionState = crashDetectionPresenter.present()
- fun handleEvent(event: RootEvents) {
- when (event) {
- RootEvents.HideShowkaseButton -> isShowkaseButtonVisible.value = false
- }
- }
-
return RootState(
- isShowkaseButtonVisible = isShowkaseButtonVisible.value,
rageshakeDetectionState = rageshakeDetectionState,
crashDetectionState = crashDetectionState,
- eventSink = ::handleEvent
)
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt
index ab3be17746..8389e1f144 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt
@@ -22,8 +22,6 @@ import io.element.android.features.rageshake.api.detection.RageshakeDetectionSta
@Immutable
data class RootState(
- val isShowkaseButtonVisible: Boolean,
val rageshakeDetectionState: RageshakeDetectionState,
val crashDetectionState: CrashDetectionState,
- val eventSink: (RootEvents) -> Unit
)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt
index 9ed9d365f2..645fbccd0d 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt
@@ -24,12 +24,10 @@ open class RootStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aRootState().copy(
- isShowkaseButtonVisible = true,
rageshakeDetectionState = aRageshakeDetectionState().copy(showDialog = false),
crashDetectionState = aCrashDetectionState().copy(crashDetected = true),
),
aRootState().copy(
- isShowkaseButtonVisible = true,
rageshakeDetectionState = aRageshakeDetectionState().copy(showDialog = true),
crashDetectionState = aCrashDetectionState().copy(crashDetected = false),
)
@@ -37,8 +35,6 @@ open class RootStateProvider : PreviewParameterProvider {
}
fun aRootState() = RootState(
- isShowkaseButtonVisible = false,
rageshakeDetectionState = aRageshakeDetectionState(),
crashDetectionState = aCrashDetectionState(),
- eventSink = {}
)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt
index e5eb25704d..fc25ee65b1 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt
@@ -37,7 +37,6 @@ fun RootView(
state: RootState,
modifier: Modifier = Modifier,
onOpenBugReport: () -> Unit = {},
- onOpenShowkase: () -> Unit = {},
children: @Composable BoxScope.() -> Unit,
) {
Box(
@@ -46,7 +45,6 @@ fun RootView(
contentAlignment = Alignment.TopCenter,
) {
children()
- val eventSink = state.eventSink
fun onOpenBugReport() {
state.crashDetectionState.eventSink(CrashDetectionEvents.ResetAppHasCrashed)
@@ -54,11 +52,6 @@ fun RootView(
onOpenBugReport.invoke()
}
- ShowkaseButton(
- isVisible = state.isShowkaseButtonVisible,
- onCloseClicked = { eventSink(RootEvents.HideShowkaseButton) },
- onClick = onOpenShowkase
- )
RageshakeDetectionView(
state = state.rageshakeDetectionState,
onOpenBugReport = ::onOpenBugReport,
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/ShowkaseButton.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/ShowkaseButton.kt
deleted file mode 100644
index 23dbbd4397..0000000000
--- a/appnav/src/main/kotlin/io/element/android/appnav/root/ShowkaseButton.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * 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.root
-
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Close
-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.preview.ElementPreviewDark
-import io.element.android.libraries.designsystem.preview.ElementPreviewLight
-import io.element.android.libraries.designsystem.theme.components.Button
-import io.element.android.libraries.designsystem.theme.components.Icon
-import io.element.android.libraries.designsystem.theme.components.IconButton
-import io.element.android.libraries.designsystem.theme.components.Text
-
-@Composable
-internal fun ShowkaseButton(
- isVisible: Boolean,
- modifier: Modifier = Modifier,
- onClick: () -> Unit = {},
- onCloseClicked: () -> Unit = {},
-) {
- if (isVisible) {
- Button(
- modifier = modifier
- .padding(top = 32.dp),
- onClick = onClick
- ) {
- Text(text = "Showkase Browser")
- IconButton(
- modifier = Modifier
- .padding(start = 8.dp)
- .size(16.dp),
- onClick = onCloseClicked,
- ) {
- Icon(imageVector = Icons.Filled.Close, contentDescription = "Close showkase button")
- }
- }
- }
-}
-
-@Preview
-@Composable
-internal fun ShowkaseButtonLightPreview() = ElementPreviewLight { ContentToPreview() }
-
-@Preview
-@Composable
-internal fun ShowkaseButtonDarkPreview() = ElementPreviewDark { ContentToPreview() }
-
-@Composable
-private fun ContentToPreview() {
- ShowkaseButton(isVisible = true)
-}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt
index d0ad2adb2e..7c45620afd 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt
@@ -14,15 +14,12 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.appnav
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.appnav.root.RootEvents
import io.element.android.appnav.root.RootPresenter
import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter
import io.element.android.features.rageshake.impl.detection.DefaultRageshakeDetectionPresenter
@@ -31,7 +28,6 @@ import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -44,21 +40,7 @@ class RootPresenterTest {
}.test {
skipItems(1)
val initialState = awaitItem()
- assertThat(initialState.isShowkaseButtonVisible).isTrue()
- }
- }
-
- @Test
- fun `present - hide showkase button`() = runTest {
- val presenter = createPresenter()
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- skipItems(1)
- val initialState = awaitItem()
- assertThat(initialState.isShowkaseButtonVisible).isTrue()
- initialState.eventSink.invoke(RootEvents.HideShowkaseButton)
- assertThat(awaitItem().isShowkaseButtonVisible).isFalse()
+ assertThat(initialState.crashDetectionState.crashDetected).isFalse()
}
}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
index 71d303150b..2fd6d2d8f7 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.appnav.loggedin
import app.cash.molecule.RecompositionClock
@@ -29,7 +27,6 @@ import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.providers.api.Distributor
import io.element.android.libraries.push.providers.api.PushProvider
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/build.gradle.kts b/build.gradle.kts
index 2ce2922189..3d049e5376 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -285,12 +285,15 @@ tasks.register("runQualityChecks") {
// Make sure to delete old screenshots before recording new ones
subprojects {
- val snapshotsDir = File("${project.path}/src/test/snapshots")
+ val snapshotsDir = File("${project.projectDir}/src/test/snapshots")
val removeOldScreenshotsTask = tasks.register("removeOldSnapshots") {
onlyIf { snapshotsDir.exists() }
doFirst {
+ println("Delete previous screenshots located at $snapshotsDir\n")
snapshotsDir.deleteRecursively()
}
}
tasks.findByName("recordPaparazzi")?.dependsOn(removeOldScreenshotsTask)
+ tasks.findByName("recordPaparazziDebug")?.dependsOn(removeOldScreenshotsTask)
+ tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask)
}
diff --git a/changelog.d/385.feature b/changelog.d/385.feature
new file mode 100644
index 0000000000..5b6cfbfff1
--- /dev/null
+++ b/changelog.d/385.feature
@@ -0,0 +1 @@
+Show pending invitations in room members list
diff --git a/changelog.d/396.feature b/changelog.d/396.feature
new file mode 100644
index 0000000000..17e5a2da77
--- /dev/null
+++ b/changelog.d/396.feature
@@ -0,0 +1 @@
+Add media pre-processing before uploading it.
diff --git a/docs/screenshot_testing.md b/docs/screenshot_testing.md
index cf1973ab4e..37299af7fc 100644
--- a/docs/screenshot_testing.md
+++ b/docs/screenshot_testing.md
@@ -30,11 +30,16 @@ If installed correctly, `git push` and `git pull` will now include LFS content.
## Recording
-It's recommended to delete the content of the folder `/snapshots` before recording.
+```shell
+./gradlew recordPaparazziDebug
+```
+
+The task will delete the content of the folder `/snapshots` before recording (see the task `removeOldSnapshots` defined in the project).
+
+If this is not the case, you can run
```shell
rm -rf ./tests/uitests/src/test/snapshots
-./gradlew recordPaparazziDebug
```
Paparazzi will generate images in `:tests:uitests/src/test/snapshots`, which will need to be committed to the repository using Git LFS.
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt
index 2575c8b66f..a5a78e54d5 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt
@@ -23,6 +23,7 @@ import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
@@ -76,10 +77,11 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
backstack.push(NavTarget.ConfigureRoom)
}
}
- createNode(context = buildContext, plugins = plugins.plus(callback))
+ createNode(context = buildContext, plugins = listOf(callback))
}
NavTarget.ConfigureRoom -> {
- createNode(context = buildContext, plugins = plugins)
+ val callbacks = plugins()
+ createNode(context = buildContext, plugins = callbacks)
}
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
index 060bb447e7..2ac5b8691a 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
@@ -39,10 +39,8 @@ class ConfigureRoomNode @AssistedInject constructor(
fun onCreateRoomSuccess(roomId: RoomId)
}
- private val callback = object : Callback {
- override fun onCreateRoomSuccess(roomId: RoomId) {
- plugins().forEach { it.onCreateRoomSuccess(roomId) }
- }
+ private fun onRoomCreated(roomId: RoomId) {
+ plugins().forEach { it.onCreateRoomSuccess(roomId) }
}
@Composable
@@ -52,7 +50,7 @@ class ConfigureRoomNode @AssistedInject constructor(
state = state,
modifier = modifier,
onBackPressed = this::navigateUp,
- onRoomCreated = callback::onCreateRoomSuccess
+ onRoomCreated = this::onRoomCreated
)
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
index 60056872db..0ac9611fc7 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
@@ -65,11 +65,12 @@ class CreateRoomRootPresenter @Inject constructor(
fun startDm(matrixUser: MatrixUser) {
startDmAction.value = Async.Uninitialized
- val existingDM = matrixClient.findDM(matrixUser.userId)
- if (existingDM == null) {
- localCoroutineScope.createDM(matrixUser, startDmAction)
- } else {
- startDmAction.value = Async.Success(existingDM.roomId)
+ matrixClient.findDM(matrixUser.userId).use { existingDM ->
+ if (existingDM == null) {
+ localCoroutineScope.createDM(matrixUser, startDmAction)
+ } else {
+ startDmAction.value = Async.Success(existingDM.roomId)
+ }
}
}
diff --git a/features/createroom/impl/src/main/res/values-es/translations.xml b/features/createroom/impl/src/main/res/values-es/translations.xml
index bb3d6fa0b8..8c8fc9b48f 100644
--- a/features/createroom/impl/src/main/res/values-es/translations.xml
+++ b/features/createroom/impl/src/main/res/values-es/translations.xml
@@ -5,4 +5,5 @@
"Añadir personas"
"Se ha producido un error al intentar iniciar un chat"
"No podemos validar el ID de Matrix de este usuario. Es posible que no reciba la invitación."
+ "Crear una sala"
\ No newline at end of file
diff --git a/features/createroom/impl/src/main/res/values-it/translations.xml b/features/createroom/impl/src/main/res/values-it/translations.xml
index 1d6ce99b5f..3562a5f017 100644
--- a/features/createroom/impl/src/main/res/values-it/translations.xml
+++ b/features/createroom/impl/src/main/res/values-it/translations.xml
@@ -5,4 +5,5 @@
"Aggiungi persone"
"Si è verificato un errore durante il tentativo di avviare una chat"
"Non possiamo convalidare l\'ID Matrix di questo utente. L\'invito potrebbe non essere ricevuto."
+ "Crea una stanza"
\ No newline at end of file
diff --git a/features/createroom/impl/src/main/res/values-ro/translations.xml b/features/createroom/impl/src/main/res/values-ro/translations.xml
index a1ea3b31f0..8fd60b5a9e 100644
--- a/features/createroom/impl/src/main/res/values-ro/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ro/translations.xml
@@ -10,9 +10,9 @@
"Cameră publică (oricine)"
"Numele camerei"
"e.g. Mici și Cozonaci"
- "Creați o cameră"
"Subiect (opțional)"
"Despre ce este această cameră?"
"A apărut o eroare la încercarea începerii conversației"
"Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost primită."
+ "Creați o cameră"
\ No newline at end of file
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/AllMatrixUsersDataSourceTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/AllMatrixUsersDataSourceTest.kt
index f20e46d1ba..043ad06361 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/AllMatrixUsersDataSourceTest.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/AllMatrixUsersDataSourceTest.kt
@@ -25,11 +25,9 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
-@OptIn(ExperimentalCoroutinesApi::class)
internal class AllMatrixUsersDataSourceTest {
@Test
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt
index 7ca2f0147f..d7ee836453 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.createroom.impl.addpeople
import app.cash.molecule.RecompositionClock
@@ -26,7 +24,6 @@ import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.userlist.test.FakeUserListDataSource
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt
index 8be1e777a8..24076bcd55 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
@@ -36,7 +34,6 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt
index 238fa8f58d..2a06278934 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.createroom.impl.root
import app.cash.molecule.RecompositionClock
@@ -35,7 +33,6 @@ import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.collections.immutable.persistentListOf
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt
index dc9302b908..db9fec3153 100644
--- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt
+++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt
@@ -131,14 +131,18 @@ class InviteListPresenter @Inject constructor(
private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState>) = launch {
suspend {
- client.getRoom(roomId)?.acceptInvitation()?.getOrThrow()
+ client.getRoom(roomId)?.use {
+ it.acceptInvitation().getOrThrow()
+ }
roomId
}.execute(acceptedAction)
}
private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState>) = launch {
suspend {
- client.getRoom(roomId)?.rejectInvitation()?.getOrThrow() ?: Unit
+ client.getRoom(roomId)?.use {
+ it.rejectInvitation().getOrThrow()
+ } ?: Unit
}.execute(declinedAction)
}
diff --git a/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt b/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt
index b8a99b40cc..462faa405f 100644
--- a/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt
+++ b/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt
@@ -38,11 +38,9 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
-@OptIn(ExperimentalCoroutinesApi::class)
class InviteListPresenterTests {
@Test
@@ -509,5 +507,4 @@ class InviteListPresenterTests {
unreadNotificationCount = 0,
)
)
-
}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
index c961b7fce7..cc216cc9dd 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.login.impl.changeserver
import app.cash.molecule.RecompositionClock
@@ -28,11 +26,9 @@ 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
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
-@OptIn(ExperimentalCoroutinesApi::class)
class ChangeServerPresenterTest {
@Test
fun `present - should start with default homeserver`() = runTest {
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/root/LoginRootPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/root/LoginRootPresenterTest.kt
index 6c825d0edd..be940feacf 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/root/LoginRootPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/root/LoginRootPresenterTest.kt
@@ -14,25 +14,18 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.login.impl.root
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.root.LoggedInState
-import io.element.android.features.login.impl.root.LoginFormState
-import io.element.android.features.login.impl.root.LoginRootEvents
-import io.element.android.features.login.impl.root.LoginRootPresenter
import io.element.android.libraries.matrix.test.A_HOMESERVER
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.FakeAuthenticationService
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/features/logout/api/src/main/res/values-de/translations.xml b/features/logout/api/src/main/res/values-de/translations.xml
index 9fd4f6b083..429a6017da 100644
--- a/features/logout/api/src/main/res/values-de/translations.xml
+++ b/features/logout/api/src/main/res/values-de/translations.xml
@@ -1,7 +1,7 @@
- "Abmelden"
"Abmelden"
"Abmeldung läuft…"
+ "Abmelden"
"Abmelden"
\ No newline at end of file
diff --git a/features/logout/api/src/main/res/values-es/translations.xml b/features/logout/api/src/main/res/values-es/translations.xml
index 9072ab88a2..8028039235 100644
--- a/features/logout/api/src/main/res/values-es/translations.xml
+++ b/features/logout/api/src/main/res/values-es/translations.xml
@@ -1,8 +1,8 @@
"¿Estás seguro de que quieres cerrar sesión?"
- "Cerrar sesión"
"Cerrar sesión"
"Cerrando sesión…"
+ "Cerrar sesión"
"Cerrar sesión"
\ No newline at end of file
diff --git a/features/logout/api/src/main/res/values-it/translations.xml b/features/logout/api/src/main/res/values-it/translations.xml
index 7d1a3ae304..351a5e208b 100644
--- a/features/logout/api/src/main/res/values-it/translations.xml
+++ b/features/logout/api/src/main/res/values-it/translations.xml
@@ -1,8 +1,8 @@
"Sei sicuro di voler uscire?"
- "Esci"
"Esci"
"Uscita in corso…"
+ "Esci"
"Esci"
\ No newline at end of file
diff --git a/features/logout/api/src/main/res/values-ro/translations.xml b/features/logout/api/src/main/res/values-ro/translations.xml
index 8befb1b1dd..bb1e36b426 100644
--- a/features/logout/api/src/main/res/values-ro/translations.xml
+++ b/features/logout/api/src/main/res/values-ro/translations.xml
@@ -1,8 +1,8 @@
"Sunteți sigur că vreți să vă deconectați?"
- "Deconectați-vă"
"Deconectați-vă"
"Deconectare în curs…"
+ "Deconectați-vă"
"Deconectați-vă"
\ No newline at end of file
diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt
index 6429ff1938..7a3556389f 100644
--- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt
+++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.logout.impl
import app.cash.molecule.RecompositionClock
@@ -28,7 +26,6 @@ import io.element.android.libraries.architecture.Async
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.FakeMatrixClient
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts
index f9f32e6e0e..1a54cb27c2 100644
--- a/features/messages/impl/build.gradle.kts
+++ b/features/messages/impl/build.gradle.kts
@@ -42,8 +42,9 @@ dependencies {
implementation(projects.libraries.textcomposer)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
- implementation(projects.libraries.mediapickers)
+ implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.featureflag.api)
+ implementation(projects.libraries.mediaupload.api)
implementation(projects.features.networkmonitor.api)
implementation(libs.coil.compose)
implementation(libs.datetime)
@@ -64,6 +65,9 @@ dependencies {
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.featureflag.test)
+ testImplementation(projects.libraries.mediaupload.test)
+ testImplementation(projects.libraries.mediapickers.test)
+ testImplementation(libs.test.mockk)
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index 4b0a6b2238..13f36c2ab6 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -41,6 +41,8 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
+import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
+import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -52,6 +54,7 @@ class MessagesPresenter @Inject constructor(
private val timelinePresenter: TimelinePresenter,
private val actionListPresenter: ActionListPresenter,
private val networkMonitor: NetworkMonitor,
+ private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter {
@Composable
@@ -71,6 +74,8 @@ class MessagesPresenter @Inject constructor(
val networkConnectionStatus by networkMonitor.connectivity.collectAsState(initial = networkMonitor.currentConnectivityStatus)
+ val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
+
LaunchedEffect(syncUpdateFlow) {
roomAvatar.value =
AvatarData(
@@ -97,6 +102,7 @@ class MessagesPresenter @Inject constructor(
timelineState = timelineState,
actionListState = actionListState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
+ snackbarMessage = snackbarMessage,
eventSink = ::handleEvents
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
index 88b25dd2d6..34e5f664ea 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
@@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId
@Immutable
@@ -32,5 +33,6 @@ data class MessagesState(
val timelineState: TimelineState,
val actionListState: ActionListState,
val hasNetworkConnection: Boolean,
+ val snackbarMessage: SnackbarMessage?,
val eventSink: (MessagesEvents) -> Unit
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
index 8426f79721..9249b808a1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
@@ -52,5 +52,6 @@ fun aMessagesState() = MessagesState(
),
actionListState = anActionListState(),
hasNetworkConnection = true,
+ snackbarMessage = null,
eventSink = {}
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index b9cace4ded..b571c98031 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -46,6 +46,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@@ -99,7 +100,6 @@ fun MessagesView(
val itemActionsBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
- val snackbarHostState = remember { SnackbarHostState() }
val composerState = state.composerState
val initialBottomSheetState = if (LocalInspectionMode.current && composerState.attachmentSourcePicker != null) {
ModalBottomSheetValue.Expanded
@@ -122,6 +122,19 @@ fun MessagesView(
}
}
+ val snackbarHostState = remember { SnackbarHostState() }
+ val snackbarMessageText = state.snackbarMessage?.let { stringResource(it.messageResId) }
+ if (snackbarMessageText != null) {
+ SideEffect {
+ coroutineScope.launch {
+ snackbarHostState.showSnackbar(
+ message = snackbarMessageText,
+ duration = state.snackbarMessage.duration
+ )
+ }
+ }
+ }
+
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
val localView = LocalView.current
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt
index d6133c5cc6..9b8dcabdd2 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt
@@ -27,23 +27,30 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
-import androidx.media3.common.MimeTypes
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.data.toStableCharSequence
+import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
+import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom
-import io.element.android.libraries.mediapickers.PickerProvider
+import io.element.android.libraries.mediapickers.api.PickerProvider
+import io.element.android.libraries.mediaupload.api.MediaPreProcessor
+import io.element.android.libraries.mediaupload.api.MediaType
+import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import timber.log.Timber
import javax.inject.Inject
+import io.element.android.libraries.ui.strings.R as StringR
@SingleIn(RoomScope::class)
class MessageComposerPresenter @Inject constructor(
@@ -52,6 +59,8 @@ class MessageComposerPresenter @Inject constructor(
private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService,
private val localMediaFactory: LocalMediaFactory,
+ private val mediaPreProcessor: MediaPreProcessor,
+ private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter {
@SuppressLint("UnsafeOptInUsageError")
@@ -73,10 +82,12 @@ class MessageComposerPresenter @Inject constructor(
}
}
- val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { handlePickedMedia(it) })
- val filesPicker = mediaPickerProvider.registerFilePicker(onResult = { handlePickedMedia(it) })
- val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { handlePickedMedia(it, MimeTypes.IMAGE_JPEG) }, deleteAfter = false)
- val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { handlePickedMedia(it, MimeTypes.VIDEO_MP4) }, deleteAfter = false)
+ val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType ->
+ handlePickedMedia(uri, mimeType)
+ })
+ val filesPicker = mediaPickerProvider.registerFilePicker(MimeTypes.Any, onResult = { handlePickedMedia(it) })
+ val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { handlePickedMedia(it, MimeTypes.Jpeg) })
+ val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { handlePickedMedia(it, MimeTypes.Mp4) })
val isFullScreen = rememberSaveable {
mutableStateOf(false)
@@ -174,4 +185,45 @@ class MessageComposerPresenter @Inject constructor(
)
}
}
+
+ private fun CoroutineScope.sendMedia(
+ uri: Uri,
+ mediaType: MediaType,
+ deleteOriginal: Boolean = false
+ ) = launch {
+ runCatching {
+ val info = handleMediaPreProcessing(uri, mediaType, deleteOriginal).getOrNull() ?: return@runCatching
+ when (info) {
+ is MediaUploadInfo.Image -> {
+ room.sendImage(info.file, info.thumbnailInfo.file, info.info)
+ }
+
+ is MediaUploadInfo.Video -> {
+ room.sendVideo(info.file, info.thumbnailInfo.file, info.info)
+ }
+
+ is MediaUploadInfo.AnyFile -> {
+ room.sendFile(info.file, info.info)
+ }
+ else -> error("Unexpected MediaUploadInfo format: $info")
+ }.getOrThrow()
+ }.onFailure {
+ snackbarDispatcher.post(SnackbarMessage(StringR.string.screen_media_upload_preview_error_failed_sending))
+ Timber.e(it, "Couldn't upload media")
+ }.onSuccess {
+ Timber.d("Media uploaded")
+ }
+ }
+
+ private suspend fun handleMediaPreProcessing(
+ uri: Uri,
+ mediaType: MediaType,
+ deleteOriginal: Boolean,
+ ): Result {
+ val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal)
+ Timber.d("Pre-processed media result: $result")
+ return result.onFailure {
+ snackbarDispatcher.post(SnackbarMessage(StringR.string.screen_media_upload_preview_error_failed_processing))
+ }
+ }
}
diff --git a/features/messages/impl/src/main/res/values-de/translations.xml b/features/messages/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..64f863c55f
--- /dev/null
+++ b/features/messages/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Kamera"
+ "Foto aufnehmen"
+ "Video aufnehmen"
+ "Anhang"
+ "Foto- & Video-Bibliothek"
+
\ No newline at end of file
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
index aa916f1e79..e6ee61ee52 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.messages
import app.cash.molecule.RecompositionClock
@@ -31,14 +29,15 @@ import io.element.android.features.messages.impl.actionlist.model.TimelineItemAc
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
+import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
-import io.element.android.libraries.mediapickers.PickerProvider
+import io.element.android.libraries.mediapickers.test.FakePickerProvider
+import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -132,8 +131,10 @@ class MessagesPresenterTest {
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
room = matrixRoom,
- mediaPickerProvider = PickerProvider(isInTest = true),
+ mediaPickerProvider = FakePickerProvider(),
featureFlagService = FakeFeatureFlagService(),
+ mediaPreProcessor = FakeMediaPreProcessor(),
+ snackbarDispatcher = SnackbarDispatcher(),
)
val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
@@ -146,6 +147,7 @@ class MessagesPresenterTest {
timelinePresenter = timelinePresenter,
actionListPresenter = actionListPresenter,
networkMonitor = FakeNetworkMonitor(),
+ snackbarDispatcher = SnackbarDispatcher(),
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt
index 410f7186aa..cd85418bff 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.messages.actionlist
import app.cash.molecule.RecompositionClock
@@ -23,9 +21,9 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.actionlist.ActionListEvents
-import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState
+import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@@ -37,7 +35,6 @@ import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import kotlinx.collections.immutable.persistentListOf
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt
index 2d4ee3842c..058406d513 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.messages.fixtures
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
@@ -34,7 +32,6 @@ import io.element.android.features.messages.impl.timeline.factories.virtual.Time
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.tests.testutils.testCoroutineDispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
internal fun aTimelineItemsFactory() = TimelineItemsFactory(
dispatchers = testCoroutineDispatchers(),
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
index e550371abd..0c0b46cbe2 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
@@ -22,45 +22,59 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
-import com.google.common.truth.Truth
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
import io.element.android.libraries.core.data.StableCharSequence
+import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
+import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.media.ImageInfo
+import io.element.android.libraries.matrix.api.media.ThumbnailInfo
+import io.element.android.libraries.matrix.api.media.VideoInfo
+import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_REPLY
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
-import io.element.android.libraries.mediapickers.PickerProvider
+import io.element.android.libraries.mediapickers.api.PickerProvider
+import io.element.android.libraries.mediapickers.test.FakePickerProvider
+import io.element.android.libraries.mediaupload.api.MediaPreProcessor
+import io.element.android.libraries.mediaupload.api.MediaUploadInfo
+import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
+import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.mockk.mockk
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
+import java.io.File
class MessageComposerPresenterTest {
- private val pickerProvider = PickerProvider(isInTest = true)
+ private val pickerProvider = FakePickerProvider().apply {
+ givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk
+ }
private val featureFlagService = FakeFeatureFlagService().apply {
runBlocking {
setFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow, true)
}
}
+ private val mediaPreProcessor = FakeMediaPreProcessor()
+ private val snackbarDispatcher = SnackbarDispatcher()
@Test
fun `present - initial state`() = runTest {
- val presenter = MessageComposerPresenter(
- this,
- FakeMatrixRoom(),
- pickerProvider,
- featureFlagService,
- )
+ val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -74,12 +88,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - toggle fullscreen`() = runTest {
- val presenter = MessageComposerPresenter(
- this,
- FakeMatrixRoom(),
- pickerProvider,
- featureFlagService,
- )
+ val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -95,12 +104,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - change message`() = runTest {
- val presenter = MessageComposerPresenter(
- this,
- FakeMatrixRoom(),
- pickerProvider,
- featureFlagService,
- )
+ val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -118,12 +122,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - change mode to edit`() = runTest {
- val presenter = MessageComposerPresenter(
- this,
- FakeMatrixRoom(),
- pickerProvider,
- featureFlagService,
- )
+ val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -139,23 +138,9 @@ class MessageComposerPresenterTest {
}
}
- private suspend fun ReceiveTurbine.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
- state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
- skipItems(skipCount)
- val normalState = awaitItem()
- assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
- assertThat(normalState.text).isEqualTo(StableCharSequence(""))
- assertThat(normalState.isSendButtonVisible).isFalse()
- }
-
@Test
fun `present - change mode to reply`() = runTest {
- val presenter = MessageComposerPresenter(
- this,
- FakeMatrixRoom(),
- pickerProvider,
- featureFlagService,
- )
+ val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -172,12 +157,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - change mode to quote`() = runTest {
- val presenter = MessageComposerPresenter(
- this,
- FakeMatrixRoom(),
- pickerProvider,
- featureFlagService,
- )
+ val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -194,12 +174,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - send message`() = runTest {
- val presenter = MessageComposerPresenter(
- this,
- FakeMatrixRoom(),
- pickerProvider,
- featureFlagService,
- )
+ val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -218,11 +193,9 @@ class MessageComposerPresenterTest {
@Test
fun `present - edit message`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
- val presenter = MessageComposerPresenter(
+ val presenter = createPresenter(
this,
fakeMatrixRoom,
- pickerProvider,
- featureFlagService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@@ -251,11 +224,9 @@ class MessageComposerPresenterTest {
@Test
fun `present - reply message`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
- val presenter = MessageComposerPresenter(
+ val presenter = createPresenter(
this,
fakeMatrixRoom,
- pickerProvider,
- featureFlagService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@@ -283,13 +254,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - Open attachments menu`() = runTest {
- val fakeMatrixRoom = FakeMatrixRoom()
- val presenter = MessageComposerPresenter(
- this,
- fakeMatrixRoom,
- pickerProvider,
- featureFlagService,
- )
+ val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -302,13 +267,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - Open camera attachments menu`() = runTest {
- val fakeMatrixRoom = FakeMatrixRoom()
- val presenter = MessageComposerPresenter(
- this,
- fakeMatrixRoom,
- pickerProvider,
- featureFlagService,
- )
+ val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -321,13 +280,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - Dismiss attachments menu`() = runTest {
- val fakeMatrixRoom = FakeMatrixRoom()
- val presenter = MessageComposerPresenter(
- this,
- fakeMatrixRoom,
- pickerProvider,
- featureFlagService,
- )
+ val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -341,80 +294,204 @@ class MessageComposerPresenterTest {
}
@Test
- fun `present - Pick media from gallery`() = runTest {
- val fakeMatrixRoom = FakeMatrixRoom()
- val presenter = MessageComposerPresenter(
- this,
- fakeMatrixRoom,
- pickerProvider,
- featureFlagService,
+ fun `present - Pick image from gallery`() = runTest {
+ val room = FakeMatrixRoom()
+ val presenter = createPresenter(this, room = room)
+ pickerProvider.givenMimeType(MimeTypes.Images)
+ mediaPreProcessor.givenResult(
+ Result.success(
+ MediaUploadInfo.Image(
+ file = File("/some/path"),
+ info = ImageInfo(
+ width = null,
+ height = null,
+ mimetype = null,
+ size = null,
+ thumbnailInfo = null,
+ thumbnailUrl = null,
+ blurhash = null,
+ ),
+ thumbnailInfo = ThumbnailProcessingInfo(
+ file = File("/some/path"),
+ info = ThumbnailInfo(
+ width = null,
+ height = null,
+ mimetype = null,
+ size = null,
+ ),
+ blurhash = "",
+ )
+ )
+ )
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
+ // Wait for the launched upload coroutine to run
+ runCurrent()
+ assertThat(room.sendMediaCount).isEqualTo(1)
+ }
+ }
- // TODO verify some post processing of the selected media is done
+ @Test
+ fun `present - Pick video from gallery`() = runTest {
+ val room = FakeMatrixRoom()
+ val presenter = createPresenter(this, room = room)
+ pickerProvider.givenMimeType(MimeTypes.Videos)
+ mediaPreProcessor.givenResult(
+ Result.success(
+ MediaUploadInfo.Video(
+ file = File("/some/path"),
+ info = VideoInfo(
+ width = null,
+ height = null,
+ mimetype = null,
+ duration = null,
+ size = null,
+ thumbnailInfo = null,
+ thumbnailUrl = null,
+ blurhash = null,
+ ),
+ thumbnailInfo = ThumbnailProcessingInfo(
+ file = File("/some/path"),
+ info = ThumbnailInfo(
+ width = null,
+ height = null,
+ mimetype = null,
+ size = null,
+ ),
+ blurhash = "",
+ )
+ )
+ )
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
+ // Wait for the launched upload coroutine to run
+ runCurrent()
+ assertThat(room.sendMediaCount).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `present - Pick media from gallery fails if returned mimetype is not video or image`() = runTest {
+ val presenter = createPresenter(this)
+ pickerProvider.givenMimeType(MimeTypes.Audio)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
+ assertThat(awaitError()).isInstanceOf(IllegalStateException::class.java)
+ }
+ }
+
+ @Test
+ fun `present - Pick media from gallery & cancel does nothing`() = runTest {
+ val presenter = createPresenter(this)
+ with(pickerProvider) {
+ givenResult(null) // Simulate a user canceling the flow
+ givenMimeType(MimeTypes.Images)
+ }
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
+ // No crashes here, otherwise it fails
}
}
@Test
fun `present - Pick file from storage`() = runTest {
- val fakeMatrixRoom = FakeMatrixRoom()
- val presenter = MessageComposerPresenter(
- this,
- fakeMatrixRoom,
- pickerProvider,
- featureFlagService,
- )
+ val room = FakeMatrixRoom()
+ val presenter = createPresenter(this, room = room)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
+ // Wait for the launched upload coroutine to run
+ runCurrent()
+ assertThat(room.sendMediaCount).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `present - Take photo`() = runTest {
+ val room = FakeMatrixRoom()
+ val presenter = createPresenter(this, room = room)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo)
+ // Wait for the launched upload coroutine to run
+ runCurrent()
+ assertThat(room.sendMediaCount).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `present - Record video`() = runTest {
+ val room = FakeMatrixRoom()
+ val presenter = createPresenter(this, room = room)
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video)
+ // Wait for the launched upload coroutine to run
+ runCurrent()
+ assertThat(room.sendMediaCount).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `present - Uploading media failure can be recovered from`() = runTest {
+ val room = FakeMatrixRoom().apply {
+ givenSendMediaResult(Result.failure(Exception()))
+ }
+ val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
- // TODO verify some post processing of the selected media is done
+ snackbarDispatcher.snackbarMessage.test {
+ // Initial value is always null
+ skipItems(1)
+ // Assert error message received
+ assertThat(awaitItem()).isNotNull()
+ }
}
}
- @Test
- fun `present - Take photo`() = runTest {
- val fakeMatrixRoom = FakeMatrixRoom()
- val presenter = MessageComposerPresenter(
- this,
- fakeMatrixRoom,
- pickerProvider,
- featureFlagService,
- )
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Photo)
-
- // TODO verify some post processing of the captured image is done
- }
+ private suspend fun ReceiveTurbine.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
+ state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
+ skipItems(skipCount)
+ val normalState = awaitItem()
+ assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
+ assertThat(normalState.text).isEqualTo(StableCharSequence(""))
+ assertThat(normalState.isSendButtonVisible).isFalse()
}
- @Test
- fun `present - Record video`() = runTest {
- val fakeMatrixRoom = FakeMatrixRoom()
- val presenter = MessageComposerPresenter(
- this,
- fakeMatrixRoom,
- pickerProvider,
- featureFlagService,
- )
- moleculeFlow(RecompositionClock.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- initialState.eventSink(MessageComposerEvents.PickCameraAttachmentSource.Video)
-
- // TODO verify some post processing of the captured video is done
- }
- }
+ private fun createPresenter(
+ coroutineScope: CoroutineScope,
+ room: MatrixRoom = FakeMatrixRoom(),
+ pickerProvider: PickerProvider = this.pickerProvider,
+ featureFlagService: FeatureFlagService = this.featureFlagService,
+ mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
+ snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
+ ) = MessageComposerPresenter(
+ coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor, snackbarDispatcher
+ )
}
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt
index 41f37158d9..a88b2251a4 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.messages.timeline
import app.cash.molecule.RecompositionClock
@@ -27,8 +25,6 @@ import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
-import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt
index 5b0795257c..7b89c7c227 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt
@@ -16,8 +16,11 @@
package io.element.android.features.preferences.impl.developer
+import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import com.airbnb.android.showkase.ui.ShowkaseBrowserActivity
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -35,11 +38,21 @@ class DeveloperSettingsNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
+ val activity = LocalContext.current as Activity
+ fun openShowkase() {
+ val intent = ShowkaseBrowserActivity.getIntent(
+ context = activity,
+ rootModuleCanonicalName = "io.element.android.libraries.designsystem.showkase.DesignSystemShowkaseRootModule"
+ )
+ activity.startActivity(intent)
+ }
+
val state = presenter.present()
DeveloperSettingsView(
state = state,
modifier = modifier,
- onBackPressed = this::navigateUp
+ onOpenShowkase = ::openShowkase,
+ onBackPressed = ::navigateUp
)
}
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt
index 9b8c5e9cde..697081c397 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt
@@ -14,28 +14,18 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalMaterial3Api::class)
-
package io.element.android.features.preferences.impl.developer
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.statusBars
-import androidx.compose.foundation.layout.systemBarsPadding
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
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 io.element.android.libraries.designsystem.components.preferences.PreferenceTopAppBar
+import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
+import io.element.android.libraries.designsystem.components.preferences.PreferenceText
+import io.element.android.libraries.designsystem.components.preferences.PreferenceView
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
-import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.featureflag.ui.FeatureListView
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.ui.strings.R
@@ -43,45 +33,42 @@ import io.element.android.libraries.ui.strings.R
@Composable
fun DeveloperSettingsView(
state: DeveloperSettingsState,
- modifier: Modifier = Modifier,
+ onOpenShowkase: () -> Unit,
onBackPressed: () -> Unit,
+ modifier: Modifier = Modifier,
) {
- Scaffold(
- modifier = modifier
- .fillMaxSize()
- .systemBarsPadding()
- .imePadding(),
- contentWindowInsets = WindowInsets.statusBars,
- topBar = {
- PreferenceTopAppBar(
- title = stringResource(id = R.string.common_developer_options),
- onBackPressed = onBackPressed,
- )
- },
- content = {
- FeatureListContent(it, state)
+ PreferenceView(
+ modifier = modifier,
+ onBackPressed = onBackPressed,
+ title = stringResource(id = R.string.common_developer_options)
+ ) {
+ // Note: this is OK to hardcode strings in this debug screen.
+ PreferenceCategory(title = "Feature flags") {
+ FeatureListContent(state)
}
- )
+ PreferenceCategory(title = "Showkase") {
+ PreferenceText(
+ title = "Open Showkase browser",
+ onClick = onOpenShowkase
+ )
+ }
+ }
}
@Composable
fun FeatureListContent(
- paddingValues: PaddingValues,
state: DeveloperSettingsState,
modifier: Modifier = Modifier
) {
-
fun onFeatureEnabled(feature: FeatureUiModel, isEnabled: Boolean) {
state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, isEnabled))
}
- Box(
- modifier = modifier
- .padding(paddingValues)
- .fillMaxSize()
- ) {
- FeatureListView(features = state.features, onCheckedChange = ::onFeatureEnabled)
- }
+ FeatureListView(
+ modifier = modifier,
+ features = state.features,
+ onCheckedChange = ::onFeatureEnabled,
+ )
}
@Preview
@@ -98,6 +85,7 @@ fun DeveloperSettingsViewDarkPreview(@PreviewParameter(DeveloperSettingsStatePro
private fun ContentToPreview(state: DeveloperSettingsState) {
DeveloperSettingsView(
state = state,
+ onOpenShowkase = {},
onBackPressed = {}
)
}
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
index 2e4bd005ca..6b7c8c2df4 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.preferences.impl.developer
import app.cash.molecule.RecompositionClock
@@ -24,7 +22,6 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
index 38e97148ca..791b0cf533 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.preferences.impl.root
import app.cash.molecule.RecompositionClock
@@ -29,7 +27,6 @@ import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataSto
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.matrix.test.FakeMatrixClient
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/features/rageshake/impl/src/main/res/values-ro/translations.xml b/features/rageshake/impl/src/main/res/values-ro/translations.xml
index 6b66ea417e..6d2657bc0c 100644
--- a/features/rageshake/impl/src/main/res/values-ro/translations.xml
+++ b/features/rageshake/impl/src/main/res/values-ro/translations.xml
@@ -10,5 +10,5 @@
"Trimiteți log-uri pentru a ajuta"
"Trimiteți captură de ecran"
"Pentru a verifica că lucrurile funcționează conform așteptărilor, log-uri vor fi trimise împreună cu mesajul. Acestea vor fi private. Pentru a trimite doar mesajul, dezactivați această setare."
- "%1$s s-a blocat ultima dată când a fost folosit. Dorești să ne trimiti un raport?"
+ "%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?"
\ No newline at end of file
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
index 1390dd1c8c..3df6c62287 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.rageshake.impl.bugreport
import app.cash.molecule.RecompositionClock
@@ -28,7 +26,6 @@ import io.element.android.features.rageshake.test.screenshot.A_SCREENSHOT_URI
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_FAILURE_REASON
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt
index 3a47ab7797..2d9834607f 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.rageshake.impl.crash.ui
import app.cash.molecule.RecompositionClock
@@ -26,7 +24,6 @@ import io.element.android.features.rageshake.api.crash.CrashDetectionEvents
import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter
import io.element.android.features.rageshake.test.crash.A_CRASH_DATA
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt
index cca82d64d5..369cb0833b 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.rageshake.impl.detection
import android.graphics.Bitmap
@@ -31,10 +29,10 @@ import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataSto
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.mockk.mockk
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
+import kotlin.time.Duration.Companion.seconds
class RageshakeDetectionPresenterTest {
@Test
@@ -101,7 +99,7 @@ class RageshakeDetectionPresenterTest {
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
- }.test {
+ }.test(timeout = 30.seconds) {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isStarted).isFalse()
@@ -155,7 +153,7 @@ class RageshakeDetectionPresenterTest {
}
@Test
- fun `present - screenshot then disable`() = runTest {
+ fun `present - screenshot then disable`() = runTest(timeout = 1.seconds) {
val screenshotHolder = FakeScreenshotHolder(screenshotUri = null)
val rageshake = FakeRageShake(isAvailableValue = true)
val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true)
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt
index e10e485d79..b01ce22645 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.features.rageshake.impl.preferences
import app.cash.molecule.RecompositionClock
@@ -26,7 +24,6 @@ import io.element.android.features.rageshake.api.preferences.RageshakePreference
import io.element.android.features.rageshake.test.rageshake.A_SENSITIVITY
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts
index 41f117d6a4..7b072a5faf 100644
--- a/features/roomdetails/impl/build.gradle.kts
+++ b/features/roomdetails/impl/build.gradle.kts
@@ -40,7 +40,6 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
- implementation(projects.features.userlist.api)
implementation(projects.libraries.androidutils)
api(projects.features.roomdetails.api)
implementation(libs.coil.compose)
@@ -51,7 +50,6 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
- testImplementation(projects.features.userlist.impl)
testImplementation(projects.features.userlist.test)
testImplementation(projects.tests.testutils)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
index 8f96487583..81b2e1e383 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt
@@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -124,7 +125,7 @@ class RoomDetailsPresenter @Inject constructor(
MatrixRoomMembersState.Unknown -> Async.Uninitialized
is MatrixRoomMembersState.Pending -> Async.Loading(prevState = membersState.prevRoomMembers?.size)
is MatrixRoomMembersState.Error -> Async.Failure(membersState.failure, prevState = membersState.prevRoomMembers?.size)
- is MatrixRoomMembersState.Ready -> Async.Success(membersState.roomMembers.size)
+ is MatrixRoomMembersState.Ready -> Async.Success(membersState.roomMembers.count { it.membership == RoomMembershipState.JOIN })
}
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt
similarity index 75%
rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt
rename to features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt
index 2cd3b7eb08..ca462c6507 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModules.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt
@@ -17,31 +17,17 @@
package io.element.android.features.roomdetails.impl.di
import com.squareup.anvil.annotations.ContributesTo
-import dagger.Binds
import dagger.Module
import dagger.Provides
-import io.element.android.features.roomdetails.impl.members.RoomUserListDataSource
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
-import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
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.RoomMember
-import javax.inject.Named
@Module
@ContributesTo(RoomScope::class)
-interface RoomMemberBindsModule {
-
- @Binds
- @Named("RoomMembers")
- fun bindRoomMemberUserListDataSource(dataSource: RoomUserListDataSource): UserListDataSource
-}
-
-@Module
-@ContributesTo(RoomScope::class)
-object RoomMemberProvidesModule {
+object RoomMemberModule {
@Provides
fun provideRoomMemberDetailsPresenterFactory(
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListDataSource.kt
similarity index 75%
rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt
rename to features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListDataSource.kt
index b8c73526ed..5a9c30698b 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListDataSource.kt
@@ -16,27 +16,23 @@
package io.element.android.features.roomdetails.impl.members
-import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
-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.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
-import io.element.android.libraries.matrix.api.room.toMatrixUser
-import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import javax.inject.Inject
-class RoomUserListDataSource @Inject constructor(
+class RoomMemberListDataSource @Inject constructor(
private val room: MatrixRoom,
private val coroutineDispatchers: CoroutineDispatchers,
-) : UserListDataSource {
+) {
- override suspend fun search(query: String): List = withContext(coroutineDispatchers.io) {
+ suspend fun search(query: String): List = withContext(coroutineDispatchers.io) {
val roomMembers = room.membersStateFlow
.dropWhile { it !is MatrixRoomMembersState.Ready }
.first()
@@ -50,11 +46,7 @@ class RoomUserListDataSource @Inject constructor(
|| member.displayName?.contains(query, ignoreCase = true).orFalse()
}
}
- filteredMembers.map(RoomMember::toMatrixUser)
- }
-
- override suspend fun getProfile(userId: UserId): MatrixUser? {
- return null
+ filteredMembers
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt
index 964a87eaf0..43716660eb 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt
@@ -16,8 +16,7 @@
package io.element.android.features.roomdetails.impl.members
-import io.element.android.libraries.matrix.api.user.MatrixUser
-
sealed interface RoomMemberListEvents {
- data class SelectUser(val user: MatrixUser) : RoomMemberListEvents
+ data class UpdateSearchQuery(val query: String) : RoomMemberListEvents
+ data class OnSearchActiveChanged(val active: Boolean) : RoomMemberListEvents
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
index 0cc893f9e1..3a719983b6 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt
@@ -18,54 +18,73 @@ package io.element.android.features.roomdetails.impl.members
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import io.element.android.features.userlist.api.SelectionMode
-import io.element.android.features.userlist.api.UserListDataSource
-import io.element.android.features.userlist.api.UserListDataStore
-import io.element.android.features.userlist.api.UserListPresenter
-import io.element.android.features.userlist.api.UserListPresenterArgs
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
-import io.element.android.libraries.matrix.api.room.MatrixRoom
-import io.element.android.libraries.matrix.api.user.MatrixUser
-import kotlinx.collections.immutable.ImmutableList
+import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.withContext
import javax.inject.Inject
-import javax.inject.Named
class RoomMemberListPresenter @Inject constructor(
- private val userListPresenterFactory: UserListPresenter.Factory,
- @Named("RoomMembers") private val userListDataSource: UserListDataSource,
- private val userListDataStore: UserListDataStore,
- private val room: MatrixRoom,
+ private val roomMemberListDataSource: RoomMemberListDataSource,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter {
- private val userListPresenter by lazy {
- userListPresenterFactory.create(
- UserListPresenterArgs(selectionMode = SelectionMode.Single),
- userListDataSource,
- userListDataStore,
- )
- }
-
@Composable
override fun present(): RoomMemberListState {
- val userListState = userListPresenter.present()
- val allUsers = remember { mutableStateOf>>(Async.Loading()) }
+ var roomMembers by remember { mutableStateOf>(Async.Loading()) }
+ var searchQuery by rememberSaveable { mutableStateOf("") }
+ var searchResults by remember {
+ mutableStateOf(RoomMemberSearchResultState.NotSearching)
+ }
+ var isSearchActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
withContext(coroutineDispatchers.io) {
- allUsers.value = Async.Success(userListDataSource.search("").toImmutableList())
+ val members = roomMemberListDataSource.search("").groupBy { it.membership }
+ roomMembers = Async.Success(
+ RoomMembers(
+ invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
+ joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(),
+ )
+ )
+ }
+ }
+
+ LaunchedEffect(searchQuery) {
+ withContext(coroutineDispatchers.io) {
+ searchResults = if (searchQuery.isEmpty()) {
+ RoomMemberSearchResultState.NotSearching
+ } else {
+ val results = roomMemberListDataSource.search(searchQuery).groupBy { it.membership }
+ if (results.isEmpty()) RoomMemberSearchResultState.NoResults
+ else RoomMemberSearchResultState.Results(
+ RoomMembers(
+ invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
+ joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(),
+ )
+ )
+ }
}
}
return RoomMemberListState(
- allUsers = allUsers.value,
- userListState = userListState,
+ roomMembers = roomMembers,
+ searchQuery = searchQuery,
+ searchResults = searchResults,
+ isSearchActive = isSearchActive,
+ eventSink = { event ->
+ when (event) {
+ is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
+ is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
+ }
+ },
)
}
}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
index ef9d1f3839..a689ad1fd2 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt
@@ -16,12 +16,30 @@
package io.element.android.features.roomdetails.impl.members
-import io.element.android.features.userlist.api.UserListState
import io.element.android.libraries.architecture.Async
-import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
data class RoomMemberListState(
- val allUsers: Async>,
- val userListState: UserListState,
+ val roomMembers: Async,
+ val searchQuery: String,
+ val searchResults: RoomMemberSearchResultState,
+ val isSearchActive: Boolean,
+ val eventSink: (RoomMemberListEvents) -> Unit,
)
+
+data class RoomMembers(
+ val invited: ImmutableList,
+ val joined: ImmutableList
+)
+
+sealed interface RoomMemberSearchResultState {
+ /** No search results are available yet (e.g. because the user hasn't entered a (long enough) search term). */
+ object NotSearching : RoomMemberSearchResultState
+
+ /** The search has completed, but no results were found. */
+ object NoResults : RoomMemberSearchResultState
+
+ /** The search has completed, and some matching users were found. */
+ data class Results(val results: RoomMembers) : RoomMemberSearchResultState
+}
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
index a9babf57d8..7f91969bcd 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt
@@ -17,27 +17,93 @@
package io.element.android.features.roomdetails.impl.members
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.features.userlist.api.UserSearchResultState
-import io.element.android.features.userlist.api.aUserListState
import io.element.android.libraries.architecture.Async
-import io.element.android.libraries.matrix.api.user.MatrixUser
-import io.element.android.libraries.matrix.ui.components.aMatrixUser
-import kotlinx.collections.immutable.ImmutableList
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
internal class RoomMemberListStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
- aRoomMemberListState(allUsers = Async.Success(persistentListOf(aMatrixUser()))),
- aRoomMemberListState(allUsers = Async.Loading())
+ aRoomMemberListState(
+ roomMembers = Async.Success(
+ RoomMembers(
+ invited = persistentListOf(aVictor(), aWalter()),
+ joined = persistentListOf(anAlice(), aBob()),
+ )
+ )
+ ),
+ aRoomMemberListState(roomMembers = Async.Loading()),
+ aRoomMemberListState().copy(isSearchActive = false),
+ aRoomMemberListState().copy(isSearchActive = true),
+ aRoomMemberListState().copy(isSearchActive = true, searchQuery = "someone"),
+ aRoomMemberListState().copy(
+ isSearchActive = true,
+ searchQuery = "@someone:matrix.org",
+ searchResults = RoomMemberSearchResultState.Results(
+ RoomMembers(
+ invited = persistentListOf(aVictor()),
+ joined = persistentListOf(anAlice()),
+ )
+ ),
+ ),
+ aRoomMemberListState().copy(
+ isSearchActive = true,
+ searchQuery = "something-with-no-results",
+ searchResults = RoomMemberSearchResultState.NoResults
+ ),
)
}
internal fun aRoomMemberListState(
- searchResults: UserSearchResultState = UserSearchResultState.NotSearching,
- allUsers: Async> = Async.Uninitialized,
-) =
- RoomMemberListState(
- userListState = aUserListState().copy(searchResults = searchResults),
- allUsers = allUsers,
- )
+ roomMembers: Async = Async.Uninitialized,
+ searchResults: RoomMemberSearchResultState = RoomMemberSearchResultState.NotSearching,
+) = RoomMemberListState(
+ roomMembers = roomMembers,
+ searchQuery = "",
+ searchResults = searchResults,
+ isSearchActive = false,
+ eventSink = {}
+)
+
+fun aRoomMember(
+ userId: UserId = UserId("@alice:server.org"),
+ displayName: String? = null,
+ avatarUrl: String? = null,
+ membership: RoomMembershipState = RoomMembershipState.JOIN,
+ isNameAmbiguous: Boolean = false,
+ powerLevel: Long = 0L,
+ normalizedPowerLevel: Long = 0L,
+ isIgnored: Boolean = false,
+) = RoomMember(
+ userId = userId,
+ displayName = displayName,
+ avatarUrl = avatarUrl,
+ membership = membership,
+ isNameAmbiguous = isNameAmbiguous,
+ powerLevel = powerLevel,
+ normalizedPowerLevel = normalizedPowerLevel,
+ isIgnored = isIgnored,
+)
+
+fun aRoomMemberList() = listOf(
+ anAlice(),
+ aBob(),
+ aRoomMember(UserId("@carol:server.org"), "Carol"),
+ aRoomMember(UserId("@david:server.org"), "David"),
+ aRoomMember(UserId("@eve:server.org"), "Eve"),
+ aRoomMember(UserId("@justin:server.org"), "Justin"),
+ aRoomMember(UserId("@mallory:server.org"), "Mallory"),
+ aRoomMember(UserId("@susie:server.org"), "Susie"),
+ aVictor(),
+ aWalter(),
+)
+
+fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice")
+fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob")
+
+fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)
+
+fun aWalter() = aRoomMember(UserId("@walter:server.org"), "Walter", membership = RoomMembershipState.INVITE)
diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
index 3a3ea3daa1..db38bbcf8b 100644
--- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
+++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt
@@ -16,20 +16,31 @@
package io.element.android.features.roomdetails.impl.members
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -39,37 +50,43 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.roomdetails.impl.R
-import io.element.android.features.userlist.api.components.SearchSingleUserResultItem
-import io.element.android.features.userlist.api.components.UserListView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.ElementTextStyles
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.ui.components.MatrixUserRow
+import kotlinx.collections.immutable.ImmutableList
+import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomMemberListView(
state: RoomMemberListState,
+ onBackPressed: () -> Unit,
+ onMemberSelected: (UserId) -> Unit,
modifier: Modifier = Modifier,
- onBackPressed: () -> Unit = {},
- onMemberSelected: (UserId) -> Unit = {},
) {
- fun onUserSelected(user: MatrixUser) {
- onMemberSelected(user.userId)
+ fun onUserSelected(roomMember: RoomMember) {
+ onMemberSelected(roomMember.userId)
}
Scaffold(
topBar = {
- if (!state.userListState.isSearchActive) {
+ if (!state.isSearchActive) {
RoomMemberListTopBar(onBackPressed = onBackPressed)
}
}
@@ -80,33 +97,26 @@ fun RoomMemberListView(
.padding(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
- UserListView(
- state = state.userListState,
- onUserSelected = ::onUserSelected,
- )
+ Column {
+ RoomMemberSearchBar(
+ query = state.searchQuery,
+ state = state.searchResults,
+ active = state.isSearchActive,
+ placeHolderTitle = stringResource(StringR.string.common_search_for_someone),
+ onActiveChanged = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) },
+ onTextChanged = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) },
+ onUserSelected = ::onUserSelected,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
- if (!state.userListState.isSearchActive) {
- if (state.allUsers is Async.Success) {
- LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
- item {
- val memberCount = state.allUsers.state.count()
- Text(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
- text = pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount),
- style = ElementTextStyles.Regular.callout,
- color = MaterialTheme.colorScheme.secondary,
- textAlign = TextAlign.Start,
- )
- }
- items(state.allUsers.state) { matrixUser ->
- SearchSingleUserResultItem(
- modifier = Modifier.fillMaxWidth(),
- matrixUser = matrixUser,
- onClick = { onUserSelected(matrixUser) }
- )
- }
- }
- } else if (state.allUsers.isLoading()) {
+ if (!state.isSearchActive) {
+ if (state.roomMembers is Async.Success) {
+ RoomMemberList(
+ roomMembers = state.roomMembers.state,
+ onUserSelected = ::onUserSelected,
+ )
+ } else if (state.roomMembers.isLoading()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
@@ -116,9 +126,73 @@ fun RoomMemberListView(
}
}
+@Composable
+private fun RoomMemberList(
+ roomMembers: RoomMembers,
+ onUserSelected: (RoomMember) -> Unit,
+) {
+ LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
+ if (roomMembers.invited.isNotEmpty()) {
+ roomMemberListSection(
+ headerText = { stringResource(id = R.string.screen_room_member_list_pending_header_title) },
+ members = roomMembers.invited,
+ onMemberSelected = { onUserSelected(it) }
+ )
+ }
+ if (roomMembers.joined.isNotEmpty()) {
+ val memberCount = roomMembers.joined.count()
+ roomMemberListSection(
+ headerText = { pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount) },
+ members = roomMembers.joined,
+ onMemberSelected = { onUserSelected(it) }
+ )
+ }
+ }
+}
+
+private fun LazyListScope.roomMemberListSection(
+ headerText: @Composable () -> String,
+ members: ImmutableList,
+ onMemberSelected: (RoomMember) -> Unit,
+) {
+ item {
+ Text(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ text = headerText(),
+ style = ElementTextStyles.Regular.callout,
+ color = MaterialTheme.colorScheme.secondary,
+ textAlign = TextAlign.Start,
+ )
+ }
+ items(members) { matrixUser ->
+ RoomMemberListItem(
+ modifier = Modifier.fillMaxWidth(),
+ roomMember = matrixUser,
+ onClick = { onMemberSelected(matrixUser) }
+ )
+ }
+}
+
+@Composable
+private fun RoomMemberListItem(
+ roomMember: RoomMember,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit = {},
+) {
+ MatrixUserRow(
+ modifier = modifier.clickable(onClick = onClick),
+ matrixUser = MatrixUser(
+ userId = roomMember.userId,
+ displayName = roomMember.displayName,
+ avatarUrl = roomMember.avatarUrl
+ ),
+ avatarSize = AvatarSize.Custom(36.dp),
+ )
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun RoomMemberListTopBar(
+private fun RoomMemberListTopBar(
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
@@ -135,6 +209,86 @@ fun RoomMemberListTopBar(
)
}
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun RoomMemberSearchBar(
+ query: String,
+ state: RoomMemberSearchResultState,
+ active: Boolean,
+ placeHolderTitle: String,
+ onActiveChanged: (Boolean) -> Unit,
+ onTextChanged: (String) -> Unit,
+ onUserSelected: (RoomMember) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val focusManager = LocalFocusManager.current
+
+ if (!active) {
+ onTextChanged("")
+ focusManager.clearFocus()
+ }
+
+ SearchBar(
+ query = query,
+ onQueryChange = onTextChanged,
+ onSearch = { focusManager.clearFocus() },
+ active = active,
+ onActiveChange = onActiveChanged,
+ modifier = modifier
+ .padding(horizontal = if (!active) 16.dp else 0.dp),
+ placeholder = {
+ Text(
+ text = placeHolderTitle,
+ modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
+ )
+ },
+ leadingIcon = if (active) {
+ { BackButton(onClick = { onActiveChanged(false) }) }
+ } else {
+ null
+ },
+ trailingIcon = when {
+ active && query.isNotEmpty() -> {
+ {
+ IconButton(onClick = { onTextChanged("") }) {
+ Icon(Icons.Default.Close, stringResource(StringR.string.action_clear))
+ }
+ }
+ }
+
+ !active -> {
+ {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = stringResource(StringR.string.action_search),
+ modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
+ )
+ }
+ }
+
+ else -> null
+ },
+ colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
+ content = {
+ if (state is RoomMemberSearchResultState.Results) {
+ RoomMemberList(
+ roomMembers = state.results,
+ onUserSelected = { onUserSelected(it) }
+ )
+ } else if (state is RoomMemberSearchResultState.NoResults) {
+ Spacer(Modifier.size(80.dp))
+
+ Text(
+ text = stringResource(StringR.string.common_no_results),
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ },
+ )
+}
+
@Preview
@Composable
fun RoomMemberListLightPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) =
@@ -147,5 +301,9 @@ fun RoomMemberListDarkPreview(@PreviewParameter(RoomMemberListStateProvider::cla
@Composable
private fun ContentToPreview(state: RoomMemberListState) {
- RoomMemberListView(state)
+ RoomMemberListView(
+ state = state,
+ onBackPressed = {},
+ onMemberSelected = {}
+ )
}
diff --git a/features/roomdetails/impl/src/main/res/values-es/translations.xml b/features/roomdetails/impl/src/main/res/values-es/translations.xml
index 58c486d6c3..4783496dd8 100644
--- a/features/roomdetails/impl/src/main/res/values-es/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-es/translations.xml
@@ -6,7 +6,6 @@
"Los mensajes están protegidos con \"candados\". Sólo tú y los destinatarios tenéis las llaves únicas para abrirlos."
"Cifrado de mensajes activado"
- "Invitar a otras personas"
"Compartir sala"
"Bloquear"
"Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puede revertir esta acción en cualquier momento."
@@ -14,6 +13,7 @@
"Desbloquear"
"Al desbloquear al usuario, podrás volver a ver todos sus mensajes."
"Desbloquear usuario"
+ "Invitar gente"
"Salir de la sala"
"Personas"
"Seguridad"
diff --git a/features/roomdetails/impl/src/main/res/values-it/translations.xml b/features/roomdetails/impl/src/main/res/values-it/translations.xml
index a2e61a329c..83569c660f 100644
--- a/features/roomdetails/impl/src/main/res/values-it/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-it/translations.xml
@@ -6,7 +6,6 @@
"I messaggi sono protetti da lucchetti. Solo tu e i destinatari avete le chiavi univoche per sbloccarli."
"Crittografia messaggi abilitata"
- "Invita persone"
"Condividi stanza"
"Blocca"
"Gli utenti bloccati non saranno in grado di inviarti nuovi messaggi e tutti quelli già esistenti saranno nascosti. Potrai annullare questa azione in qualsiasi momento."
@@ -14,6 +13,7 @@
"Sblocca"
"Dopo aver sbloccato l\'utente, potrai vedere nuovamente tutti i suoi messaggi."
"Sblocca utente"
+ "Invita persone"
"Esci dalla stanza"
"Persone"
"Sicurezza"
diff --git a/features/roomdetails/impl/src/main/res/values-ro/translations.xml b/features/roomdetails/impl/src/main/res/values-ro/translations.xml
index d8ce43d070..dc154df2e0 100644
--- a/features/roomdetails/impl/src/main/res/values-ro/translations.xml
+++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml
@@ -6,7 +6,6 @@
"Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca."
"Criptarea mesajelor este activată"
- "Invitați persoane"
"Partajați camera"
"Blocați"
"Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând."
@@ -14,6 +13,7 @@
"Deblocați"
"La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta."
"Deblocați utilizatorul"
+ "Invitați persoane"
"Părăsiți camera"
"Persoane"
"Securitate"
diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml
index d1356896d0..7c5006cc21 100644
--- a/features/roomdetails/impl/src/main/res/values/localazy.xml
+++ b/features/roomdetails/impl/src/main/res/values/localazy.xml
@@ -7,6 +7,7 @@
"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."
"Message encryption enabled"
"Share room"
+ "Pending"
"Block"
"Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."
"Block user"
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
index 2cc6d0ec24..b3717f4244 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt
@@ -24,6 +24,8 @@ import io.element.android.features.roomdetails.impl.LeaveRoomWarning
import io.element.android.features.roomdetails.impl.RoomDetailsEvent
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.features.roomdetails.impl.RoomDetailsType
+import io.element.android.features.roomdetails.impl.members.aRoomMember
+import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
@@ -90,7 +92,7 @@ class RoomDetailsPresenterTests {
val room = aMatrixRoom()
val roomMembers = listOf(
aRoomMember(A_USER_ID),
- aRoomMember(A_USER_ID_2),
+ aRoomMember(A_USER_ID_2, membership = RoomMembershipState.INVITE),
)
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
@@ -112,7 +114,7 @@ class RoomDetailsPresenterTests {
room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
//skipItems(1)
val successState = awaitItem()
- Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(roomMembers.size))
+ Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(1))
cancelAndIgnoreRemainingEvents()
}
@@ -266,22 +268,3 @@ fun aMatrixRoom(
isDirect = isDirect,
)
-fun aRoomMember(
- userId: UserId = A_USER_ID,
- displayName: String? = null,
- avatarUrl: String? = null,
- membership: RoomMembershipState = RoomMembershipState.JOIN,
- isNameAmbiguous: Boolean = false,
- powerLevel: Long = 0L,
- normalizedPowerLevel: Long = 0L,
- isIgnored: Boolean = false,
-) = RoomMember(
- userId = userId,
- displayName = displayName,
- avatarUrl = avatarUrl,
- membership = membership,
- isNameAmbiguous = isNameAmbiguous,
- powerLevel = powerLevel,
- normalizedPowerLevel = normalizedPowerLevel,
- isIgnored = isIgnored,
-)
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
index 7eb650c937..48cf660877 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
@@ -20,62 +20,111 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
+import io.element.android.features.roomdetails.aMatrixRoom
+import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
+import io.element.android.features.roomdetails.impl.members.RoomMemberListEvents
import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter
-import io.element.android.features.userlist.api.SelectionMode
-import io.element.android.features.userlist.api.UserListDataSource
-import io.element.android.features.userlist.api.UserListDataStore
-import io.element.android.features.userlist.api.UserListPresenter
-import io.element.android.features.userlist.api.UserListPresenterArgs
-import io.element.android.features.userlist.api.UserSearchResultState
-import io.element.android.features.userlist.impl.DefaultUserListPresenter
-import io.element.android.features.userlist.test.FakeUserListDataSource
+import io.element.android.features.roomdetails.impl.members.RoomMemberSearchResultState
+import io.element.android.features.roomdetails.impl.members.aRoomMemberList
+import io.element.android.features.roomdetails.impl.members.aVictor
+import io.element.android.features.roomdetails.impl.members.aWalter
import io.element.android.libraries.architecture.Async
-import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
-import io.element.android.libraries.matrix.ui.components.aMatrixUser
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
-import okhttp3.internal.toImmutableList
import org.junit.Test
@ExperimentalCoroutinesApi
class RoomMemberListPresenterTests {
- private val testCoroutineDispatchers = testCoroutineDispatchers()
-
@Test
- fun `present - search is done automatically on start, but is async`() = runTest {
- val searchResult = listOf(aMatrixUser())
- val userListDataSource = FakeUserListDataSource().apply {
- givenSearchResult(searchResult)
- }
- val userListDataStore = UserListDataStore()
- val userListFactory = object : UserListPresenter.Factory {
- override fun create(
- args: UserListPresenterArgs,
- userListDataSource: UserListDataSource,
- userListDataStore: UserListDataStore,
- ) = DefaultUserListPresenter(args, userListDataSource, userListDataStore)
- }
- val fakeRoom = FakeMatrixRoom()
- val presenter = RoomMemberListPresenter(
- userListPresenterFactory = userListFactory,
- userListDataSource = userListDataSource,
- userListDataStore = userListDataStore,
- room = fakeRoom,
- coroutineDispatchers = testCoroutineDispatchers
- )
+ fun `search is done automatically on start, but is async`() = runTest {
+ val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
- Truth.assertThat(initialState.allUsers).isInstanceOf(Async.Loading::class.java)
- Truth.assertThat(initialState.userListState.isSearchActive).isFalse()
- Truth.assertThat(initialState.userListState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
- Truth.assertThat(initialState.userListState.selectionMode).isEqualTo(SelectionMode.Single)
+ Truth.assertThat(initialState.roomMembers).isInstanceOf(Async.Loading::class.java)
+ Truth.assertThat(initialState.searchQuery).isEmpty()
+ Truth.assertThat(initialState.searchResults).isEqualTo(RoomMemberSearchResultState.NotSearching)
+ Truth.assertThat(initialState.isSearchActive).isFalse()
val loadedState = awaitItem()
- Truth.assertThat((loadedState.allUsers as? Async.Success)?.state).isEqualTo(searchResult.toImmutableList())
+ Truth.assertThat(loadedState.roomMembers).isInstanceOf(Async.Success::class.java)
+ Truth.assertThat((loadedState.roomMembers as Async.Success).state.invited).isEqualTo(listOf(aVictor(), aWalter()))
+ Truth.assertThat((loadedState.roomMembers as Async.Success).state.joined).isNotEmpty()
+ }
+ }
+
+ @Test
+ fun `open search`() = runTest {
+ val presenter = createPresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val loadedState = awaitItem()
+
+ loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
+
+ val searchActiveState = awaitItem()
+ Truth.assertThat((searchActiveState.isSearchActive)).isTrue()
+ }
+ }
+
+ @Test
+ fun `search for something which is not found`() = runTest {
+ val presenter = createPresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val loadedState = awaitItem()
+ loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
+ val searchActiveState = awaitItem()
+ loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("something"))
+ val searchQueryUpdatedState = awaitItem()
+ Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("something")
+ val searchSearchResultDelivered = awaitItem()
+ Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(RoomMemberSearchResultState.NoResults::class.java)
+ }
+ }
+
+ @Test
+ fun `search for something which is found`() = runTest {
+ val presenter = createPresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val loadedState = awaitItem()
+ loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
+ val searchActiveState = awaitItem()
+ loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("Alice"))
+ val searchQueryUpdatedState = awaitItem()
+ Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("Alice")
+ val searchSearchResultDelivered = awaitItem()
+ Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(RoomMemberSearchResultState.Results::class.java)
+ Truth.assertThat((searchSearchResultDelivered.searchResults as RoomMemberSearchResultState.Results).results.joined.first().displayName)
+ .isEqualTo("Alice")
+
}
}
}
+
+@ExperimentalCoroutinesApi
+private fun createDataSource(
+ matrixRoom: MatrixRoom = aMatrixRoom().apply {
+ givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
+ },
+ coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
+) = RoomMemberListDataSource(matrixRoom, coroutineDispatchers)
+
+@ExperimentalCoroutinesApi
+private fun createPresenter(
+ roomMemberListDataSource: RoomMemberListDataSource = createDataSource(),
+ coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
+) = RoomMemberListPresenter(roomMemberListDataSource, coroutineDispatchers)
diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
index 13eb28ca85..294b689ea9 100644
--- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
+++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
@@ -22,7 +22,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.roomdetails.aMatrixClient
import io.element.android.features.roomdetails.aMatrixRoom
-import io.element.android.features.roomdetails.aRoomMember
+import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultInviteStateDataSourceTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultInviteStateDataSourceTest.kt
index 28b8219189..040a5b5b50 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultInviteStateDataSourceTest.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultInviteStateDataSourceTest.kt
@@ -27,11 +27,9 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.tests.testutils.testCoroutineDispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
-@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultInviteStateDataSourceTest {
@Test
@@ -133,5 +131,4 @@ internal class DefaultInviteStateDataSourceTest {
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
}
}
-
}
diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
index 9030b818bc..104c419f74 100644
--- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
+++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
@@ -38,12 +38,11 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
-@OptIn(ExperimentalCoroutinesApi::class) class RoomListPresenterTests {
+class RoomListPresenterTests {
@Test
fun `present - should start with no user and then load user with success`() = runTest {
diff --git a/features/userlist/impl/src/test/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenterTests.kt b/features/userlist/impl/src/test/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenterTests.kt
index cf9d9da642..b13881dc5b 100644
--- a/features/userlist/impl/src/test/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenterTests.kt
+++ b/features/userlist/impl/src/test/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenterTests.kt
@@ -16,7 +16,6 @@
package io.element.android.features.userlist.impl
-import androidx.compose.foundation.lazy.LazyListState
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
@@ -30,14 +29,10 @@ import io.element.android.features.userlist.test.FakeUserListDataSource
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
-import io.mockk.coJustRun
-import io.mockk.mockkConstructor
import kotlinx.collections.immutable.persistentListOf
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
-@OptIn(ExperimentalCoroutinesApi::class)
class DefaultUserListPresenterTests {
private val userListDataSource = FakeUserListDataSource()
@@ -136,9 +131,6 @@ class DefaultUserListPresenterTests {
@Test
fun `present - select a user`() = runTest {
- mockkConstructor(LazyListState::class)
- coJustRun { anyConstructed().scrollToItem(index = any()) }
-
val presenter = DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userListDataSource,
@@ -158,16 +150,15 @@ class DefaultUserListPresenterTests {
assertThat(awaitItem().selectedUsers).containsExactly(userA)
initialState.eventSink(UserListEvents.AddToSelection(userB))
- // the last added user should be presented first
- assertThat(awaitItem().selectedUsers).containsExactly(userB, userA)
+ assertThat(awaitItem().selectedUsers).containsExactly(userA, userB)
initialState.eventSink(UserListEvents.AddToSelection(userABis))
initialState.eventSink(UserListEvents.AddToSelection(userC))
// duplicated users should be ignored
- assertThat(awaitItem().selectedUsers).containsExactly(userC, userB, userA)
+ assertThat(awaitItem().selectedUsers).containsExactly(userA, userB, userC)
initialState.eventSink(UserListEvents.RemoveFromSelection(userB))
- assertThat(awaitItem().selectedUsers).containsExactly(userC, userA)
+ assertThat(awaitItem().selectedUsers).containsExactly(userA, userC)
initialState.eventSink(UserListEvents.RemoveFromSelection(userA))
assertThat(awaitItem().selectedUsers).containsExactly(userC)
initialState.eventSink(UserListEvents.RemoveFromSelection(userC))
diff --git a/gradle.properties b/gradle.properties
index 6518b0c617..ae25b1ed02 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -46,7 +46,7 @@ signing.element.nightly.keyPassword=Secret
# Customise the Lint version to use a more recent version than the one bundled with AGP
# https://googlesamples.github.io/android-custom-lint-rules/usage/newer-lint.md.html
-android.experimental.lint.version=8.0.0
+android.experimental.lint.version=8.2.0-alpha02
# Enable test fixture for all modules by default
android.experimental.enableTestFixtures=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 885e480846..d3c8167441 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -10,7 +10,7 @@ molecule = "0.9.0"
# AndroidX
material = "1.9.0"
-core = "1.10.0"
+core = "1.10.1"
datastore = "1.0.0"
constraintlayout = "2.1.4"
recyclerview = "1.3.0"
@@ -24,7 +24,7 @@ compose_bom = "2023.04.01"
composecompiler = "1.4.7"
# Coroutines
-coroutines = "1.6.4"
+coroutines = "1.7.1"
# Accompanist
accompanist = "0.30.1"
@@ -44,7 +44,7 @@ stem = "2.3.0"
sqldelight = "1.5.5"
# DI
-dagger = "2.46"
+dagger = "2.46.1"
anvil = "2.4.5"
# quality
@@ -64,6 +64,7 @@ androidx_core = { module = "androidx.core:core", version.ref = "core" }
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" }
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
+androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.6"
androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
androidx_recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
@@ -117,7 +118,7 @@ test_orchestrator = "androidx.test:orchestrator:1.4.2"
test_turbine = "app.cash.turbine:turbine:0.12.3"
test_truth = "com.google.truth:truth:1.1.3"
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.11"
-test_robolectric = "org.robolectric:robolectric:4.10.1"
+test_robolectric = "org.robolectric:robolectric:4.10.2"
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
# Others
@@ -131,7 +132,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1"
-matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.11"
+matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.13"
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
@@ -139,6 +140,8 @@ sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
sqlite = "androidx.sqlite:sqlite:2.3.1"
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
gujun_span = "me.gujun.android:span:1.7"
+otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
+vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0"
# Di
inject = "javax.inject:javax.inject:1"
diff --git a/libraries/androidutils/build.gradle.kts b/libraries/androidutils/build.gradle.kts
index 971cc9ef40..e9a8feaa05 100644
--- a/libraries/androidutils/build.gradle.kts
+++ b/libraries/androidutils/build.gradle.kts
@@ -26,6 +26,7 @@ dependencies {
implementation(libs.timber)
implementation(libs.androidx.corektx)
implementation(libs.androidx.activity.activity)
+ implementation(libs.androidx.exifinterface)
implementation(libs.androidx.security.crypto)
implementation(projects.libraries.core)
}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt
index f71fb1535e..6f8aa76d03 100644
--- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt
@@ -17,7 +17,13 @@
package io.element.android.libraries.androidutils.bitmap
import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Matrix
+import androidx.core.graphics.scale
+import androidx.exifinterface.media.ExifInterface
import java.io.File
+import java.io.InputStream
+import kotlin.math.min
fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) {
outputStream().use { out ->
@@ -25,3 +31,71 @@ fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int
out.flush()
}
}
+
+/**
+ * Reads the EXIF metadata from the [inputStream] and rotates the current [Bitmap] to match it.
+ * @return The resulting [Bitmap] or `null` if no metadata was found.
+ */
+fun Bitmap.rotateToMetadataOrientation(inputStream: InputStream): Result =
+ runCatching { rotateToMetadataOrientation(this, ExifInterface(inputStream)) }
+
+/**
+ * Scales the current [Bitmap] to fit the ([maxWidth], [maxHeight]) bounds while keeping aspect ratio.
+ * @throws IllegalStateException if [maxWidth] or [maxHeight] <= 0.
+ */
+fun Bitmap.resizeToMax(maxWidth: Int, maxHeight: Int): Bitmap {
+ // No need to resize
+ if (this.width == maxWidth && this.height == maxHeight) return this
+
+ val aspectRatio = this.width.toFloat() / this.height.toFloat()
+ val useWidth = aspectRatio >= 1
+ val calculatedMaxWidth = min(this.width, maxWidth)
+ val calculatedMinHeight = min(this.height, maxHeight)
+ val width = if (useWidth) calculatedMaxWidth else (calculatedMinHeight * aspectRatio).toInt()
+ val height = if (useWidth) (calculatedMaxWidth / aspectRatio).toInt() else calculatedMinHeight
+ return scale(width, height)
+}
+
+/**
+ * Calculates and returns [BitmapFactory.Options.inSampleSize] given a pair of [desiredWidth] & [desiredHeight]
+ * and the previously read [BitmapFactory.Options.outWidth] & [BitmapFactory.Options.outHeight].
+ */
+fun BitmapFactory.Options.calculateInSampleSize(desiredWidth: Int, desiredHeight: Int): Int {
+ var inSampleSize = 1
+
+ if (outWidth > desiredWidth || outHeight > desiredHeight) {
+ val halfHeight: Int = outHeight / 2
+ val halfWidth: Int = outWidth / 2
+
+ // Calculate the largest inSampleSize value that is a power of 2 and keeps both
+ // height and width larger than the requested height and width.
+ while (halfHeight / inSampleSize >= desiredHeight && halfWidth / inSampleSize >= desiredWidth) {
+ inSampleSize *= 2
+ }
+ }
+
+ return inSampleSize
+}
+
+private fun rotateToMetadataOrientation(bitmap: Bitmap, exifInterface: ExifInterface): Bitmap {
+ val orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
+ val matrix = Matrix()
+ when (orientation) {
+ ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
+ ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
+ ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
+ ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
+ ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
+ ExifInterface.ORIENTATION_TRANSPOSE -> {
+ matrix.preRotate(-90f)
+ matrix.preScale(-1f, 1f)
+ }
+ ExifInterface.ORIENTATION_TRANSVERSE -> {
+ matrix.preRotate(90f)
+ matrix.preScale(-1f, 1f)
+ }
+ else -> return bitmap
+ }
+
+ return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt
index b12e4d9986..80df69cbcc 100644
--- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt
@@ -16,9 +16,13 @@
package io.element.android.libraries.androidutils.file
+import android.content.Context
import io.element.android.libraries.core.data.tryOrNull
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
+import java.util.UUID
fun File.safeDelete() {
tryOrNull(
@@ -32,3 +36,8 @@ fun File.safeDelete() {
}
)
}
+
+suspend fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File = withContext(Dispatchers.IO) {
+ val suffix = extension?.let { ".$extension" }
+ File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
+}
diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt
new file mode 100644
index 0000000000..3b29787285
--- /dev/null
+++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.androidutils.media
+
+import android.media.MediaMetadataRetriever
+
+/** [MediaMetadataRetriever] only implements `AutoClosable` since API 29, so we need to execute this to have the same in older APIs. */
+inline fun MediaMetadataRetriever.runAndRelease(block: MediaMetadataRetriever.() -> T): T {
+ return block().also { release() }
+}
diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt
index 082623b4de..20e2a6d8ec 100644
--- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt
+++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt
@@ -31,6 +31,11 @@ object MimeTypes {
const val Jpeg = "image/jpeg"
const val Gif = "image/gif"
+ const val Videos = "video/*"
+ const val Mp4 = "video/mp4"
+
+ const val Audio = "audio/*"
+
const val Ogg = "audio/ogg"
const val PlainText = "text/plain"
diff --git a/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/FeatureListView.kt b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/FeatureListView.kt
index 280e062110..2f17482875 100644
--- a/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/FeatureListView.kt
+++ b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/FeatureListView.kt
@@ -16,8 +16,7 @@
package io.element.android.libraries.featureflag.ui
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@@ -34,14 +33,10 @@ fun FeatureListView(
onCheckedChange: (FeatureUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
- LazyColumn(
+ Column(
modifier = modifier,
) {
- items(
- items = features,
- key = { it.key }
- ) { feature ->
-
+ features.forEach { feature ->
fun onCheckedChange(isChecked: Boolean) {
onCheckedChange(feature, isChecked)
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt
index 6547f32448..6ed6e474b6 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt
@@ -18,5 +18,6 @@ package io.element.android.libraries.matrix.api.media
data class AudioInfo(
val duration: Long?,
- val size: Long?
+ val size: Long?,
+ val mimeType: String?,
)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt
index 972873ab38..f52e24d79b 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt
@@ -21,5 +21,5 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
interface NotificationService {
- suspend fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId): Result
+ fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId): Result
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
index 35451874aa..90b55a3a42 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
@@ -20,10 +20,15 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.media.AudioInfo
+import io.element.android.libraries.matrix.api.media.FileInfo
+import io.element.android.libraries.matrix.api.media.ImageInfo
+import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable
+import java.io.File
interface MatrixRoom : Closeable {
val sessionId: SessionId
@@ -67,6 +72,14 @@ interface MatrixRoom : Closeable {
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result
+ suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result
+
+ suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result
+
+ suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result
+
+ suspend fun sendFile(file: File, fileInfo: FileInfo): Result
+
suspend fun leave(): Result
suspend fun acceptInvitation(): Result
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt
index 7c977f7569..3c9bd030b0 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt
@@ -17,7 +17,6 @@
package io.element.android.libraries.matrix.api.room
import io.element.android.libraries.matrix.api.core.UserId
-import io.element.android.libraries.matrix.api.user.MatrixUser
data class RoomMember(
val userId: UserId,
@@ -30,12 +29,6 @@ data class RoomMember(
val isIgnored: Boolean,
)
-fun RoomMember.toMatrixUser() = MatrixUser(
- userId = userId,
- displayName = displayName,
- avatarUrl = avatarUrl,
-)
-
enum class RoomMembershipState {
BAN, INVITE, JOIN, KNOCK, LEAVE
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index f1c60770b4..7327ff7b5f 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -14,6 +14,8 @@
* limitations under the License.
*/
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
package io.element.android.libraries.matrix.impl
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@@ -44,7 +46,9 @@ import io.element.android.libraries.matrix.impl.verification.RustSessionVerifica
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
@@ -54,7 +58,9 @@ import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.RequiredState
+import org.matrix.rustcomponents.sdk.SlidingSyncList
import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder
+import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt
import org.matrix.rustcomponents.sdk.SlidingSyncMode
import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters
import org.matrix.rustcomponents.sdk.TaskHandle
@@ -81,7 +87,7 @@ class RustMatrixClient constructor(
client = client,
dispatchers = dispatchers,
)
- private val notificationService = RustNotificationService(baseDirectory, dispatchers)
+ private val notificationService = RustNotificationService(client)
private var slidingSyncUpdateJob: Job? = null
private val clientDelegate = object : ClientDelegate {
@@ -104,7 +110,8 @@ class RustMatrixClient constructor(
notTags = emptyList()
)
- private val visibleRoomsSlidingSyncList = SlidingSyncListBuilder()
+ private val visibleRoomsSlidingSyncList = MutableSharedFlow(replay = 1)
+ private val visibleRoomsSlidingSyncListBuilder = SlidingSyncListBuilder("CurrentlyVisibleRooms")
.timelineLimit(limit = 1u)
.requiredState(
requiredState = listOf(
@@ -114,16 +121,19 @@ class RustMatrixClient constructor(
)
)
.filters(visibleRoomsSlidingSyncFilters)
- .name(name = "CurrentlyVisibleRooms")
.syncMode(mode = SlidingSyncMode.SELECTIVE)
.addRange(0u, 20u)
- .use {
- it.build()
- }
+ .onceBuilt(object : SlidingSyncListOnceBuilt {
+ override fun updateList(list: SlidingSyncList): SlidingSyncList {
+ visibleRoomsSlidingSyncList.tryEmit(list)
+ return list
+ }
+ })
private val invitesSlidingSyncFilters = visibleRoomsSlidingSyncFilters.copy(isInvite = true)
- private val invitesSlidingSyncList = SlidingSyncListBuilder()
+ private val invitesSlidingSyncList = MutableSharedFlow(replay = 1)
+ private val invitesSlidingSyncListBuilder = SlidingSyncListBuilder("CurrentInvites")
.timelineLimit(limit = 1u)
.requiredState(
requiredState = listOf(
@@ -133,20 +143,22 @@ class RustMatrixClient constructor(
)
)
.filters(invitesSlidingSyncFilters)
- .name(name = "CurrentInvites")
.syncMode(mode = SlidingSyncMode.SELECTIVE)
.addRange(0u, 20u)
- .use {
- it.build()
- }
+ .onceBuilt(object : SlidingSyncListOnceBuilt {
+ override fun updateList(list: SlidingSyncList): SlidingSyncList {
+ invitesSlidingSyncList.tryEmit(list)
+ return list
+ }
+ })
private val slidingSync = client
.slidingSync()
.homeserver("https://slidingsync.lab.matrix.org")
.withCommonExtensions()
.storageKey("ElementX")
- .addList(visibleRoomsSlidingSyncList)
- .addList(invitesSlidingSyncList)
+ .addList(visibleRoomsSlidingSyncListBuilder)
+ .addList(invitesSlidingSyncListBuilder)
.use {
it.build()
}
@@ -158,7 +170,6 @@ class RustMatrixClient constructor(
slidingSync,
visibleRoomsSlidingSyncList,
dispatchers,
- ::onRestartSync
)
override val roomSummaryDataSource: RoomSummaryDataSource
@@ -170,7 +181,6 @@ class RustMatrixClient constructor(
slidingSync,
invitesSlidingSyncList,
dispatchers,
- ::onRestartSync
)
override val invitesDataSource: RoomSummaryDataSource
@@ -196,11 +206,6 @@ class RustMatrixClient constructor(
.launchIn(coroutineScope)
}
- private fun onRestartSync() {
- stopSync()
- startSync()
- }
-
override fun getRoom(roomId: RoomId): MatrixRoom? {
val slidingSyncRoom = slidingSync.getRoom(roomId.value) ?: return null
val fullRoom = slidingSyncRoom.fullRoom() ?: return null
@@ -304,7 +309,10 @@ class RustMatrixClient constructor(
rustRoomSummaryDataSource.close()
rustInvitesDataSource.close()
client.setDelegate(null)
- visibleRoomsSlidingSyncList.destroy()
+ visibleRoomsSlidingSyncListBuilder.destroy()
+ invitesSlidingSyncListBuilder.destroy()
+ visibleRoomsSlidingSyncList.resetReplayCache()
+ invitesSlidingSyncList.resetReplayCache()
slidingSync.destroy()
verificationService.destroy()
client.destroy()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt
index 1108dd1cc8..7c35c14fb7 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt
@@ -21,5 +21,12 @@ import org.matrix.rustcomponents.sdk.AudioInfo as RustAudioInfo
fun RustAudioInfo.map(): AudioInfo = AudioInfo(
duration = duration?.toLong(),
- size = size?.toLong()
+ size = size?.toLong(),
+ mimeType = mimetype
+)
+
+fun AudioInfo.map(): RustAudioInfo = RustAudioInfo(
+ duration = duration?.toULong(),
+ size = size?.toULong(),
+ mimetype = mimeType,
)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt
index d9f8f74df1..c287c9446f 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt
@@ -25,3 +25,10 @@ fun RustFileInfo.map(): FileInfo = FileInfo(
thumbnailInfo = thumbnailInfo?.map(),
thumbnailSource = thumbnailSource?.map()
)
+
+fun FileInfo.map(): RustFileInfo = RustFileInfo(
+ mimetype = mimetype,
+ size = size?.toULong(),
+ thumbnailInfo = thumbnailInfo?.map(),
+ thumbnailSource = null
+)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt
index 3716912c79..b66cec96fd 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt
@@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.impl.media
import io.element.android.libraries.matrix.api.media.ImageInfo
+import org.matrix.rustcomponents.sdk.MediaSource
import org.matrix.rustcomponents.sdk.ImageInfo as RustImageInfo
fun RustImageInfo.map(): ImageInfo = ImageInfo(
@@ -28,3 +29,13 @@ fun RustImageInfo.map(): ImageInfo = ImageInfo(
thumbnailSource = thumbnailSource?.map(),
blurhash = blurhash
)
+
+fun ImageInfo.map(): RustImageInfo = RustImageInfo(
+ height = height?.toULong(),
+ width = width?.toULong(),
+ mimetype = mimetype,
+ size = size?.toULong(),
+ thumbnailInfo = thumbnailInfo?.map(),
+ thumbnailSource = null,
+ blurhash = blurhash,
+)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
index 87805f98bd..b7e69226c0 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
@@ -64,6 +64,7 @@ class RustMediaLoader(
mediaSourceFromUrl(source.url).use { mediaSource ->
val mediaFile = innerClient.getMediaFile(
mediaSource = mediaSource,
+ body = null,
mimeType = mimeType ?: "application/octet-stream"
)
RustMediaFile(mediaFile)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ThumbnailInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ThumbnailInfo.kt
index 3f10720132..c3940ac967 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ThumbnailInfo.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ThumbnailInfo.kt
@@ -25,3 +25,10 @@ fun RustThumbnailInfo.map(): ThumbnailInfo = ThumbnailInfo(
mimetype = mimetype,
size = size?.toLong()
)
+
+fun ThumbnailInfo.map(): RustThumbnailInfo = RustThumbnailInfo(
+ height = height?.toULong(),
+ width = width?.toULong(),
+ mimetype = mimetype,
+ size = size?.toULong()
+)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt
index 09e717c85d..b474c2ab2e 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt
@@ -29,3 +29,14 @@ fun RustVideoInfo.map(): VideoInfo = VideoInfo(
thumbnailSource = thumbnailSource?.map(),
blurhash = blurhash
)
+
+fun VideoInfo.map(): RustVideoInfo = RustVideoInfo(
+ duration = duration?.toULong(),
+ height = height?.toULong(),
+ width = width?.toULong(),
+ mimetype = mimetype,
+ size = size?.toULong(),
+ thumbnailInfo = thumbnailInfo?.map(),
+ thumbnailSource = null,
+ blurhash = blurhash
+)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
index 079b1e0a5a..4b121db9bf 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt
@@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.impl.notification
+import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -36,7 +37,7 @@ class NotificationMapper @Inject constructor() {
senderDisplayName = it.senderDisplayName,
roomAvatarUrl = it.roomAvatarUrl,
isDirect = it.isDirect,
- isEncrypted = it.isEncrypted,
+ isEncrypted = it.isEncrypted.orFalse(),
isNoisy = it.isNoisy
)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt
index 1368c17243..bd94de21fc 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt
@@ -17,33 +17,30 @@
package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationService
import kotlinx.coroutines.withContext
+import org.matrix.rustcomponents.sdk.Client
+import org.matrix.rustcomponents.sdk.use
import java.io.File
class RustNotificationService(
- private val baseDirectory: File,
- private val dispatchers: CoroutineDispatchers,
+ private val client: Client,
) : NotificationService {
private val notificationMapper: NotificationMapper = NotificationMapper()
- override suspend fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId): Result {
- return withContext(dispatchers.io) {
- runCatching {
- org.matrix.rustcomponents.sdk.NotificationService(
- basePath = File(baseDirectory, "sessions").absolutePath,
- userId = userId.value
- ).use {
- // TODO Not implemented yet, see https://github.com/matrix-org/matrix-rust-sdk/issues/1628
- it.getNotificationItem(roomId.value, eventId.value)?.let { notificationItem ->
- notificationMapper.map(notificationItem)
- }
- }
- }
+ override fun getNotification(
+ userId: SessionId,
+ roomId: RoomId,
+ eventId: EventId
+ ): Result {
+ return runCatching {
+ client.getNotificationItem(roomId.value, eventId.value).use(notificationMapper::map)
}
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt
index 2d2be4f36a..e79a8088aa 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt
@@ -24,17 +24,18 @@ import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
object RoomMemberMapper {
- fun map(roomMember: RustRoomMember): RoomMember =
+ fun map(roomMember: RustRoomMember): RoomMember = roomMember.use {
RoomMember(
- UserId(roomMember.userId()),
- roomMember.displayName(),
- roomMember.avatarUrl(),
- mapMembership(roomMember.membership()),
- roomMember.isNameAmbiguous(),
- roomMember.powerLevel(),
- roomMember.normalizedPowerLevel(),
- roomMember.isIgnored(),
+ UserId(it.userId()),
+ it.displayName(),
+ it.avatarUrl(),
+ mapMembership(it.membership()),
+ it.isNameAmbiguous(),
+ it.powerLevel(),
+ it.normalizedPowerLevel(),
+ it.isIgnored(),
)
+ }
fun mapMembership(membershipState: RustMembershipState): RoomMembershipState =
when (membershipState) {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
index 9caba227ce..ae35449a8e 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
@@ -21,10 +21,15 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.media.AudioInfo
+import io.element.android.libraries.matrix.api.media.FileInfo
+import io.element.android.libraries.matrix.api.media.ImageInfo
+import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
+import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
@@ -33,13 +38,13 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
-import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.SlidingSyncRoom
import org.matrix.rustcomponents.sdk.UpdateSummary
import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
+import java.io.File
class RustMatrixRoom(
override val sessionId: SessionId,
@@ -156,9 +161,10 @@ class RustMatrixRoom(
override suspend fun sendMessage(message: String): Result = withContext(coroutineDispatchers.io) {
val transactionId = genTransactionId()
- val content = messageEventContentFromMarkdown(message)
- runCatching {
- innerRoom.send(content, transactionId)
+ messageEventContentFromMarkdown(message).use { content ->
+ runCatching {
+ innerRoom.send(content, transactionId)
+ }
}
}
@@ -203,4 +209,28 @@ class RustMatrixRoom(
}
}
+ override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result {
+ return runCatching {
+ innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
+ }
+ }
+
+ override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result {
+ return runCatching {
+ innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map())
+ }
+ }
+
+ override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result {
+ return runCatching {
+ innerRoom.sendAudio(file.path, audioInfo.map())
+ }
+ }
+
+ override suspend fun sendFile(file: File, fileInfo: FileInfo): Result {
+ return runCatching {
+ innerRoom.sendFile(file.path, fileInfo.map())
+ }
+ }
+
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt
index 9c2382fb4a..c908651e75 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt
@@ -27,9 +27,12 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.RoomListEntry
import org.matrix.rustcomponents.sdk.SlidingSync
@@ -44,9 +47,8 @@ import java.util.UUID
internal class RustRoomSummaryDataSource(
private val slidingSyncUpdateFlow: Flow,
private val slidingSync: SlidingSync,
- private val slidingSyncList: SlidingSyncList,
+ private val slidingSyncListFlow: Flow,
private val coroutineDispatchers: CoroutineDispatchers,
- private val onRestartSync: () -> Unit,
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
) : RoomSummaryDataSource, Closeable {
@@ -57,34 +59,35 @@ internal class RustRoomSummaryDataSource(
fun init() {
coroutineScope.launch {
+ val slidingSyncList = slidingSyncListFlow.first()
+ val summaries = slidingSyncList.currentRoomList().map(::buildSummaryForRoomListEntry)
updateRoomSummaries {
- addAll(
- slidingSyncList.currentRoomList().map(::buildSummaryForRoomListEntry)
- )
+ addAll(summaries)
}
+
+ slidingSyncList.roomListDiff(this)
+ .onEach { diffs ->
+ updateRoomSummaries {
+ applyDiff(diffs)
+ }
+ }
+ .launchIn(this)
+
+ slidingSyncList.state(this)
+ .onEach { slidingSyncState ->
+ Timber.v("New sliding sync state: $slidingSyncState")
+ state.value = slidingSyncState
+ }.launchIn(this)
}
slidingSyncUpdateFlow
.onEach {
didReceiveSyncUpdate(it)
}.launchIn(coroutineScope)
-
- slidingSyncList.roomListDiff(coroutineScope)
- .onEach { diffs ->
- updateRoomSummaries {
- applyDiff(diffs)
- }
- }
- .launchIn(coroutineScope)
-
- slidingSyncList.state(coroutineScope)
- .onEach { slidingSyncState ->
- Timber.v("New sliding sync state: $slidingSyncState")
- state.value = slidingSyncState
- }.launchIn(coroutineScope)
}
override fun close() {
+ runBlocking { slidingSyncListFlow.firstOrNull() }?.close()
coroutineScope.cancel()
}
@@ -95,8 +98,9 @@ internal class RustRoomSummaryDataSource(
override fun setSlidingSyncRange(range: IntRange) {
Timber.v("setVisibleRange=$range")
- slidingSyncList.setRange(range.first.toUInt(), range.last.toUInt())
- onRestartSync()
+ coroutineScope.launch {
+ slidingSyncListFlow.first().setRange(range.first.toUInt(), range.last.toUInt())
+ }
}
private suspend fun didReceiveSyncUpdate(summary: UpdateSummary) {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt
index 7151f3550b..5b4eaa9e36 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt
@@ -27,12 +27,12 @@ class MatrixTimelineItemMapper(
) {
fun map(timelineItem: TimelineItem): MatrixTimelineItem = timelineItem.use {
- val asEvent = timelineItem.asEvent()
+ val asEvent = it.asEvent()
if (asEvent != null) {
val eventTimelineItem = eventTimelineItemMapper.map(asEvent)
return MatrixTimelineItem.Event(eventTimelineItem)
}
- val asVirtual = timelineItem.asVirtual()
+ val asVirtual = it.asVirtual()
if (asVirtual != null) {
val virtualTimelineItem = virtualTimelineItemMapper.map(asVirtual)
return MatrixTimelineItem.Virtual(virtualTimelineItem)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt
index 439ce46b1d..2e4693c1fb 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt
@@ -38,7 +38,7 @@ import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat
class EventMessageMapper {
fun map(message: Message): MessageContent = message.use {
- val type = message.msgtype().use { type ->
+ val type = it.msgtype().use { type ->
when (type) {
is MessageType.Audio -> {
AudioMessageType(type.content.body, type.content.source.map(), type.content.info?.map())
@@ -67,9 +67,9 @@ class EventMessageMapper {
}
}
MessageContent(
- body = message.body(),
- inReplyTo = message.inReplyTo()?.eventId?.let(::EventId),
- isEdited = message.isEdited(),
+ body = it.body(),
+ inReplyTo = it.inReplyTo()?.eventId?.let(::EventId),
+ isEdited = it.isEdited(),
type = type
)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt
index 3ec412ae7d..726b448099 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt
@@ -31,18 +31,18 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
fun map(eventTimelineItem: RustEventTimelineItem): EventTimelineItem = eventTimelineItem.use {
EventTimelineItem(
- uniqueIdentifier = eventTimelineItem.uniqueIdentifier(),
- eventId = eventTimelineItem.eventId()?.let { EventId(it) },
- isEditable = eventTimelineItem.isEditable(),
- isLocal = eventTimelineItem.isLocal(),
- isOwn = eventTimelineItem.isOwn(),
- isRemote = eventTimelineItem.isRemote(),
- localSendState = eventTimelineItem.localSendState()?.map(),
- reactions = eventTimelineItem.reactions().map(),
- sender = UserId(eventTimelineItem.sender()),
- senderProfile = eventTimelineItem.senderProfile().map(),
- timestamp = eventTimelineItem.timestamp().toLong(),
- content = contentMapper.map(eventTimelineItem.content())
+ uniqueIdentifier = it.uniqueIdentifier(),
+ eventId = it.eventId()?.let { EventId(it) },
+ isEditable = it.isEditable(),
+ isLocal = it.isLocal(),
+ isOwn = it.isOwn(),
+ isRemote = it.isRemote(),
+ localSendState = it.localSendState()?.map(),
+ reactions = it.reactions().map(),
+ sender = UserId(it.sender()),
+ senderProfile = it.senderProfile().map(),
+ timestamp = it.timestamp().toLong(),
+ content = contentMapper.map(it.content())
)
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
index 2a30d80323..33727c15d4 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
@@ -39,7 +39,7 @@ import org.matrix.rustcomponents.sdk.OtherState as RustOtherState
class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMapper = EventMessageMapper()) {
fun map(content: TimelineItemContent): EventContent = content.use {
- when (val kind = content.kind()) {
+ when (val kind = it.kind()) {
is TimelineItemContentKind.FailedToParseMessageLike -> {
FailedToParseMessageLikeContent(
eventType = kind.eventType,
@@ -54,7 +54,7 @@ class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMap
)
}
TimelineItemContentKind.Message -> {
- val message = content.asMessage()
+ val message = it.asMessage()
if (message == null) {
UnknownContent
} else {
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt
index 7cb92d35c5..b92c9b7a92 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt
@@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationService
class FakeNotificationService : NotificationService {
- override suspend fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId): Result {
+ override fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId): Result {
return Result.success(null)
}
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
index 28d6525739..a790812137 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
@@ -20,6 +20,10 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.media.AudioInfo
+import io.element.android.libraries.matrix.api.media.FileInfo
+import io.element.android.libraries.matrix.api.media.ImageInfo
+import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
@@ -30,6 +34,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
+import java.io.File
class FakeMatrixRoom(
override val sessionId: SessionId = A_SESSION_ID,
@@ -54,6 +59,9 @@ class FakeMatrixRoom(
private var updateMembersResult: Result = Result.success(Unit)
private var acceptInviteResult = Result.success(Unit)
private var rejectInviteResult = Result.success(Unit)
+ private var sendMediaResult = Result.success(Unit)
+ var sendMediaCount = 0
+ private set
var isInviteAccepted: Boolean = false
private set
@@ -128,6 +136,14 @@ class FakeMatrixRoom(
return rejectInviteResult
}
+ override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result = sendMediaResult.also { sendMediaCount++ }
+
+ override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result = sendMediaResult.also { sendMediaCount++ }
+
+ override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result = sendMediaResult.also { sendMediaCount++ }
+
+ override suspend fun sendFile(file: File, fileInfo: FileInfo): Result = sendMediaResult.also { sendMediaCount++ }
+
override fun close() = Unit
fun givenLeaveRoomError(throwable: Throwable?) {
@@ -165,4 +181,8 @@ class FakeMatrixRoom(
fun givenUnIgnoreResult(result: Result) {
unignoreResult = result
}
+
+ fun givenSendMediaResult(result: Result) {
+ sendMediaResult = result
+ }
}
diff --git a/libraries/mediapickers/build.gradle.kts b/libraries/mediapickers/api/build.gradle.kts
similarity index 84%
rename from libraries/mediapickers/build.gradle.kts
rename to libraries/mediapickers/api/build.gradle.kts
index 444244d2f0..75afa417f4 100644
--- a/libraries/mediapickers/build.gradle.kts
+++ b/libraries/mediapickers/api/build.gradle.kts
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 New Vector Ltd
+ * 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.
@@ -16,14 +16,16 @@
plugins {
id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
}
android {
- namespace = "io.element.android.libraries.mediapickers"
+ namespace = "io.element.android.libraries.mediapickers.api"
dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.core)
+ implementation(projects.libraries.di)
implementation(libs.inject)
testImplementation(libs.test.junit)
diff --git a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerLauncher.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerLauncher.kt
similarity index 96%
rename from libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerLauncher.kt
rename to libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerLauncher.kt
index 16f21a9683..9eca6b373a 100644
--- a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerLauncher.kt
+++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerLauncher.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package io.element.android.libraries.mediapickers
+package io.element.android.libraries.mediapickers.api
import androidx.activity.compose.ManagedActivityResultLauncher
diff --git a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt
new file mode 100644
index 0000000000..9b0b248aa5
--- /dev/null
+++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.mediapickers.api
+
+import android.net.Uri
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.compose.runtime.Composable
+
+interface PickerProvider {
+
+ @Composable
+ fun registerGalleryPicker(
+ onResult: (uri: Uri?, mimeType: String?) -> Unit
+ ): PickerLauncher
+
+ @Composable
+ fun registerFilePicker(
+ mimeType: String,
+ onResult: (Uri?) -> Unit
+ ): PickerLauncher
+
+ @Composable
+ fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher
+
+ @Composable
+ fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher
+
+}
diff --git a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerType.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt
similarity index 97%
rename from libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerType.kt
rename to libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt
index 354d9a4918..ad89175ddf 100644
--- a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerType.kt
+++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package io.element.android.libraries.mediapickers
+package io.element.android.libraries.mediapickers.api
import android.net.Uri
import androidx.activity.result.PickVisualMediaRequest
diff --git a/libraries/mediapickers/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt
similarity index 97%
rename from libraries/mediapickers/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt
rename to libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt
index 693ef40cfe..4f17081f8b 100644
--- a/libraries/mediapickers/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt
+++ b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt
@@ -20,6 +20,7 @@ import android.net.Uri
import androidx.activity.result.contract.ActivityResultContracts
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.mediapickers.api.PickerType
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
diff --git a/libraries/mediapickers/impl/build.gradle.kts b/libraries/mediapickers/impl/build.gradle.kts
new file mode 100644
index 0000000000..856e7765b0
--- /dev/null
+++ b/libraries/mediapickers/impl/build.gradle.kts
@@ -0,0 +1,31 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+}
+
+android {
+ namespace = "io.element.android.libraries.mediapickers.impl"
+
+ dependencies {
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.di)
+ implementation(libs.inject)
+ api(projects.libraries.mediapickers.api)
+ }
+}
diff --git a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt
similarity index 78%
rename from libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt
rename to libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt
index 720378aaca..b7e9bad420 100644
--- a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt
+++ b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package io.element.android.libraries.mediapickers
+package io.element.android.libraries.mediapickers.impl
import android.content.Context
import android.net.Uri
@@ -25,12 +25,19 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.core.content.FileProvider
-import io.element.android.libraries.core.mimetype.MimeTypes
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.mediapickers.api.ComposePickerLauncher
+import io.element.android.libraries.mediapickers.api.NoOpPickerLauncher
+import io.element.android.libraries.mediapickers.api.PickerLauncher
+import io.element.android.libraries.mediapickers.api.PickerProvider
+import io.element.android.libraries.mediapickers.api.PickerType
import java.io.File
import java.util.UUID
import javax.inject.Inject
-class PickerProvider constructor(private val isInTest: Boolean) {
+@ContributesBinding(AppScope::class)
+class PickerProviderImpl constructor(private val isInTest: Boolean) : PickerProvider {
@Inject
constructor(): this(false)
@@ -57,12 +64,18 @@ class PickerProvider constructor(private val isInTest: Boolean) {
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
*/
@Composable
- fun registerGalleryPicker(onResult: (Uri?) -> Unit): PickerLauncher {
+ override fun registerGalleryPicker(
+ onResult: (uri: Uri?, mimeType: String?) -> Unit
+ ): PickerLauncher {
// Tests and UI preview can't handle Contexts, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
- NoOpPickerLauncher { onResult(null) }
+ NoOpPickerLauncher { onResult(null, null) }
} else {
- rememberPickerLauncher(type = PickerType.ImageAndVideo) { uri -> onResult(uri) }
+ val context = LocalContext.current
+ rememberPickerLauncher(type = PickerType.ImageAndVideo) { uri ->
+ val mimeType = uri?.let { context.contentResolver.getType(it) }
+ onResult(uri, mimeType)
+ }
}
}
@@ -71,7 +84,10 @@ class PickerProvider constructor(private val isInTest: Boolean) {
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
*/
@Composable
- fun registerFilePicker(mimeType: String = MimeTypes.Any, onResult: (Uri?) -> Unit): PickerLauncher {
+ override fun registerFilePicker(
+ mimeType: String,
+ onResult: (Uri?) -> Unit,
+ ): PickerLauncher {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { onResult(null) }
@@ -83,11 +99,9 @@ class PickerProvider constructor(private val isInTest: Boolean) {
/**
* Remembers and returns a [PickerLauncher] for taking a photo with a camera app.
* @param [onResult] will be called with either the photo's [Uri] or `null` if nothing was selected.
- * @param [deleteAfter] When it's `true`, the taken photo will be automatically removed after calling [onResult].
- * It's `true` by default.
*/
@Composable
- fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit, deleteAfter: Boolean = true): PickerLauncher {
+ override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { onResult(null) }
@@ -98,10 +112,6 @@ class PickerProvider constructor(private val isInTest: Boolean) {
rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success ->
// Execute callback
onResult(if (success) tmpFileUri else null)
- // Then remove the file and clear the picker
- if (deleteAfter) {
- tmpFile.delete()
- }
}
}
}
@@ -109,11 +119,9 @@ class PickerProvider constructor(private val isInTest: Boolean) {
/**
* Remembers and returns a [PickerLauncher] for recording a video with a camera app.
* @param [onResult] will be called with either the video's [Uri] or `null` if nothing was selected.
- * @param [deleteAfter] When it's `true`, the recorded video will be automatically removed after calling [onResult].
- * It's `true` by default.
*/
@Composable
- fun registerCameraVideoPicker(onResult: (Uri?) -> Unit, deleteAfter: Boolean = true): PickerLauncher {
+ override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { onResult(null) }
@@ -124,10 +132,6 @@ class PickerProvider constructor(private val isInTest: Boolean) {
rememberPickerLauncher(type = PickerType.Camera.Video(tmpFileUri)) { success ->
// Execute callback
onResult(if (success) tmpFileUri else null)
- // Then remove the file and clear the picker
- if (deleteAfter) {
- tmpFile.delete()
- }
}
}
}
diff --git a/libraries/mediapickers/test/build.gradle.kts b/libraries/mediapickers/test/build.gradle.kts
new file mode 100644
index 0000000000..87bde9b6e7
--- /dev/null
+++ b/libraries/mediapickers/test/build.gradle.kts
@@ -0,0 +1,31 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+}
+
+android {
+ namespace = "io.element.android.libraries.mediapickers.test"
+
+ dependencies {
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.di)
+ implementation(libs.inject)
+ api(projects.libraries.mediapickers.api)
+ }
+}
diff --git a/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt
new file mode 100644
index 0000000000..2a32387db4
--- /dev/null
+++ b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.mediapickers.test
+
+import android.net.Uri
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.compose.runtime.Composable
+import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.mediapickers.api.NoOpPickerLauncher
+import io.element.android.libraries.mediapickers.api.PickerLauncher
+import io.element.android.libraries.mediapickers.api.PickerProvider
+
+class FakePickerProvider : PickerProvider {
+ private var mimeType = MimeTypes.Any
+ private var result: Uri? = null
+
+ @Composable
+ override fun registerGalleryPicker(onResult: (uri: Uri?, mimeType: String?) -> Unit): PickerLauncher {
+ return NoOpPickerLauncher { onResult(result, mimeType) }
+ }
+
+ @Composable
+ override fun registerFilePicker(mimeType: String, onResult: (Uri?) -> Unit): PickerLauncher {
+ return NoOpPickerLauncher { onResult(result) }
+ }
+
+ @Composable
+ override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher {
+ return NoOpPickerLauncher { onResult(result) }
+ }
+
+ @Composable
+ override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher {
+ return NoOpPickerLauncher { onResult(result) }
+ }
+
+ fun givenResult(value: Uri?) {
+ this.result = value
+ }
+
+ fun givenMimeType(mimeType: String) {
+ this.mimeType = mimeType
+ }
+}
diff --git a/libraries/mediaupload/api/build.gradle.kts b/libraries/mediaupload/api/build.gradle.kts
new file mode 100644
index 0000000000..111abc2bcc
--- /dev/null
+++ b/libraries/mediaupload/api/build.gradle.kts
@@ -0,0 +1,42 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id("io.element.android-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "io.element.android.libraries.mediaupload.api"
+
+ anvil {
+ generateDaggerFactories.set(true)
+ }
+
+ dependencies {
+ implementation(projects.anvilannotations)
+ anvil(projects.anvilcodegen)
+
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.di)
+ api(projects.libraries.matrix.api)
+ implementation(libs.inject)
+ implementation(libs.coroutines.core)
+ }
+}
diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt
new file mode 100644
index 0000000000..6e2168ca4b
--- /dev/null
+++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.mediaupload.api
+
+import android.net.Uri
+
+interface MediaPreProcessor {
+ /**
+ * Given a [uri] and [mediaType], pre-processes the media before it's uploaded, resizing, transcoding, and removing sensitive info from its metadata.
+ * If [deleteOriginal] is `true`, the file reference by the [uri] will be automatically deleted too when this process finishes.
+ * @return a [Result] with the [MediaUploadInfo] containing all the info needed to begin the upload.
+ */
+ suspend fun process(
+ uri: Uri,
+ mediaType: MediaType,
+ deleteOriginal: Boolean = false
+ ): Result
+}
diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaType.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaType.kt
new file mode 100644
index 0000000000..e16ca43699
--- /dev/null
+++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaType.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.mediaupload.api
+
+sealed interface MediaType {
+ object Image : MediaType
+ object Video : MediaType
+ object Audio : MediaType
+ object File : MediaType
+}
diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt
new file mode 100644
index 0000000000..6696806e24
--- /dev/null
+++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.mediaupload.api
+
+import io.element.android.libraries.matrix.api.media.AudioInfo
+import io.element.android.libraries.matrix.api.media.FileInfo
+import io.element.android.libraries.matrix.api.media.ImageInfo
+import io.element.android.libraries.matrix.api.media.ThumbnailInfo
+import io.element.android.libraries.matrix.api.media.VideoInfo
+import java.io.File
+
+sealed interface MediaUploadInfo {
+ data class Image(val file: File, val info: ImageInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo
+ data class Video(val file: File, val info: VideoInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo
+ data class Audio(val file: File, val info: AudioInfo) : MediaUploadInfo
+ data class AnyFile(val file: File, val info: FileInfo) : MediaUploadInfo
+}
+
+data class ThumbnailProcessingInfo(
+ val file: File,
+ val info: ThumbnailInfo,
+ val blurhash: String,
+)
diff --git a/libraries/mediaupload/impl/build.gradle.kts b/libraries/mediaupload/impl/build.gradle.kts
new file mode 100644
index 0000000000..a23ab14b74
--- /dev/null
+++ b/libraries/mediaupload/impl/build.gradle.kts
@@ -0,0 +1,50 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id("io.element.android-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "io.element.android.libraries.mediaupload.impl"
+
+ anvil {
+ generateDaggerFactories.set(true)
+ }
+
+ dependencies {
+ implementation(projects.anvilannotations)
+ anvil(projects.anvilcodegen)
+
+ api(projects.libraries.mediaupload.api)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.di)
+ implementation(projects.libraries.matrix.api)
+ implementation(libs.inject)
+ implementation(libs.androidx.exifinterface)
+ implementation(libs.coroutines.core)
+ implementation(libs.otaliastudios.transcoder)
+ implementation(libs.vanniktech.blurhash)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.test.truth)
+ }
+}
diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt
new file mode 100644
index 0000000000..96d5e2ea63
--- /dev/null
+++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.mediaupload
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import com.vanniktech.blurhash.BlurHash
+import io.element.android.libraries.androidutils.bitmap.calculateInSampleSize
+import io.element.android.libraries.androidutils.bitmap.resizeToMax
+import io.element.android.libraries.androidutils.bitmap.rotateToMetadataOrientation
+import io.element.android.libraries.androidutils.file.createTmpFile
+import io.element.android.libraries.di.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.BufferedInputStream
+import java.io.File
+import java.io.InputStream
+import javax.inject.Inject
+
+class ImageCompressor @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+
+ /**
+ * Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode], then writes it into a
+ * temporary file using the passed [format] and [desiredQuality].
+ * @return a [Result] containing the resulting [ImageCompressionResult] with the temporary [File] and some metadata.
+ */
+ suspend fun compressToTmpFile(
+ inputStream: InputStream,
+ resizeMode: ResizeMode,
+ format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG,
+ desiredQuality: Int = 80,
+ ): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ val compressedBitmap = compressToBitmap(inputStream, resizeMode).getOrThrow()
+ val blurhash = BlurHash.encode(compressedBitmap, 3, 3)
+
+ // Encode bitmap to the destination temporary file
+ val tmpFile = context.createTmpFile(extension = "jpeg")
+ tmpFile.outputStream().use {
+ compressedBitmap.compress(format, desiredQuality, it)
+ }
+
+ ImageCompressionResult(
+ file = tmpFile,
+ width = compressedBitmap.width,
+ height = compressedBitmap.height,
+ size = tmpFile.length(),
+ blurhash = blurhash
+ )
+ }
+ }
+
+ /**
+ * Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode].
+ * @return a [Result] containing the resulting [Bitmap].
+ */
+ fun compressToBitmap(
+ inputStream: InputStream,
+ resizeMode: ResizeMode,
+ ): Result = runCatching {
+ BufferedInputStream(inputStream).use { input ->
+ val options = BitmapFactory.Options()
+ calculateDecodingScale(input, resizeMode, options)
+ val decodedBitmap = BitmapFactory.decodeStream(input, null, options)
+ ?: error("Decoding Bitmap from InputStream failed")
+ val rotatedBitmap = decodedBitmap.rotateToMetadataOrientation(input).getOrThrow()
+ if (resizeMode is ResizeMode.Strict) {
+ rotatedBitmap.resizeToMax(resizeMode.maxWidth, resizeMode.maxHeight)
+ } else {
+ rotatedBitmap
+ }
+ }
+ }
+
+ private fun calculateDecodingScale(
+ inputStream: BufferedInputStream,
+ resizeMode: ResizeMode,
+ options: BitmapFactory.Options
+ ) {
+ val (width, height) = when (resizeMode) {
+ is ResizeMode.Approximate -> resizeMode.desiredWidth to resizeMode.desiredHeight
+ is ResizeMode.Strict -> (resizeMode.maxWidth / 2) to (resizeMode.maxHeight / 2)
+ is ResizeMode.None -> return
+ }
+ // Read bounds only
+ inputStream.mark(inputStream.available())
+ options.inJustDecodeBounds = true
+ BitmapFactory.decodeStream(inputStream, null, options)
+ // Set sample size based on the outWidth and outHeight
+ options.inSampleSize = options.calculateInSampleSize(width, height)
+ // Now read the actual image and rotate it to match its metadata
+ inputStream.reset()
+ options.inJustDecodeBounds = false
+ }
+}
+
+data class ImageCompressionResult(
+ val file: File,
+ val width: Int,
+ val height: Int,
+ val size: Long,
+ val blurhash: String,
+)
+
+sealed interface ResizeMode {
+ object None : ResizeMode
+ data class Approximate(val desiredWidth: Int, val desiredHeight: Int) : ResizeMode
+ data class Strict(val maxWidth: Int, val maxHeight: Int) : ResizeMode
+}
diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt
new file mode 100644
index 0000000000..76cc7fae1e
--- /dev/null
+++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt
@@ -0,0 +1,267 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.mediaupload
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.media.MediaMetadataRetriever
+import android.net.Uri
+import androidx.exifinterface.media.ExifInterface
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.androidutils.file.createTmpFile
+import io.element.android.libraries.androidutils.media.runAndRelease
+import io.element.android.libraries.core.data.tryOrNull
+import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.matrix.api.media.AudioInfo
+import io.element.android.libraries.matrix.api.media.FileInfo
+import io.element.android.libraries.matrix.api.media.ImageInfo
+import io.element.android.libraries.matrix.api.media.MatrixMediaSource
+import io.element.android.libraries.matrix.api.media.ThumbnailInfo
+import io.element.android.libraries.matrix.api.media.VideoInfo
+import io.element.android.libraries.mediaupload.api.MediaPreProcessor
+import io.element.android.libraries.mediaupload.api.MediaType
+import io.element.android.libraries.mediaupload.api.MediaUploadInfo
+import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.withContext
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.InputStream
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
+
+@ContributesBinding(AppScope::class)
+class MediaPreProcessorImpl @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val imageCompressor: ImageCompressor,
+ private val videoCompressor: VideoCompressor,
+) : MediaPreProcessor {
+ companion object {
+ /**
+ * Used for calculating `inSampleSize` for bitmaps.
+ *
+ * *Note*: Ideally, this should result in images of up to (but not included) 1280x1280 being sent. However, images with very different width and height
+ * values may surpass this limit. (i.e.: an image of `480x3000px` would have `inSampleSize=1` and be sent as is).
+ */
+ private const val IMAGE_SCALE_REF_SIZE = 640
+
+ /**
+ * Max width of thumbnail images.
+ * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/?ref=blog.gitter.im#thumbnails).
+ */
+ private const val THUMB_MAX_WIDTH = 800
+ /**
+ * Max height of thumbnail images.
+ * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/?ref=blog.gitter.im#thumbnails).
+ */
+ private const val THUMB_MAX_HEIGHT = 600
+
+ /**
+ * Frame of the video to be used for generating a thumbnail.
+ */
+ private val VIDEO_THUMB_FRAME = 5.seconds.inWholeMicroseconds
+ }
+
+ private val contentResolver = context.contentResolver
+
+ override suspend fun process(
+ uri: Uri,
+ mediaType: MediaType,
+ deleteOriginal: Boolean,
+ ): Result = runCatching {
+ // Camera returns an 'octet-stream' mimetype, so it needs to be overridden
+ val mimeType = contentResolver.getType(uri)
+ val mimeTypeOrDefault = if (mimeType == MimeTypes.OctetStream) {
+ when(mediaType) {
+ MediaType.Image -> MimeTypes.Jpeg
+ MediaType.Video -> MimeTypes.Mp4
+ MediaType.Audio -> MimeTypes.Ogg
+ else -> mimeType
+ }
+ } else {
+ mimeType
+ }
+ val compressBeforeSending = mediaType in sequenceOf(MediaType.Image, MediaType.Video)
+ val result = if (compressBeforeSending && mimeType != MimeTypes.Gif) {
+ when(mediaType) {
+ MediaType.Image -> processImage(uri)
+ MediaType.Video -> processVideo(uri, mimeTypeOrDefault)
+ MediaType.Audio -> processAudio(uri, mimeTypeOrDefault)
+ else -> error("Cannot compress file of type: $mimeType")
+ }
+ } else {
+ val file = copyToTmpFile(uri)
+ // Remove image metadata here too
+ if (mimeType.isMimeTypeImage() && mimeType != MimeTypes.Gif) {
+ removeSensitiveImageMetadata(file)
+ }
+ val info = FileInfo(
+ mimetype = mimeType,
+ size = file.length(),
+ thumbnailInfo = null,
+ thumbnailSource = null,
+ )
+ MediaUploadInfo.AnyFile(file, info)
+ }
+
+ if (deleteOriginal) {
+ contentResolver.delete(uri, null, null)
+ }
+
+ result
+ }
+
+ private suspend fun processImage(uri: Uri): MediaUploadInfo {
+ val compressedFileResult = contentResolver.openInputStream(uri).use { input ->
+ imageCompressor.compressToTmpFile(
+ inputStream = requireNotNull(input),
+ resizeMode = ResizeMode.Approximate(IMAGE_SCALE_REF_SIZE, IMAGE_SCALE_REF_SIZE),
+ ).getOrThrow()
+ }
+
+ removeSensitiveImageMetadata(compressedFileResult.file)
+
+ val thumbnailResult = compressedFileResult.file.inputStream().use { generateImageThumbnail(it) }
+ val processingResult = compressedFileResult.toImageInfo(MimeTypes.Jpeg, thumbnailResult.file.path, thumbnailResult.info)
+ return MediaUploadInfo.Image(compressedFileResult.file, processingResult, thumbnailResult)
+ }
+
+ private suspend fun processVideo(uri: Uri, mimeType: String?): MediaUploadInfo {
+ val thumbnailInfo = extractVideoThumbnail(uri)
+ val resultFile = videoCompressor.compress(uri)
+ .onEach {
+ // TODO handle progress
+ }
+ .filterIsInstance()
+ .first()
+ .file
+
+ val videoProcessingInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo.file.path, thumbnailInfo)
+ return MediaUploadInfo.Video(resultFile, videoProcessingInfo, thumbnailInfo)
+ }
+
+ private suspend fun processAudio(uri: Uri, mimeType: String?): MediaUploadInfo {
+ val file = copyToTmpFile(uri)
+ return MediaMetadataRetriever().runAndRelease {
+ setDataSource(context, Uri.fromFile(file))
+
+ val info = AudioInfo(
+ duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L,
+ size = file.length(),
+ mimeType = mimeType,
+ )
+
+ MediaUploadInfo.Audio(file, info)
+ }
+ }
+
+ private suspend fun generateImageThumbnail(inputStream: InputStream): ThumbnailProcessingInfo {
+ val thumbnailResult = imageCompressor
+ .compressToTmpFile(
+ inputStream = inputStream,
+ resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT),
+ ).getOrThrow()
+
+ return thumbnailResult.toThumbnailProcessingInfo(MimeTypes.Jpeg)
+ }
+
+ private fun removeSensitiveImageMetadata(file: File) {
+ // Remove GPS info, user comments and subject location tags
+ val exifInterface = ExifInterface(file)
+ // See ExifInterface.TAG_GPS_INFO_IFD_POINTER
+ exifInterface.setAttribute("GPSInfoIFDPointer", null)
+ exifInterface.setAttribute(ExifInterface.TAG_USER_COMMENT, null)
+ exifInterface.setAttribute(ExifInterface.TAG_SUBJECT_LOCATION, null)
+ tryOrNull { exifInterface.saveAttributes() }
+ }
+
+ private suspend fun createTmpFileWithInput(inputStream: InputStream): File? {
+ return withContext(Dispatchers.IO) {
+ tryOrNull {
+ val tmpFile = context.createTmpFile()
+ tmpFile.outputStream().use { inputStream.copyTo(it) }
+ tmpFile
+ }
+ }
+ }
+
+ private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailUrl: String?, thumbnailInfo: ThumbnailProcessingInfo?): VideoInfo =
+ MediaMetadataRetriever().runAndRelease {
+ setDataSource(context, Uri.fromFile(file))
+
+ VideoInfo(
+ duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L,
+ width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L,
+ height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L,
+ mimetype = mimeType,
+ size = file.length(),
+ thumbnailInfo = thumbnailInfo?.info,
+ thumbnailSource = thumbnailUrl?.let { MatrixMediaSource(it) },
+ blurhash = thumbnailInfo?.blurhash,
+ )
+ }
+
+ private suspend fun extractVideoThumbnail(uri: Uri): ThumbnailProcessingInfo =
+ MediaMetadataRetriever().runAndRelease {
+ setDataSource(context, uri)
+ val bitmap = requireNotNull(getFrameAtTime(VIDEO_THUMB_FRAME))
+ val inputStream = ByteArrayOutputStream().use {
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it)
+ ByteArrayInputStream(it.toByteArray())
+ }
+
+ val result = imageCompressor.compressToTmpFile(
+ inputStream = inputStream,
+ resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT),
+ )
+
+ result.getOrThrow().toThumbnailProcessingInfo(MimeTypes.Jpeg)
+ }
+
+ private suspend fun copyToTmpFile(uri: Uri): File {
+ return contentResolver.openInputStream(uri)?.use { createTmpFileWithInput(it) }
+ ?: error("Could not copy the contents of $uri to a temporary file")
+ }
+}
+
+fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailUrl: String?, thumbnailInfo: ThumbnailInfo?) = ImageInfo(
+ width = width.toLong(),
+ height = height.toLong(),
+ mimetype = mimeType,
+ size = size,
+ thumbnailInfo = thumbnailInfo,
+ thumbnailSource = thumbnailUrl?.let { MatrixMediaSource(it) },
+ blurhash = blurhash,
+)
+
+fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = ThumbnailProcessingInfo(
+ file = file,
+ info = ThumbnailInfo(
+ width = width.toLong(),
+ height = height.toLong(),
+ mimetype = mimeType,
+ size = size,
+ ),
+ blurhash = blurhash,
+)
diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt
new file mode 100644
index 0000000000..490e286353
--- /dev/null
+++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.mediaupload
+
+import android.content.Context
+import android.net.Uri
+import com.otaliastudios.transcoder.Transcoder
+import com.otaliastudios.transcoder.TranscoderListener
+import io.element.android.libraries.androidutils.file.createTmpFile
+import io.element.android.libraries.di.ApplicationContext
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.callbackFlow
+import java.io.File
+import javax.inject.Inject
+
+class VideoCompressor @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+
+ fun compress(uri: Uri) = callbackFlow {
+ val tmpFile = context.createTmpFile(extension = "mp4")
+ val future = Transcoder.into(tmpFile.path)
+ .addDataSource(context, uri)
+ .setListener(object : TranscoderListener {
+ override fun onTranscodeProgress(progress: Double) {
+ trySend(VideoTranscodingEvent.Progress(progress.toFloat()))
+ }
+
+ override fun onTranscodeCompleted(successCode: Int) {
+ trySend(VideoTranscodingEvent.Completed(tmpFile))
+ close()
+ }
+
+ override fun onTranscodeCanceled() {
+ tmpFile.delete()
+ close()
+ }
+
+ override fun onTranscodeFailed(exception: Throwable) {
+ tmpFile.delete()
+ close(exception)
+ }
+ })
+ .transcode()
+
+ awaitClose {
+ if (!future.isDone) {
+ future.cancel(true)
+ }
+ }
+ }
+}
+
+sealed interface VideoTranscodingEvent {
+ data class Progress(val value: Float) : VideoTranscodingEvent
+ data class Completed(val file: File) : VideoTranscodingEvent
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootEvents.kt b/libraries/mediaupload/test/build.gradle.kts
similarity index 77%
rename from appnav/src/main/kotlin/io/element/android/appnav/root/RootEvents.kt
rename to libraries/mediaupload/test/build.gradle.kts
index f6e4103017..6daa89b152 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootEvents.kt
+++ b/libraries/mediaupload/test/build.gradle.kts
@@ -14,8 +14,14 @@
* limitations under the License.
*/
-package io.element.android.appnav.root
-
-sealed interface RootEvents {
- object HideShowkaseButton : RootEvents
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.libraries.matrix.test"
+}
+
+dependencies {
+ api(projects.libraries.mediaupload.api)
}
diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt
new file mode 100644
index 0000000000..08a284af6c
--- /dev/null
+++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt
@@ -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.libraries.mediaupload.test
+
+import android.net.Uri
+import io.element.android.libraries.matrix.api.media.FileInfo
+import io.element.android.libraries.mediaupload.api.MediaPreProcessor
+import io.element.android.libraries.mediaupload.api.MediaType
+import io.element.android.libraries.mediaupload.api.MediaUploadInfo
+import java.io.File
+
+class FakeMediaPreProcessor : MediaPreProcessor {
+
+ private var result: Result = Result.success(
+ MediaUploadInfo.AnyFile(
+ File("test"),
+ FileInfo(
+ mimetype = "*/*",
+ size = 999L,
+ thumbnailInfo = null,
+ thumbnailUrl = null,
+ )
+ )
+ )
+ override suspend fun process(uri: Uri, mediaType: MediaType, deleteOriginal: Boolean): Result = result
+
+ fun givenResult(value: Result) {
+ this.result = value
+ }
+}
diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt
index 10e6edf3f5..24b174426c 100644
--- a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt
+++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalPermissionsApi::class)
+@file:OptIn(ExperimentalPermissionsApi::class)
package io.element.android.libraries.permissions.impl
@@ -25,7 +25,6 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.permissions.api.PermissionsEvents
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/libraries/permissions/noop/src/test/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterTest.kt b/libraries/permissions/noop/src/test/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterTest.kt
index 36b908cb0d..9992480436 100644
--- a/libraries/permissions/noop/src/test/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterTest.kt
+++ b/libraries/permissions/noop/src/test/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterTest.kt
@@ -14,15 +14,12 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.libraries.permissions.noop
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt
index 4f5316497d..48e4daa40c 100644
--- a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt
+++ b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt
@@ -14,13 +14,10 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.libraries.pushstore.impl.clientsecret
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.SessionId
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt
index 0260604f6e..28b9dfba50 100644
--- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt
+++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt
@@ -20,12 +20,10 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
import io.element.android.libraries.matrix.session.SessionData
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
-@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseSessionStoreTests {
private lateinit var database: SessionDatabase
diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml
index dfedf9cf23..4cd9d61ac9 100644
--- a/libraries/ui-strings/src/main/res/values-de/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-de/translations.xml
@@ -101,4 +101,4 @@
"Fehler"
"Erfolg"
"Nutzer blockieren"
-
+
\ No newline at end of file
diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml
index 4ec790f330..78a386f650 100644
--- a/libraries/ui-strings/src/main/res/values-es/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-es/translations.xml
@@ -136,4 +136,4 @@
"Error"
"Terminado"
"Bloquear usuario"
-
+
\ No newline at end of file
diff --git a/libraries/ui-strings/src/main/res/values-it/translations.xml b/libraries/ui-strings/src/main/res/values-it/translations.xml
index a0e90ece3e..20f255a21c 100644
--- a/libraries/ui-strings/src/main/res/values-it/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-it/translations.xml
@@ -136,4 +136,4 @@
"Errore"
"Operazione riuscita"
"Blocca utente"
-
+
\ No newline at end of file
diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml
index 32a7e62d45..d7b052badc 100644
--- a/libraries/ui-strings/src/main/res/values-ro/translations.xml
+++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml
@@ -122,10 +122,12 @@
"%1$s Android"
- "%1$d membru"
+ - "%1$d membri"
- "%1$d membri"
- "%1$d schimbare a camerii"
+ - "%1$d schimbări ale camerei"
- "%1$d schimbări ale camerei"
"Rageshake pentru a raporta erori"
@@ -154,4 +156,4 @@
"Puteți citi toate condițiile noastre %1$s."
"aici"
"Blocați utilizatorul"
-
+
\ No newline at end of file
diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt
index 18ce6e55f8..885a830b96 100644
--- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt
+++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt
@@ -90,7 +90,8 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:di"))
implementation(project(":libraries:session-storage:impl"))
implementation(project(":libraries:statemachine"))
- implementation(project(":libraries:mediapickers"))
+ implementation(project(":libraries:mediapickers:impl"))
+ implementation(project(":libraries:mediaupload:impl"))
}
fun DependencyHandlerScope.allServicesImpl() {
diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
index df1f052e79..aab7d5ca5f 100644
--- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
+++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
@@ -33,17 +33,16 @@ import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import java.util.Locale
-import java.util.concurrent.Executors
class RoomListScreen(
context: Context,
private val matrixClient: MatrixClient,
+ private val coroutineDispatchers: CoroutineDispatchers = Singleton.coroutineDispatchers,
) {
private val clock = Clock.System
private val locale = Locale.getDefault()
@@ -58,28 +57,24 @@ class RoomListScreen(
sessionVerificationService = sessionVerificationService,
networkMonitor = NetworkMonitorImpl(context),
snackbarDispatcher = SnackbarDispatcher(),
- inviteStateDataSource = DefaultInviteStateDataSource(
- matrixClient,
- DefaultSeenInvitesStore(context),
- CoroutineDispatchers(
- io = Dispatchers.IO,
- computation = Dispatchers.Default,
- main = Dispatchers.Main,
- diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
- )
- )
+ inviteStateDataSource = DefaultInviteStateDataSource(matrixClient, DefaultSeenInvitesStore(context), coroutineDispatchers)
)
@Composable
fun Content(modifier: Modifier = Modifier) {
fun onRoomClicked(roomId: RoomId) {
- val room = matrixClient.getRoom(roomId)!!
- val timeline = room.timeline()
Singleton.appScope.launch {
- timeline.apply {
- initialize()
- paginateBackwards(20, 50)
- dispose()
+ withContext(coroutineDispatchers.io) {
+ matrixClient.getRoom(roomId)!!.use { room ->
+ val timeline = room.timeline()
+
+ timeline.apply {
+ // TODO This doesn't work reliably as initialize is asynchronous, and the timeline can't be used until it's finished
+ initialize()
+ paginateBackwards(20, 50)
+ dispose()
+ }
+ }
}
}
}
diff --git a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt
index 1ccd4c5b7a..d6000dc1d8 100644
--- a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt
+++ b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalCoroutinesApi::class)
-
package io.element.android.services.appnavstate.impl
import com.google.common.truth.Truth.assertThat
@@ -28,11 +26,11 @@ import io.element.android.services.appnavstate.test.A_ROOM_OWNER
import io.element.android.services.appnavstate.test.A_SESSION_OWNER
import io.element.android.services.appnavstate.test.A_SPACE_OWNER
import io.element.android.services.appnavstate.test.A_THREAD_OWNER
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertThrows
import org.junit.Test
+
class DefaultAppNavigationStateServiceTest {
@Test
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png
index 0298cc02c7..57b4a846e2 100644
--- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d07bdb794fe858dfc3e3122c10b24dcf5a4918d21deb3c87686139a2d94f4f43
-size 18918
+oid sha256:5d806cbab0f26fb4f471adb5fbecfc600603a7651c5391501d42b13b23617a4c
+size 29301
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png
index 5ae68d249f..d8d2dbc8d1 100644
--- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:e944e15b9eb9477e57b002d6b16c8a28bc8bc25d8bef40cdf6982509e4424c7f
-size 18211
+oid sha256:d04eb5d2ebb8b740edcaac00912994e8c164e7b5083595d4085d350af0ca6c33
+size 28482
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_0,NEXUS_5,1.0,en].png
index 27f36f9248..f96c3b07b9 100644
--- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_0,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_0,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6893108aabbf06f43af90f17b6b1a8a521312559f407647ce08db06ecb9a8f84
-size 22033
+oid sha256:29fa45284bef52648e02629a440b335595c7d4a4d04d0da5c4acbe9ae457bad6
+size 46918
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_2,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..d03094bab0
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_2,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:49e7533e6afba903219942193e0cc5cfbe2f67ba3b57516f77c259e7c42e8e3f
+size 11813
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_3,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..f13a65f8e2
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_3,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:89ea65099fb4981bbeb24e49afb7400b0e8da79e3b783e198519ba1e970404a8
+size 8317
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_4,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..74a7f599cd
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_4,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9a8ba207cf61c56b64c6855d04c8a30def0b3daf325adf57edd45b40981ed745
+size 7733
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_5,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..31ca39b498
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_5,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:92c4537ed8794f4db08b4edec74a0de41847c4fd8c4fe03b7112bc63124b0a61
+size 29326
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_6,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..0f9a5a79cc
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_6,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d5b879b74654fdad0638f432a71adfc9186fbe00dafd5d963a3affb33ec8c5c8
+size 12841
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_0,NEXUS_5,1.0,en].png
index b493c070d2..c6b9c1b3ab 100644
--- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_0,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_0,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:3e7b5bd916d4d3067b5013400b4ac864fab560e96fcb75ec895684021a00b8ba
-size 21808
+oid sha256:3d40819700e3cbe9ca8ae1e5886de036a6dc05a7da6dd366490cc79c27ae3e8d
+size 45653
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_2,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..3d261cb40b
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_2,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9c3abebbe9e55706af5f4d1089e4308b0e975258a9d6a0e1793a4208b36c9c93
+size 11754
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_3,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..eb23dcec96
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_3,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b8de2f28cf9918a7eddcc1311c34ba0376242a4708724b57689df000b7480524
+size 8197
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_4,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..e2e4a33e67
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_4,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:687133e4729ea42382ac0b76af6ceaf8b20cbc65923412fb97b077d2475c70f7
+size 7514
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_5,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..4275020d4c
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_5,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1f663a70c43b9f2e66b651f17169b63859fe7c87c21312b61ed61a9136eabe5f
+size 27989
diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_6,NEXUS_5,1.0,en].png
new file mode 100644
index 0000000000..787c71d87e
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_6,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:534cba0c13aa52b3557ab8df854c521dd13b82e5a1550d73abcef12925e389da
+size 11878