From 6b77313fd6e3b2495c4d57b9acb1ee993cbbc3de Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 4 May 2023 17:29:03 +0200 Subject: [PATCH 01/32] Move the button to open the Showkase browser to the developer settings screen. (#389) --- .maestro/tests/init.yaml | 1 - .../io/element/android/appnav/RootFlowNode.kt | 9 --- .../element/android/appnav/root/RootEvents.kt | 4 +- .../android/appnav/root/RootPresenter.kt | 11 +-- .../element/android/appnav/root/RootState.kt | 1 - .../android/appnav/root/RootStateProvider.kt | 3 - .../element/android/appnav/root/RootView.kt | 6 -- .../android/appnav/root/ShowkaseButton.kt | 71 ------------------- .../android/appnav/RootPresenterTest.kt | 16 +---- .../impl/developer/DeveloperSettingsNode.kt | 17 ++++- .../impl/developer/DeveloperSettingsView.kt | 62 +++++++--------- .../featureflag/ui/FeatureListView.kt | 11 +-- 12 files changed, 47 insertions(+), 165 deletions(-) delete mode 100644 appnav/src/main/kotlin/io/element/android/appnav/root/ShowkaseButton.kt 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/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/RootEvents.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootEvents.kt index f6e4103017..a10274be61 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootEvents.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootEvents.kt @@ -16,6 +16,4 @@ package io.element.android.appnav.root -sealed interface RootEvents { - object HideShowkaseButton : RootEvents -} +sealed interface RootEvents 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..c62c8c5867 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,20 +29,13 @@ 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 - } + fun handleEvent(@Suppress("UNUSED_PARAMETER") event: RootEvents) { } 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..36c6d3ced9 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,7 +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..7ac7cdcffc 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,7 +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..ec21e83d6a 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( @@ -54,11 +53,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..fdfef67745 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt @@ -44,21 +44,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/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..f6a440e529 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,12 @@ package io.element.android.features.preferences.impl.developer +import android.app.Activity +import android.content.Intent 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 +39,22 @@ class DeveloperSettingsNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { + val activity = LocalContext.current as Activity + fun openShowkase() { + val intent = Intent(activity, ShowkaseBrowserActivity::class.java) + intent.putExtra( + "SHOWKASE_ROOT_MODULE", + "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..47c4bdc328 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 @@ -44,44 +34,41 @@ import io.element.android.libraries.ui.strings.R fun DeveloperSettingsView( state: DeveloperSettingsState, modifier: Modifier = Modifier, + onOpenShowkase: () -> Unit, onBackPressed: () -> Unit, ) { - 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/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) } From e89fcdcc94a3b2a47a274add222d50d2788d90be Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 4 May 2023 17:31:08 +0200 Subject: [PATCH 02/32] Avoid using hard-coded key, use `ShowkaseBrowserActivity.getIntent()` instead. --- .../preferences/impl/developer/DeveloperSettingsNode.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 f6a440e529..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 @@ -17,7 +17,6 @@ package io.element.android.features.preferences.impl.developer import android.app.Activity -import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -41,10 +40,9 @@ class DeveloperSettingsNode @AssistedInject constructor( override fun View(modifier: Modifier) { val activity = LocalContext.current as Activity fun openShowkase() { - val intent = Intent(activity, ShowkaseBrowserActivity::class.java) - intent.putExtra( - "SHOWKASE_ROOT_MODULE", - "io.element.android.libraries.designsystem.showkase.DesignSystemShowkaseRootModule" + val intent = ShowkaseBrowserActivity.getIntent( + context = activity, + rootModuleCanonicalName = "io.element.android.libraries.designsystem.showkase.DesignSystemShowkaseRootModule" ) activity.startActivity(intent) } From 8f5ae86c7d21e6ee39a3390073feceadf862d308 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 4 May 2023 20:39:10 +0000 Subject: [PATCH 03/32] Update dependency org.robolectric:robolectric to v4.10.2 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8a0d5bb50a..c5cac0be61 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -114,7 +114,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 From f3ee05b939ddd8d1fc038dac9ff1c522e2a13536 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 May 2023 09:43:08 +0200 Subject: [PATCH 04/32] Fix issue in task, previous screenshot were never deleted. --- build.gradle.kts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index a0648f165a..da94f1f0fd 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) } From 6fd12448cba299c96620134b475df262a45a6b78 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 May 2023 09:47:10 +0200 Subject: [PATCH 05/32] Update documentation about screenshot test. --- docs/screenshot_testing.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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. From 2223e870877dd15736df74d94e7f0bf30c566031 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 May 2023 09:49:28 +0200 Subject: [PATCH 06/32] Record screenshot tests. --- ...eloperSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...loperSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 From 4d7ec3916b53caf9d6948e0c2ffd254e1d61c5ec Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 May 2023 11:49:27 +0200 Subject: [PATCH 07/32] Re-order parameters. --- .../preferences/impl/developer/DeveloperSettingsView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 47c4bdc328..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 @@ -33,9 +33,9 @@ import io.element.android.libraries.ui.strings.R @Composable fun DeveloperSettingsView( state: DeveloperSettingsState, - modifier: Modifier = Modifier, onOpenShowkase: () -> Unit, onBackPressed: () -> Unit, + modifier: Modifier = Modifier, ) { PreferenceView( modifier = modifier, From d36338b4ef2e48158d03456bad63acb7f3dfd2bd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 5 May 2023 10:13:05 +0000 Subject: [PATCH 08/32] Update dependency com.squareup:kotlinpoet to v1.13.2 --- anvilcodegen/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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") From 629c87153bc82294a2dedb774b3f7e7e3220afc0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 May 2023 15:38:21 +0200 Subject: [PATCH 09/32] Cleanup --- .../element/android/appnav/root/RootEvents.kt | 19 ------------------- .../android/appnav/root/RootPresenter.kt | 4 ---- .../element/android/appnav/root/RootState.kt | 1 - .../android/appnav/root/RootStateProvider.kt | 1 - .../element/android/appnav/root/RootView.kt | 1 - 5 files changed, 26 deletions(-) delete mode 100644 appnav/src/main/kotlin/io/element/android/appnav/root/RootEvents.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootEvents.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootEvents.kt deleted file mode 100644 index a10274be61..0000000000 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootEvents.kt +++ /dev/null @@ -1,19 +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 - -sealed interface RootEvents 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 c62c8c5867..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 @@ -32,13 +32,9 @@ class RootPresenter @Inject constructor( val rageshakeDetectionState = rageshakeDetectionPresenter.present() val crashDetectionState = crashDetectionPresenter.present() - fun handleEvent(@Suppress("UNUSED_PARAMETER") event: RootEvents) { - } - return RootState( 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 36c6d3ced9..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 @@ -24,5 +24,4 @@ import io.element.android.features.rageshake.api.detection.RageshakeDetectionSta data class RootState( 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 7ac7cdcffc..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 @@ -37,5 +37,4 @@ open class RootStateProvider : PreviewParameterProvider { fun aRootState() = RootState( 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 ec21e83d6a..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 @@ -45,7 +45,6 @@ fun RootView( contentAlignment = Alignment.TopCenter, ) { children() - val eventSink = state.eventSink fun onOpenBugReport() { state.crashDetectionState.eventSink(CrashDetectionEvents.ResetAppHasCrashed) From 0a77dd6150da30e849011c412c9adbbacfda0ea7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 5 May 2023 16:22:00 +0200 Subject: [PATCH 10/32] Fix test. --- .../test/kotlin/io/element/android/appnav/RootPresenterTest.kt | 1 - 1 file changed, 1 deletion(-) 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 fdfef67745..2938ce41df 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt @@ -22,7 +22,6 @@ 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 From 403014b1dd1d9797388c92c740e24c47ba7bc18c Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Fri, 5 May 2023 14:14:12 +0200 Subject: [PATCH 11/32] Fix navigation broken --- .../features/createroom/impl/ConfigureRoomFlowNode.kt | 6 ++++-- .../createroom/impl/configureroom/ConfigureRoomNode.kt | 8 +++----- 2 files changed, 7 insertions(+), 7 deletions(-) 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 ) } } From f54b4e9f7e099827017a3f418929493dfc9eaf82 Mon Sep 17 00:00:00 2001 From: bmarty Date: Mon, 8 May 2023 00:08:48 +0000 Subject: [PATCH 12/32] Sync Strings from Localazy --- .../impl/src/main/res/values-es/translations.xml | 1 + .../impl/src/main/res/values-it/translations.xml | 1 + .../impl/src/main/res/values-ro/translations.xml | 2 +- .../logout/api/src/main/res/values-de/translations.xml | 2 +- .../logout/api/src/main/res/values-es/translations.xml | 2 +- .../logout/api/src/main/res/values-it/translations.xml | 2 +- .../logout/api/src/main/res/values-ro/translations.xml | 2 +- .../messages/impl/src/main/res/values-de/translations.xml | 8 ++++++++ .../impl/src/main/res/values-ro/translations.xml | 2 +- .../impl/src/main/res/values-es/translations.xml | 2 +- .../impl/src/main/res/values-it/translations.xml | 2 +- .../impl/src/main/res/values-ro/translations.xml | 2 +- .../roomdetails/impl/src/main/res/values/localazy.xml | 1 + .../ui-strings/src/main/res/values-de/translations.xml | 2 +- .../ui-strings/src/main/res/values-es/translations.xml | 2 +- .../ui-strings/src/main/res/values-it/translations.xml | 2 +- .../ui-strings/src/main/res/values-ro/translations.xml | 4 +++- 17 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 features/messages/impl/src/main/res/values-de/translations.xml 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/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/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/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/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/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 From 2b7fa99117f26db1ddefd5109f3c17379d19022d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 9 May 2023 09:50:08 +0200 Subject: [PATCH 13/32] trigger CI From 845d51486fb6e323dd3cbc81ef98d6f69ed11cd6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 9 May 2023 10:59:21 +0200 Subject: [PATCH 14/32] Upgrade version of lint from 8.0.0 to 8.2.0-alpha02 (latest). Fix warning: WARNING: The build will use lint version 8.0.0 which is older than the default. Recommendation: Remove or update the gradle property android.experimental.lint.version to be at least 8.0.1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From f4a51c41dfe703c5fc76310e29ac0ddf8b17c1e5 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Tue, 9 May 2023 17:07:23 +0200 Subject: [PATCH 15/32] Create CODEOWNERS This change, together with enabling the "Require review from Code Owners" option in the Github's branch protection rule will effectively auto assign a reviewer to any non-draft PR. --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 CODEOWNERS 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 From 5eaa40a14be2749b4b18bf7416ead348abcca8d0 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 10 May 2023 10:06:56 +0200 Subject: [PATCH 16/32] [Media upload] Media pre-processing (#403) * Create `mediaupload` module for media pre-processing. * Split `mediapicker` and `mediaupload` modules. --- .idea/kotlinc.xml | 2 +- app/src/main/AndroidManifest.xml | 18 +- changelog.d/396.feature | 1 + features/messages/impl/build.gradle.kts | 6 +- .../textcomposer/MessageComposerPresenter.kt | 44 ++- .../messages/MessagesPresenterTest.kt | 6 +- .../MessageComposerPresenterTest.kt | 187 ++++++------- gradle/libs.versions.toml | 2 + libraries/androidutils/build.gradle.kts | 1 + .../libraries/androidutils/bitmap/Bitmap.kt | 74 +++++ .../libraries/androidutils/file/File.kt | 8 + .../media/MediaMetaDataRetriever.kt | 24 ++ .../libraries/core/mimetype/MimeTypes.kt | 4 + .../mediapickers/{ => api}/build.gradle.kts | 6 +- .../mediapickers/api}/PickerLauncher.kt | 2 +- .../mediapickers/api/PickerProvider.kt | 42 +++ .../libraries/mediapickers/api}/PickerType.kt | 2 +- .../libraries/mediapickers/PickerTypeTests.kt | 1 + libraries/mediapickers/impl/build.gradle.kts | 31 +++ .../mediapickers/impl/PickerProviderImpl.kt} | 46 +-- libraries/mediapickers/test/build.gradle.kts | 31 +++ .../mediapickers/test/FakePickerProvider.kt | 58 ++++ libraries/mediaupload/api/build.gradle.kts | 42 +++ .../mediaupload/api/MediaPreProcessor.kt | 32 +++ .../libraries/mediaupload/api/MediaType.kt | 24 ++ .../mediaupload/api/MediaUploadInfo.kt | 36 +++ libraries/mediaupload/impl/build.gradle.kts | 49 ++++ .../libraries/mediaupload/ImageCompressor.kt | 117 ++++++++ .../mediaupload/MediaPreProcessorImpl.kt | 263 ++++++++++++++++++ .../libraries/mediaupload/VideoCompressor.kt | 71 +++++ libraries/mediaupload/test/build.gradle.kts | 27 ++ .../mediaupload/test/FakeMediaPreProcessor.kt | 44 +++ .../kotlin/extension/DependencyHandleScope.kt | 3 +- 33 files changed, 1148 insertions(+), 156 deletions(-) create mode 100644 changelog.d/396.feature create mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt rename libraries/mediapickers/{ => api}/build.gradle.kts (84%) rename libraries/mediapickers/{src/main/kotlin/io/element/android/libraries/mediapickers => api/src/main/kotlin/io/element/android/libraries/mediapickers/api}/PickerLauncher.kt (96%) create mode 100644 libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt rename libraries/mediapickers/{src/main/kotlin/io/element/android/libraries/mediapickers => api/src/main/kotlin/io/element/android/libraries/mediapickers/api}/PickerType.kt (97%) rename libraries/mediapickers/{ => api}/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt (97%) create mode 100644 libraries/mediapickers/impl/build.gradle.kts rename libraries/mediapickers/{src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt => impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt} (78%) create mode 100644 libraries/mediapickers/test/build.gradle.kts create mode 100644 libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt create mode 100644 libraries/mediaupload/api/build.gradle.kts create mode 100644 libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt create mode 100644 libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaType.kt create mode 100644 libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt create mode 100644 libraries/mediaupload/impl/build.gradle.kts create mode 100644 libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt create mode 100644 libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt create mode 100644 libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt create mode 100644 libraries/mediaupload/test/build.gradle.kts create mode 100644 libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 69e86158ba..217e5c51fb 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file 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/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/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index f53f130bf7..d50c02bc82 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -41,8 +41,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) @@ -60,6 +61,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/textcomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt index 8d495aa269..8193c0c0b2 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 @@ -16,6 +16,7 @@ package io.element.android.features.messages.impl.textcomposer +import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -28,12 +29,17 @@ import androidx.compose.runtime.setValue 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.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo 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.textcomposer.MessageComposerMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -46,27 +52,38 @@ class MessageComposerPresenter @Inject constructor( private val room: MatrixRoom, private val mediaPickerProvider: PickerProvider, private val featureFlagService: FeatureFlagService, + private val mediaPreProcessor: MediaPreProcessor, ) : Presenter { @Composable override fun present(): MessageComposerState { val localCoroutineScope = rememberCoroutineScope() - val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri -> + val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType -> Timber.d("Media picked from $uri") + // We don't know which type of media was retrieved, so we need this check + val mediaType = when { + mimeType.isMimeTypeImage() -> MediaType.Image + mimeType.isMimeTypeVideo() -> MediaType.Video + else -> error("MimeType must be either image/* or video/*") + } + localCoroutineScope.handleMediaPreProcessing(uri, mediaType) }) - val filesPicker = mediaPickerProvider.registerFilePicker(onResult = { uri -> + val filesPicker = mediaPickerProvider.registerFilePicker(mimeType = MimeTypes.Any) { uri -> Timber.d("File picked from $uri") - }) + localCoroutineScope.handleMediaPreProcessing(uri, MediaType.File) + } - val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { uri -> + val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> Timber.d("Photo saved at $uri") - }) + localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Image, deleteOriginal = true) + } - val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { uri -> + val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri -> Timber.d("Video saved at $uri") - }) + localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Video, deleteOriginal = true) + } val isFullScreen = rememberSaveable { mutableStateOf(false) @@ -163,4 +180,15 @@ class MessageComposerPresenter @Inject constructor( ) } } + + private fun CoroutineScope.handleMediaPreProcessing( + uri: Uri?, + mediaType: MediaType, + deleteOriginal: Boolean = false + ) = launch { + if (uri == null) return@launch + + val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal) + Timber.d("Pre-processed media result: $result") + } } 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..d2ba50ad5a 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 @@ -36,7 +36,8 @@ 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 @@ -132,8 +133,9 @@ class MessagesPresenterTest { val messageComposerPresenter = MessageComposerPresenter( appCoroutineScope = this, room = matrixRoom, - mediaPickerProvider = PickerProvider(isInTest = true), + mediaPickerProvider = FakePickerProvider(), featureFlagService = FakeFeatureFlagService(), + mediaPreProcessor = FakeMediaPreProcessor(), ) val timelinePresenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), 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..dc1efd7d88 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,23 +22,30 @@ 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.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.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.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.runTest @@ -46,21 +53,19 @@ import org.junit.Test 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() @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 +79,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 +95,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 +113,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 +129,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 +148,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 +165,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 +184,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 +215,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 +245,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 +258,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 +271,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 { @@ -342,13 +286,8 @@ class MessageComposerPresenterTest { @Test fun `present - Pick media from gallery`() = runTest { - val fakeMatrixRoom = FakeMatrixRoom() - val presenter = MessageComposerPresenter( - this, - fakeMatrixRoom, - pickerProvider, - featureFlagService, - ) + val presenter = createPresenter(this) + pickerProvider.givenMimeType(MimeTypes.Images) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -359,15 +298,38 @@ class MessageComposerPresenterTest { } } + @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 presenter = createPresenter(this) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -380,13 +342,7 @@ class MessageComposerPresenterTest { @Test fun `present - Take photo`() = runTest { - val fakeMatrixRoom = FakeMatrixRoom() - val presenter = MessageComposerPresenter( - this, - fakeMatrixRoom, - pickerProvider, - featureFlagService, - ) + val presenter = createPresenter(this) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -399,13 +355,7 @@ class MessageComposerPresenterTest { @Test fun `present - Record video`() = runTest { - val fakeMatrixRoom = FakeMatrixRoom() - val presenter = MessageComposerPresenter( - this, - fakeMatrixRoom, - pickerProvider, - featureFlagService, - ) + val presenter = createPresenter(this) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -415,6 +365,25 @@ class MessageComposerPresenterTest { // TODO verify some post processing of the captured video 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() + } + + private fun createPresenter( + coroutineScope: CoroutineScope, + room: MatrixRoom = FakeMatrixRoom(), + pickerProvider: PickerProvider = this.pickerProvider, + featureFlagService: FeatureFlagService = this.featureFlagService, + mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, + ) = MessageComposerPresenter( + coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor + ) } fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 551aa7d39f..e5495eaf3c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,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" } @@ -136,6 +137,7 @@ 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" # 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..137b15a893 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,7 @@ fun File.safeDelete() { } ) } + +suspend fun Context.createTmpFile(baseDir: File = cacheDir): File = withContext(Dispatchers.IO) { + File.createTempFile(UUID.randomUUID().toString(), null, 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..3e3afbfe0e 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,10 @@ object MimeTypes { const val Jpeg = "image/jpeg" const val Gif = "image/gif" + const val Videos = "video/*" + + const val Audio = "audio/*" + const val Ogg = "audio/ogg" const val PlainText = "text/plain" 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..36eb8f5102 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt @@ -0,0 +1,36 @@ +/* + * 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, +) diff --git a/libraries/mediaupload/impl/build.gradle.kts b/libraries/mediaupload/impl/build.gradle.kts new file mode 100644 index 0000000000..c451d81c7e --- /dev/null +++ b/libraries/mediaupload/impl/build.gradle.kts @@ -0,0 +1,49 @@ +/* + * 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) + + 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..45c09a2a47 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.mediaupload + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +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() + + // Encode bitmap to the destination temporary file + val tmpFile = context.createTmpFile() + tmpFile.outputStream().use { + compressedBitmap.compress(format, desiredQuality, it) + } + + ImageCompressionResult(tmpFile, compressedBitmap.width, compressedBitmap.height, tmpFile.length()) + } + } + + /** + * 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, +) + +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..3d51cb24f1 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt @@ -0,0 +1,263 @@ +/* + * 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.isMimeTypeAudio +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +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.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.catch +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 originalMimeType = contentResolver.getType(uri) + val mimeType = when (mediaType) { + MediaType.Image -> MimeTypes.Images + MediaType.Video -> MimeTypes.Videos + MediaType.Audio -> MimeTypes.Audio + else -> originalMimeType + } + val compressBeforeSending = mediaType in sequenceOf(MediaType.Image, MediaType.Video) + val result = if (compressBeforeSending && mimeType != MimeTypes.Gif) { + when { + mimeType.isMimeTypeImage() -> processImage(uri) + mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType) + mimeType.isMimeTypeAudio() -> processAudio(uri) + 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 = originalMimeType, + size = file.length(), + thumbnailInfo = null, + thumbnailUrl = 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?.info) + return MediaUploadInfo.Video(resultFile, videoProcessingInfo, thumbnailInfo) + } + + private suspend fun processAudio(uri: Uri): 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() + ) + + 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), + ).getOrNull() + + 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: ThumbnailInfo?): 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, + thumbnailUrl = thumbnailUrl, + blurhash = null, + ) + } + + private suspend fun extractVideoThumbnail(uri: Uri): ThumbnailProcessingInfo? = + MediaMetadataRetriever().runAndRelease { + setDataSource(context, uri) + val bitmap = getFrameAtTime(VIDEO_THUMB_FRAME) ?: return@runAndRelease null + 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, + thumbnailUrl = thumbnailUrl, + blurhash = null, +) + +fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = ThumbnailProcessingInfo( + file = file, + info = ThumbnailInfo( + width = width.toLong(), + height = height.toLong(), + mimetype = mimeType, + size = size, + ), +) 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..ea0dbf28fd --- /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() + 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/libraries/mediaupload/test/build.gradle.kts b/libraries/mediaupload/test/build.gradle.kts new file mode 100644 index 0000000000..6daa89b152 --- /dev/null +++ b/libraries/mediaupload/test/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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/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() { From 474bc00f8e7ca2b1a28b68c056cd9ec646b3c6b9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 May 2023 15:27:15 +0000 Subject: [PATCH 17/32] Update dependency org.matrix.rustcomponents:sdk-android to v0.1.12 (#407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update dependency org.matrix.rustcomponents:sdk-android to v0.1.12 * Fix Rust SDK update issues. * Try to handle Rust memory more gracefully. --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Jorge Martín --- gradle/libs.versions.toml | 2 +- .../api/notification/NotificationService.kt | 2 +- .../libraries/matrix/impl/RustMatrixClient.kt | 49 +++++++++++-------- .../notification/RustNotificationService.kt | 27 +++++----- .../matrix/impl/room/RustMatrixRoom.kt | 1 - .../impl/room/RustRoomSummaryDataSource.kt | 46 +++++++++-------- .../notification/FakeNotificationService.kt | 2 +- 7 files changed, 68 insertions(+), 61 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e5495eaf3c..2250d9bf39 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -129,7 +129,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.12" 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" } 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/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 73e37121d5..cce7ac90c7 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 @@ -44,7 +44,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 +56,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 @@ -82,7 +86,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 { @@ -105,7 +109,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( @@ -115,16 +120,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( @@ -134,20 +142,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() } @@ -159,7 +169,6 @@ class RustMatrixClient constructor( slidingSync, visibleRoomsSlidingSyncList, dispatchers, - ::onRestartSync ) override val roomSummaryDataSource: RoomSummaryDataSource @@ -171,7 +180,6 @@ class RustMatrixClient constructor( slidingSync, invitesSlidingSyncList, dispatchers, - ::onRestartSync ) override val invitesDataSource: RoomSummaryDataSource @@ -194,11 +202,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 @@ -297,6 +300,7 @@ class RustMatrixClient constructor( } } + @ExperimentalCoroutinesApi override fun close() { slidingSyncUpdateJob?.cancel() stopSync() @@ -304,7 +308,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/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/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 9caba227ce..062f24ab62 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 @@ -33,7 +33,6 @@ 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 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/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) } } From c4d0de7c8e4fe4e00ec703e70c9e545f85677f9b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 May 2023 19:14:16 +0000 Subject: [PATCH 18/32] Update core to v1.10.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2250d9bf39..a6e923667e 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" From 1765398eb1f6fef26b4fc865fe0a4f18aefcc5d4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 11 May 2023 16:36:46 +0200 Subject: [PATCH 19/32] No need to duplicate the workflow, the workflow can have several `on` sections. --- .github/workflows/nightly.yml | 1 + .github/workflows/nightly_manual.yml | 43 ---------------------------- 2 files changed, 1 insertion(+), 43 deletions(-) delete mode 100644 .github/workflows/nightly_manual.yml 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 }} From 89b9db3be6709a858a46311e266d1e962cdceff5 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Thu, 11 May 2023 17:56:13 +0200 Subject: [PATCH 20/32] [Media upload] Upload image, video and files (#411) * Add media upload * Display media upload error messages using a Snackbar. --- .../messages/impl/MessagesPresenter.kt | 6 + .../features/messages/impl/MessagesState.kt | 2 + .../messages/impl/MessagesStateProvider.kt | 1 + .../features/messages/impl/MessagesView.kt | 15 +- .../textcomposer/MessageComposerPresenter.kt | 55 +++++- .../messages/MessagesPresenterTest.kt | 3 + .../MessageComposerPresenterTest.kt | 172 ++++++++++++++---- gradle/libs.versions.toml | 1 + .../libraries/androidutils/file/File.kt | 5 +- .../libraries/core/mimetype/MimeTypes.kt | 1 + .../libraries/matrix/api/media/AudioInfo.kt | 3 +- .../libraries/matrix/api/room/MatrixRoom.kt | 13 ++ .../libraries/matrix/impl/media/AudioInfo.kt | 9 +- .../libraries/matrix/impl/media/FileInfo.kt | 7 + .../libraries/matrix/impl/media/ImageInfo.kt | 11 ++ .../matrix/impl/media/ThumbnailInfo.kt | 7 + .../libraries/matrix/impl/media/VideoInfo.kt | 11 ++ .../matrix/impl/room/RustMatrixRoom.kt | 30 +++ .../matrix/test/room/FakeMatrixRoom.kt | 20 ++ .../mediaupload/api/MediaUploadInfo.kt | 5 +- libraries/mediaupload/impl/build.gradle.kts | 1 + .../libraries/mediaupload/ImageCompressor.kt | 13 +- .../mediaupload/MediaPreProcessorImpl.kt | 57 +++--- .../libraries/mediaupload/VideoCompressor.kt | 2 +- 24 files changed, 373 insertions(+), 77 deletions(-) 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 5bf26d88bc..cd70dc97cd 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 @@ -94,7 +95,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 @@ -110,6 +110,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 8193c0c0b2..99d0f98b66 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 @@ -32,6 +32,8 @@ import io.element.android.libraries.core.data.toStableCharSequence import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +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 @@ -40,11 +42,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom 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.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( @@ -53,6 +57,7 @@ class MessageComposerPresenter @Inject constructor( private val mediaPickerProvider: PickerProvider, private val featureFlagService: FeatureFlagService, private val mediaPreProcessor: MediaPreProcessor, + private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { @Composable @@ -60,6 +65,7 @@ class MessageComposerPresenter @Inject constructor( val localCoroutineScope = rememberCoroutineScope() val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType -> + if (uri == null) return@registerGalleryPicker Timber.d("Media picked from $uri") // We don't know which type of media was retrieved, so we need this check val mediaType = when { @@ -67,22 +73,25 @@ class MessageComposerPresenter @Inject constructor( mimeType.isMimeTypeVideo() -> MediaType.Video else -> error("MimeType must be either image/* or video/*") } - localCoroutineScope.handleMediaPreProcessing(uri, mediaType) + appCoroutineScope.sendMedia(uri, mediaType) }) val filesPicker = mediaPickerProvider.registerFilePicker(mimeType = MimeTypes.Any) { uri -> + if (uri == null) return@registerFilePicker Timber.d("File picked from $uri") - localCoroutineScope.handleMediaPreProcessing(uri, MediaType.File) + appCoroutineScope.sendMedia(uri, MediaType.File) } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> + if (uri == null) return@registerCameraPhotoPicker Timber.d("Photo saved at $uri") - localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Image, deleteOriginal = true) + appCoroutineScope.sendMedia(uri, MediaType.Image, deleteOriginal = true) } val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri -> + if (uri == null) return@registerCameraVideoPicker Timber.d("Video saved at $uri") - localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Video, deleteOriginal = true) + appCoroutineScope.sendMedia(uri, MediaType.Video, deleteOriginal = true) } val isFullScreen = rememberSaveable { @@ -181,14 +190,44 @@ class MessageComposerPresenter @Inject constructor( } } - private fun CoroutineScope.handleMediaPreProcessing( - uri: Uri?, + private fun CoroutineScope.sendMedia( + uri: Uri, mediaType: MediaType, deleteOriginal: Boolean = false ) = launch { - if (uri == null) return@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) + } - val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal) + 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/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index d2ba50ad5a..6756f55f9c 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 @@ -31,6 +31,7 @@ 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 @@ -136,6 +137,7 @@ class MessagesPresenterTest { mediaPickerProvider = FakePickerProvider(), featureFlagService = FakeFeatureFlagService(), mediaPreProcessor = FakeMediaPreProcessor(), + snackbarDispatcher = SnackbarDispatcher(), ) val timelinePresenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), @@ -148,6 +150,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/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index dc1efd7d88..2ef7aa0c69 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 @@ -29,9 +29,13 @@ import io.element.android.features.messages.impl.textcomposer.MessageComposerPre 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 @@ -42,14 +46,22 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom 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.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.android.awaitFrame +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.Test +import java.io.File class MessageComposerPresenterTest { @@ -62,6 +74,7 @@ class MessageComposerPresenterTest { } } private val mediaPreProcessor = FakeMediaPreProcessor() + private val snackbarDispatcher = SnackbarDispatcher() @Test fun `present - initial state`() = runTest { @@ -285,16 +298,83 @@ class MessageComposerPresenterTest { } @Test - fun `present - Pick media from gallery`() = runTest { - val presenter = createPresenter(this) + 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) } } @@ -329,40 +409,67 @@ class MessageComposerPresenterTest { @Test fun `present - Pick file from storage`() = runTest { - val presenter = createPresenter(this) + 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 - } - } - - @Test - fun `present - Take photo`() = runTest { - val presenter = createPresenter(this) - 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 - } - } - - @Test - fun `present - Record video`() = runTest { - val presenter = createPresenter(this) - 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 + snackbarDispatcher.snackbarMessage.test { + // Initial value is always null + skipItems(1) + // Assert error message received + assertThat(awaitItem()).isNotNull() + } } } @@ -381,8 +488,9 @@ class MessageComposerPresenterTest { pickerProvider: PickerProvider = this.pickerProvider, featureFlagService: FeatureFlagService = this.featureFlagService, mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, + snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, ) = MessageComposerPresenter( - coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor + coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor, snackbarDispatcher ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a6e923667e..3504a3b0fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -138,6 +138,7 @@ 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/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 137b15a893..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 @@ -37,6 +37,7 @@ fun File.safeDelete() { ) } -suspend fun Context.createTmpFile(baseDir: File = cacheDir): File = withContext(Dispatchers.IO) { - File.createTempFile(UUID.randomUUID().toString(), null, baseDir).apply { mkdirs() } +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/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 3e3afbfe0e..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 @@ -32,6 +32,7 @@ object MimeTypes { const val Gif = "image/gif" const val Videos = "video/*" + const val Mp4 = "video/mp4" const val Audio = "audio/*" 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/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/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 98c96c4d9a..a13c48efc5 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 @@ -27,3 +27,10 @@ fun RustFileInfo.map(): FileInfo = FileInfo( thumbnailInfo = thumbnailInfo?.map(), thumbnailUrl = thumbnailSource?.useUrl() ) + +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 27ab6d656a..21130ab86e 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( thumbnailUrl = thumbnailSource?.useUrl(), 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/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 0db364f544..9d03e2be2f 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( thumbnailUrl = thumbnailSource?.useUrl(), 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/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 062f24ab62..3930ebebe6 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 @@ -39,6 +44,7 @@ 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, @@ -202,4 +208,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/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/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 index 36eb8f5102..6696806e24 100644 --- 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 @@ -24,8 +24,8 @@ 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 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 } @@ -33,4 +33,5 @@ sealed interface 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 index c451d81c7e..a23ab14b74 100644 --- a/libraries/mediaupload/impl/build.gradle.kts +++ b/libraries/mediaupload/impl/build.gradle.kts @@ -41,6 +41,7 @@ android { implementation(libs.androidx.exifinterface) implementation(libs.coroutines.core) implementation(libs.otaliastudios.transcoder) + implementation(libs.vanniktech.blurhash) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) 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 index 45c09a2a47..96d5e2ea63 100644 --- 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 @@ -19,6 +19,7 @@ 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 @@ -48,14 +49,21 @@ class ImageCompressor @Inject constructor( ): 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() + val tmpFile = context.createTmpFile(extension = "jpeg") tmpFile.outputStream().use { compressedBitmap.compress(format, desiredQuality, it) } - ImageCompressionResult(tmpFile, compressedBitmap.width, compressedBitmap.height, tmpFile.length()) + ImageCompressionResult( + file = tmpFile, + width = compressedBitmap.width, + height = compressedBitmap.height, + size = tmpFile.length(), + blurhash = blurhash + ) } } @@ -108,6 +116,7 @@ data class ImageCompressionResult( val width: Int, val height: Int, val size: Long, + val blurhash: String, ) sealed interface 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 index 3d51cb24f1..c6dd7eb841 100644 --- 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 @@ -26,9 +26,7 @@ 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.isMimeTypeAudio import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage -import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.matrix.api.media.AudioInfo @@ -41,7 +39,6 @@ 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.catch import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach @@ -93,19 +90,23 @@ class MediaPreProcessorImpl @Inject constructor( deleteOriginal: Boolean, ): Result = runCatching { // Camera returns an 'octet-stream' mimetype, so it needs to be overridden - val originalMimeType = contentResolver.getType(uri) - val mimeType = when (mediaType) { - MediaType.Image -> MimeTypes.Images - MediaType.Video -> MimeTypes.Videos - MediaType.Audio -> MimeTypes.Audio - else -> originalMimeType + 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 { - mimeType.isMimeTypeImage() -> processImage(uri) - mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType) - mimeType.isMimeTypeAudio() -> processAudio(uri) + 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 { @@ -115,7 +116,7 @@ class MediaPreProcessorImpl @Inject constructor( removeSensitiveImageMetadata(file) } val info = FileInfo( - mimetype = originalMimeType, + mimetype = mimeType, size = file.length(), thumbnailInfo = null, thumbnailUrl = null, @@ -141,7 +142,7 @@ class MediaPreProcessorImpl @Inject constructor( removeSensitiveImageMetadata(compressedFileResult.file) val thumbnailResult = compressedFileResult.file.inputStream().use { generateImageThumbnail(it) } - val processingResult = compressedFileResult.toImageInfo(MimeTypes.Jpeg, thumbnailResult?.file?.path, thumbnailResult?.info) + val processingResult = compressedFileResult.toImageInfo(MimeTypes.Jpeg, thumbnailResult.file.path, thumbnailResult.info) return MediaUploadInfo.Image(compressedFileResult.file, processingResult, thumbnailResult) } @@ -155,32 +156,33 @@ class MediaPreProcessorImpl @Inject constructor( .first() .file - val videoProcessingInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo?.file?.path, thumbnailInfo?.info) + val videoProcessingInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo.file.path, thumbnailInfo) return MediaUploadInfo.Video(resultFile, videoProcessingInfo, thumbnailInfo) } - private suspend fun processAudio(uri: Uri): MediaUploadInfo { + 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() + size = file.length(), + mimeType = mimeType, ) MediaUploadInfo.Audio(file, info) } } - private suspend fun generateImageThumbnail(inputStream: InputStream): ThumbnailProcessingInfo? { + private suspend fun generateImageThumbnail(inputStream: InputStream): ThumbnailProcessingInfo { val thumbnailResult = imageCompressor .compressToTmpFile( inputStream = inputStream, resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), - ).getOrNull() + ).getOrThrow() - return thumbnailResult?.toThumbnailProcessingInfo(MimeTypes.Jpeg) + return thumbnailResult.toThumbnailProcessingInfo(MimeTypes.Jpeg) } private fun removeSensitiveImageMetadata(file: File) { @@ -203,7 +205,7 @@ class MediaPreProcessorImpl @Inject constructor( } } - private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailUrl: String?, thumbnailInfo: ThumbnailInfo?): VideoInfo = + private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailUrl: String?, thumbnailInfo: ThumbnailProcessingInfo?): VideoInfo = MediaMetadataRetriever().runAndRelease { setDataSource(context, Uri.fromFile(file)) @@ -213,16 +215,16 @@ class MediaPreProcessorImpl @Inject constructor( height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L, mimetype = mimeType, size = file.length(), - thumbnailInfo = thumbnailInfo, + thumbnailInfo = thumbnailInfo?.info, thumbnailUrl = thumbnailUrl, - blurhash = null, + blurhash = thumbnailInfo?.blurhash, ) } - private suspend fun extractVideoThumbnail(uri: Uri): ThumbnailProcessingInfo? = + private suspend fun extractVideoThumbnail(uri: Uri): ThumbnailProcessingInfo = MediaMetadataRetriever().runAndRelease { setDataSource(context, uri) - val bitmap = getFrameAtTime(VIDEO_THUMB_FRAME) ?: return@runAndRelease null + val bitmap = requireNotNull(getFrameAtTime(VIDEO_THUMB_FRAME)) val inputStream = ByteArrayOutputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) ByteArrayInputStream(it.toByteArray()) @@ -249,7 +251,7 @@ fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailUrl: String?, size = size, thumbnailInfo = thumbnailInfo, thumbnailUrl = thumbnailUrl, - blurhash = null, + blurhash = blurhash, ) fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = ThumbnailProcessingInfo( @@ -260,4 +262,5 @@ fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = Thumbna 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 index ea0dbf28fd..490e286353 100644 --- 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 @@ -32,7 +32,7 @@ class VideoCompressor @Inject constructor( ) { fun compress(uri: Uri) = callbackFlow { - val tmpFile = context.createTmpFile() + val tmpFile = context.createTmpFile(extension = "mp4") val future = Transcoder.into(tmpFile.path) .addDataSource(context, uri) .setListener(object : TranscoderListener { From f33742f609ba9b98e7f5ec944a1de2f4d08e91c6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 May 2023 16:01:30 +0000 Subject: [PATCH 21/32] Update kotlin to v1.7.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3504a3b0fe..40d89b7dd2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,7 +23,7 @@ compose_bom = "2023.04.01" composecompiler = "1.4.7" # Coroutines -coroutines = "1.6.4" +coroutines = "1.7.0" # Accompanist accompanist = "0.30.1" From 719dd20555f998773d54d6e0b269145aca336124 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Fri, 12 May 2023 10:05:39 +0200 Subject: [PATCH 22/32] Remove useless OptIn in tests --- .../android/appnav/RootPresenterTest.kt | 3 - .../appnav/loggedin/LoggedInPresenterTest.kt | 3 - .../impl/AllMatrixUsersDataSourceTest.kt | 2 - .../impl/addpeople/AddPeoplePresenterTests.kt | 3 - .../ConfigureRoomPresenterTests.kt | 3 - .../impl/root/CreateRoomRootPresenterTests.kt | 3 - .../impl/InviteListPresenterTests.kt | 3 - .../changeserver/ChangeServerPresenterTest.kt | 4 - .../login/impl/root/LoginRootPresenterTest.kt | 7 -- .../impl/LogoutPreferencePresenterTest.kt | 3 - .../messages/MessagesPresenterTest.kt | 3 - .../actionlist/ActionListPresenterTest.kt | 5 +- .../messages/fixtures/timelineItemsFactory.kt | 3 - .../MessageComposerPresenterTest.kt | 79 +++++++++---------- .../timeline/TimelinePresenterTest.kt | 4 - .../DeveloperSettingsPresenterTest.kt | 3 - .../impl/root/PreferencesRootPresenterTest.kt | 3 - .../impl/bugreport/BugReportPresenterTest.kt | 3 - .../crash/ui/CrashDetectionPresenterTest.kt | 3 - .../RageshakeDetectionPresenterTest.kt | 3 - .../RageshakePreferencesPresenterTest.kt | 3 - .../impl/DefaultInviteStateDataSourceTest.kt | 3 - .../roomlist/impl/RoomListPresenterTests.kt | 3 +- .../impl/DefaultUserListPresenterTests.kt | 2 - .../impl/DefaultPermissionsPresenterTest.kt | 3 +- .../noop/NoopPermissionsPresenterTest.kt | 3 - .../clientsecret/PushClientSecretImplTest.kt | 3 - .../impl/DatabaseSessionStoreTests.kt | 2 - .../DefaultAppNavigationStateServiceTest.kt | 4 +- 29 files changed, 42 insertions(+), 127 deletions(-) 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 2938ce41df..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,8 +14,6 @@ * limitations under the License. */ -@file:OptIn(ExperimentalCoroutinesApi::class) - package io.element.android.appnav import app.cash.molecule.RecompositionClock @@ -30,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 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/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/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/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/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 6756f55f9c..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 @@ -40,7 +38,6 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom 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 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 2ef7aa0c69..9639759529 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 @@ -14,8 +14,6 @@ * limitations under the License. */ -@file:OptIn(ExperimentalCoroutinesApi::class) - package io.element.android.features.messages.textcomposer import app.cash.molecule.RecompositionClock @@ -52,14 +50,9 @@ 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.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.android.awaitFrame -import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain import org.junit.Test import java.io.File @@ -302,30 +295,32 @@ class MessageComposerPresenterTest { 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( + mediaPreProcessor.givenResult( + Result.success( + MediaUploadInfo.Image( file = File("/some/path"), - info = ThumbnailInfo( + info = ImageInfo( width = null, height = null, mimetype = null, size = null, + thumbnailInfo = null, + thumbnailUrl = null, + blurhash = null, ), - blurhash = "", + thumbnailInfo = ThumbnailProcessingInfo( + file = File("/some/path"), + info = ThumbnailInfo( + width = null, + height = null, + mimetype = null, + size = null, + ), + blurhash = "", + ) ) ) - )) + ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -342,31 +337,33 @@ class MessageComposerPresenterTest { 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( + mediaPreProcessor.givenResult( + Result.success( + MediaUploadInfo.Video( file = File("/some/path"), - info = ThumbnailInfo( + info = VideoInfo( width = null, height = null, mimetype = null, + duration = null, size = null, + thumbnailInfo = null, + thumbnailUrl = null, + blurhash = null, ), - blurhash = "", + thumbnailInfo = ThumbnailProcessingInfo( + file = File("/some/path"), + info = ThumbnailInfo( + width = null, + height = null, + mimetype = null, + size = null, + ), + blurhash = "", + ) ) ) - )) + ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -394,7 +391,7 @@ class MessageComposerPresenterTest { @Test fun `present - Pick media from gallery & cancel does nothing`() = runTest { val presenter = createPresenter(this) - with(pickerProvider){ + with(pickerProvider) { givenResult(null) // Simulate a user canceling the flow givenMimeType(MimeTypes.Images) } 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/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/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..88135d5633 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,7 +29,6 @@ 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 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/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..ae2f709608 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 @@ -33,11 +33,9 @@ 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() 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/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 From 92e9d3a1279e2f50b79f9ee7b343eb1fc834ada0 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Fri, 12 May 2023 11:50:39 +0100 Subject: [PATCH 23/32] Fix a few FFI leaks (#405) Fix a few FFI leaks These are instances where we obtain an FFIObject and don't call Close on it to release the underlying reference on the Rust side. The worst instance here was leaking an object per room member every time we refreshed the member list --- .../assertions/assertRoomListSynced.yaml | 5 +++ .maestro/tests/roomList/searchRoomList.yaml | 1 + .../impl/root/CreateRoomRootPresenter.kt | 11 ++++--- .../invitelist/impl/InviteListPresenter.kt | 8 +++-- .../matrix/impl/room/RoomMemberMapper.kt | 19 ++++++----- .../matrix/impl/room/RustMatrixRoom.kt | 7 ++-- .../impl/timeline/MatrixTimelineItemMapper.kt | 4 +-- .../timeline/item/event/EventMessageMapper.kt | 8 ++--- .../item/event/EventTimelineItemMapper.kt | 24 +++++++------- .../item/event/TimelineEventContentMapper.kt | 6 ++-- .../android/samples/minimal/RoomListScreen.kt | 33 ++++++++----------- 11 files changed, 67 insertions(+), 59 deletions(-) create mode 100644 .maestro/tests/assertions/assertRoomListSynced.yaml 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/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/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/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/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 3930ebebe6..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 @@ -161,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) + } } } 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 a000783e86..674e02a13d 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 @@ -39,7 +39,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.useUrl(), type.content.info?.map()) @@ -68,9 +68,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 51e84b441f..f776c52670 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 @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.impl.timeline.item.event import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange @@ -26,7 +27,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.RedactedConte import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent import io.element.android.libraries.matrix.api.timeline.item.event.StateContent import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent -import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent import io.element.android.libraries.matrix.impl.media.map @@ -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/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() + } + } } } } From 04930167a399604b40ea4c48449187cb4a3386e4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 12 May 2023 10:55:58 +0000 Subject: [PATCH 24/32] Update dependency org.matrix.rustcomponents:sdk-android to v0.1.13 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3504a3b0fe..11131203d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -129,7 +129,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.12" +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" } From d7d1d0154383f484fff422f9c8ec2cf24bffe7a5 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Fri, 12 May 2023 13:28:22 +0200 Subject: [PATCH 25/32] Add missing OptIn --- .../messages/textcomposer/MessageComposerPresenterTest.kt | 3 +++ .../element/android/libraries/matrix/impl/RustMatrixClient.kt | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) 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 9639759529..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 @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.features.messages.textcomposer import app.cash.molecule.RecompositionClock @@ -50,6 +52,7 @@ 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 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 cce7ac90c7..8709db67f0 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 @@ -300,7 +302,6 @@ class RustMatrixClient constructor( } } - @ExperimentalCoroutinesApi override fun close() { slidingSyncUpdateJob?.cancel() stopSync() From 53adb456ba63f77fdd1ee6f4accfd5c6d8dec280 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Fri, 12 May 2023 13:30:06 +0200 Subject: [PATCH 26/32] Test cleanup --- .../userlist/impl/DefaultUserListPresenterTests.kt | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) 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 ae2f709608..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,8 +29,6 @@ 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.test.runTest import org.junit.Test @@ -134,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, @@ -156,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)) From 394ef825ef799c7db237eda9c68a2cb87d3a9f3d Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Fri, 12 May 2023 15:34:35 +0200 Subject: [PATCH 27/32] increase test timeout --- .../impl/detection/RageshakeDetectionPresenterTest.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 88135d5633..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 @@ -32,6 +32,7 @@ import io.mockk.mockk import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Test +import kotlin.time.Duration.Companion.seconds class RageshakeDetectionPresenterTest { @Test @@ -98,7 +99,7 @@ class RageshakeDetectionPresenterTest { ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() - }.test { + }.test(timeout = 30.seconds) { skipItems(1) val initialState = awaitItem() assertThat(initialState.isStarted).isFalse() @@ -152,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) From 32d8bf0b9daf5dceb407d7aafd73b6c5abacadb8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 12 May 2023 19:15:53 +0000 Subject: [PATCH 28/32] Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-test to v1.7.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 40d89b7dd2..4a841a0d64 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,7 +23,7 @@ compose_bom = "2023.04.01" composecompiler = "1.4.7" # Coroutines -coroutines = "1.7.0" +coroutines = "1.7.1" # Accompanist accompanist = "0.30.1" From a336e5cad67b8c80a3d61dccbd5cdc8092d9735b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 12 May 2023 21:51:50 +0000 Subject: [PATCH 29/32] Update dependency com.google.dagger:dagger-compiler to v2.46.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 40d89b7dd2..c4ff7595e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,7 +43,7 @@ stem = "2.3.0" sqldelight = "1.5.5" # DI -dagger = "2.46" +dagger = "2.46.1" anvil = "2.4.5" # quality From ac515afae33cea42f3ee078ae638ac3093993db5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 15 May 2023 13:24:27 +0200 Subject: [PATCH 30/32] Now that we have a CODEOWNERS file, there is no need for Renovate to assign a reviewer. It will be done automatically by GitHub. --- .github/renovate.json | 1 - 1 file changed, 1 deletion(-) 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": [ { From b51c19af19198f01e549706aa3473d44ff3434f8 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Mon, 15 May 2023 14:39:27 +0200 Subject: [PATCH 31/32] Show pending invitations in room members list (#385) Splits a Room's member list in 2 showing pending invitees first and then the actual room member. This simple user facing change entails a host of under the hood changes: - It copies the logic from the `userlist` module and merges it into the `roomdetails` module removing all details not related to the member list (e.g. gets rid of multiple selection, debouncing etc.). - Uncouples the `roomdetails` module from the `userlist` one. Now leaving only the `createroom` module to depend on the `userlist` module. Therefore the `userlist` module could be in the future completely removed and merged into the `createroom` module. - Changes the room members count in the room details screen to only show the members who have joined (i.e. don't count those still in the invited state). Missed ACs: - This change does not make the member list live update. Discussion is ongoing on how to make this technically feasible. Parent issue: - https://github.com/vector-im/element-x-android/issues/246 --- changelog.d/385.feature | 1 + features/roomdetails/impl/build.gradle.kts | 2 - .../roomdetails/impl/RoomDetailsPresenter.kt | 3 +- ...omMemberModules.kt => RoomMemberModule.kt} | 16 +- ...aSource.kt => RoomMemberListDataSource.kt} | 16 +- .../impl/members/RoomMemberListEvents.kt | 5 +- .../impl/members/RoomMemberListPresenter.kt | 71 ++++-- .../impl/members/RoomMemberListState.kt | 26 +- .../members/RoomMemberListStateProvider.kt | 94 ++++++-- .../impl/members/RoomMemberListView.kt | 228 +++++++++++++++--- .../roomdetails/RoomDetailsPresenterTests.kt | 25 +- .../members/RoomMemberListPresenterTests.kt | 127 +++++++--- .../RoomMemberDetailsPresenterTests.kt | 2 +- .../libraries/matrix/api/room/RoomMember.kt | 7 - ...stDarkPreview_0_null_0,NEXUS_5,1.0,en].png | 4 +- ...stDarkPreview_0_null_2,NEXUS_5,1.0,en].png | 3 + ...stDarkPreview_0_null_3,NEXUS_5,1.0,en].png | 3 + ...stDarkPreview_0_null_4,NEXUS_5,1.0,en].png | 3 + ...stDarkPreview_0_null_5,NEXUS_5,1.0,en].png | 3 + ...stDarkPreview_0_null_6,NEXUS_5,1.0,en].png | 3 + ...tLightPreview_0_null_0,NEXUS_5,1.0,en].png | 4 +- ...tLightPreview_0_null_2,NEXUS_5,1.0,en].png | 3 + ...tLightPreview_0_null_3,NEXUS_5,1.0,en].png | 3 + ...tLightPreview_0_null_4,NEXUS_5,1.0,en].png | 3 + ...tLightPreview_0_null_5,NEXUS_5,1.0,en].png | 3 + ...tLightPreview_0_null_6,NEXUS_5,1.0,en].png | 3 + 26 files changed, 477 insertions(+), 184 deletions(-) create mode 100644 changelog.d/385.feature rename features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/{RoomMemberModules.kt => RoomMemberModule.kt} (75%) rename features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/{RoomUserListDataSource.kt => RoomMemberListDataSource.kt} (75%) create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 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/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/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/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/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 From b3c6d64fa30857c3d24b40b163fd8ad90b936dc8 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 15 May 2023 18:16:52 +0200 Subject: [PATCH 32/32] SDK - fix compilation --- .../libraries/matrix/impl/notification/NotificationMapper.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 ) }