diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index fad1e4a426..240a9f7cd7 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -18,9 +18,7 @@ jobs: if: ${{ github.repository == 'vector-im/element-x-android' }} steps: - name: ⏬ Checkout with LFS - uses: actions/checkout@v3 - with: - lfs: 'true' + uses: nschloe/action-cached-lfs-checkout@v1.2.1 - name: Use JDK 17 uses: actions/setup-java@v3 diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml index c28f4df26b..8803ef156d 100644 --- a/.github/workflows/recordScreenshots.yml +++ b/.github/workflows/recordScreenshots.yml @@ -14,9 +14,9 @@ jobs: steps: - name: ⏬ Checkout with LFS - uses: actions/checkout@v3 + uses: nschloe/action-cached-lfs-checkout@v1.2.1 with: - lfs: 'true' + persist-credentials: false - name: ☕️ Use JDK 17 uses: actions/setup-java@v3 with: @@ -30,5 +30,6 @@ jobs: - name: Record screenshots run: "./.github/workflows/scripts/recordScreenshots.sh" env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }} + diff --git a/.github/workflows/scripts/recordScreenshots.sh b/.github/workflows/scripts/recordScreenshots.sh index 8400d86913..2cfec13818 100755 --- a/.github/workflows/scripts/recordScreenshots.sh +++ b/.github/workflows/scripts/recordScreenshots.sh @@ -16,13 +16,43 @@ # limitations under the License. # -if [[ -z ${GITHUB_TOKEN} ]]; then - echo "Missing GITHUB_TOKEN variable" +TOKEN=$GITHUB_TOKEN +REPO=$GITHUB_REPOSITORY + +SHORT=t:,r: +LONG=token:,repo: +OPTS=$(getopt -a -n recordScreenshots --options $SHORT --longoptions $LONG -- "$@") + +eval set -- "$OPTS" +while : +do + case "$1" in + -t | --token ) + TOKEN="$2" + shift 2 + ;; + -r | --repo ) + REPO="$2" + shift 2 + ;; + --) + shift; + break + ;; + *) + echo "Unexpected option: $1" + help + ;; + esac +done + +if [[ -z ${TOKEN} ]]; then + echo "No token specified, either set the env var GITHUB_TOKEN or use the --token option" exit 1 fi -if [[ -z ${GITHUB_REPOSITORY} ]]; then - echo "Missing GITHUB_REPOSITORY variable" +if [[ -z ${REPO} ]]; then + echo "No repo specified, either set the env var GITHUB_REPOSITORY or use the --repo option" exit 1 fi @@ -35,6 +65,8 @@ git config user.email "benoitm+elementbot@element.io" git add -A git commit -m "Update screenshots" +BRANCH=$(git rev-parse --abbrev-ref HEAD) + echo "Pushing changes" -git push "https://$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY.git" +git push "https://$TOKEN@github.com/$REPO.git" $BRANCH:refs/heads/$BRANCH echo "Done!" diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index 02f93cc3e1..3d63f8b542 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -1,5 +1,6 @@ name: Sync Localazy on: + workflow_dispatch: schedule: # At 00:00 on every Monday UTC - cron: '0 0 * * 1' @@ -25,6 +26,7 @@ jobs: - name: Create Pull Request for Strings uses: peter-evans/create-pull-request@v5 with: + token: ${{ secrets.DANGER_GITHUB_API_TOKEN }} commit-message: Sync Strings from Localazy title: Sync Strings body: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aea05b5a22..a65c78c7a2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,12 +22,11 @@ jobs: cancel-in-progress: true steps: - name: ⏬ Checkout with LFS - uses: actions/checkout@v3 + uses: nschloe/action-cached-lfs-checkout@v1.2.1 with: # Ensure we are building the branch and not the branch after being merged on develop # https://github.com/actions/checkout/issues/881 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} - lfs: 'true' - name: ☕️ Use JDK 17 uses: actions/setup-java@v3 with: @@ -81,8 +80,12 @@ jobs: **/out/failures/ **/build/reports/tests/*UnitTest/ - - name: 🔊 Publish results to Sonar (disabled) - run: echo "This is now done only once a day, see nightlyReports.yml" + - name: 🔊 Publish results to Sonar + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }} + if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }} + run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES # https://github.com/codecov/codecov-action - name: ☂️ Upload coverage reports to codecov diff --git a/.github/workflows/validate-lfs.yml b/.github/workflows/validate-lfs.yml index 417b95ce2c..25fe50359c 100644 --- a/.github/workflows/validate-lfs.yml +++ b/.github/workflows/validate-lfs.yml @@ -7,9 +7,7 @@ jobs: runs-on: ubuntu-latest name: Validate steps: - - uses: actions/checkout@v3 - with: - lfs: 'true' + - uses: nschloe/action-cached-lfs-checkout@v1.2.1 - run: | ./tools/git/validate_lfs.sh diff --git a/.idea/dictionaries/bmarty.xml b/.idea/dictionaries/bmarty.xml new file mode 100644 index 0000000000..dd650c15e1 --- /dev/null +++ b/.idea/dictionaries/bmarty.xml @@ -0,0 +1,7 @@ + + + + homeserver + + + \ No newline at end of file diff --git a/.maestro/README.md b/.maestro/README.md index 76268e144e..3926dcdf56 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -25,7 +25,7 @@ maestro test \ -e APP_ID=io.element.android.x.debug \ -e USERNAME=user \ -e PASSWORD=123 \ - -e ROOM_NAME="my room" \ + -e ROOM_NAME="MyRoom" \ .maestro/allTests.yaml ``` diff --git a/.maestro/tests/account/changeServer.yaml b/.maestro/tests/account/changeServer.yaml index 505a12d2e1..df4b12f253 100644 --- a/.maestro/tests/account/changeServer.yaml +++ b/.maestro/tests/account/changeServer.yaml @@ -3,4 +3,15 @@ appId: ${APP_ID} - tapOn: id: "login-change_server" - takeScreenshot: build/maestro/200-ChangeServer -- tapOn: "Continue" +- tapOn: "matrix.org" +- tapOn: + id: "login-change_server" +- tapOn: "Other" +- tapOn: + id: "change_server-server" +- inputText: "element" +- hideKeyboard +- tapOn: "element.io" +- tapOn: "Cancel" +- back +- back diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml index 845746a76b..728ff98b31 100644 --- a/.maestro/tests/account/login.yaml +++ b/.maestro/tests/account/login.yaml @@ -5,6 +5,8 @@ appId: ${APP_ID} - takeScreenshot: build/maestro/100-SignIn - runFlow: changeServer.yaml - runFlow: ../assertions/assertLoginDisplayed.yaml +- tapOn: + id: "login-continue" - tapOn: id: "login-email_username" - inputText: ${USERNAME} diff --git a/.maestro/tests/assertions/assertLoginDisplayed.yaml b/.maestro/tests/assertions/assertLoginDisplayed.yaml index 41f1ff3306..3abd86ceef 100644 --- a/.maestro/tests/assertions/assertLoginDisplayed.yaml +++ b/.maestro/tests/assertions/assertLoginDisplayed.yaml @@ -1,5 +1,5 @@ appId: ${APP_ID} --- - extendedWaitUntil: - visible: "Welcome back!" + visible: "Change account provider" timeout: 10_000 diff --git a/anvilcodegen/build.gradle.kts b/anvilcodegen/build.gradle.kts index e592d98b2e..57758f8909 100644 --- a/anvilcodegen/build.gradle.kts +++ b/anvilcodegen/build.gradle.kts @@ -25,6 +25,6 @@ dependencies { implementation(libs.anvil.compiler.utils) implementation("com.squareup:kotlinpoet:1.14.2") implementation(libs.dagger) - compileOnly("com.google.auto.service:auto-service-annotations:1.1.0") - kapt("com.google.auto.service:auto-service:1.1.0") + compileOnly(libs.google.autoservice.annotations) + kapt(libs.google.autoservice) } diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt index a969c8e0ba..010035e1f7 100644 --- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt +++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt @@ -23,6 +23,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat @@ -32,6 +34,7 @@ import com.bumble.appyx.core.plugin.NodeReadyObserver import io.element.android.libraries.architecture.bindings import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.designsystem.theme.ElementTheme +import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher import io.element.android.x.di.AppBindings import timber.log.Timber @@ -39,17 +42,28 @@ private val loggerTag = LoggerTag("MainActivity") class MainActivity : NodeComponentActivity() { - lateinit var mainNode: MainNode + private lateinit var mainNode: MainNode + + private lateinit var appBindings: AppBindings override fun onCreate(savedInstanceState: Bundle?) { Timber.tag(loggerTag.value).w("onCreate, with savedInstanceState: ${savedInstanceState != null}") installSplashScreen() super.onCreate(savedInstanceState) - val appBindings = bindings() + appBindings = bindings() appBindings.matrixClientsHolder().restore(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) setContent { - ElementTheme { + MainContent(appBindings) + } + } + + @Composable + private fun MainContent(appBindings: AppBindings) { + ElementTheme { + CompositionLocalProvider( + LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(), + ) { Box( modifier = Modifier .fillMaxSize() @@ -62,6 +76,7 @@ class MainActivity : NodeComponentActivity() { plugins = listOf( object : NodeReadyObserver { override fun init(node: MainNode) { + Timber.tag(loggerTag.value).w("onMainNodeInit") mainNode = node mainNode.handleIntent(intent) } @@ -80,11 +95,17 @@ class MainActivity : NodeComponentActivity() { * - a notification is clicked. * - the app is going to background (<- this is strange) */ - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) Timber.tag(loggerTag.value).w("onNewIntent") - intent ?: return - mainNode.handleIntent(intent) + // If the mainNode is not init yet, keep the intent for later. + // It can happen when the activity is killed by the system. The methods are called in this order : + // onCreate(savedInstanceState=true) -> onNewIntent -> onResume -> onMainNodeInit + if (::mainNode.isInitialized) { + mainNode.handleIntent(intent) + } else { + setIntent(intent) + } } override fun onPause() { diff --git a/app/src/main/kotlin/io/element/android/x/MainNode.kt b/app/src/main/kotlin/io/element/android/x/MainNode.kt index fb551f326d..7f56ef4d63 100644 --- a/app/src/main/kotlin/io/element/android/x/MainNode.kt +++ b/app/src/main/kotlin/io/element/android/x/MainNode.kt @@ -57,24 +57,24 @@ class MainNode( DaggerComponentOwner by mainDaggerComponentOwner { private val loggedInFlowNodeCallback = object : LoggedInFlowNode.LifecycleCallback { - override fun onFlowCreated(client: MatrixClient) { + override fun onFlowCreated(identifier: String, client: MatrixClient) { val component = bindings().sessionComponentBuilder().client(client).build() - mainDaggerComponentOwner.addComponent(client.sessionId.value, component) + mainDaggerComponentOwner.addComponent(identifier, component) } - override fun onFlowReleased(client: MatrixClient) { - mainDaggerComponentOwner.removeComponent(client.sessionId.value) + override fun onFlowReleased(identifier: String, client: MatrixClient) { + mainDaggerComponentOwner.removeComponent(identifier) } } private val roomFlowNodeCallback = object : RoomFlowNode.LifecycleCallback { - override fun onFlowCreated(room: MatrixRoom) { + override fun onFlowCreated(identifier: String, room: MatrixRoom) { val component = bindings().roomComponentBuilder().room(room).build() - mainDaggerComponentOwner.addComponent(room.roomId.value, component) + mainDaggerComponentOwner.addComponent(identifier, component) } - override fun onFlowReleased(room: MatrixRoom) { - mainDaggerComponentOwner.removeComponent(room.roomId.value) + override fun onFlowReleased(identifier: String, room: MatrixRoom) { + mainDaggerComponentOwner.removeComponent(identifier) } } diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt index 9d5fa9446f..59f7e98d20 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt @@ -18,10 +18,12 @@ package io.element.android.x.di import com.squareup.anvil.annotations.ContributesTo import io.element.android.appnav.di.MatrixClientsHolder +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.di.AppScope @ContributesTo(AppScope::class) interface AppBindings { fun matrixClientsHolder(): MatrixClientsHolder fun mainDaggerComponentOwner(): MainDaggerComponentsOwner + fun snackbarDispatcher(): SnackbarDispatcher } diff --git a/app/src/main/kotlin/io/element/android/x/di/MainDaggerComponentsOwner.kt b/app/src/main/kotlin/io/element/android/x/di/MainDaggerComponentsOwner.kt index 5489b07830..de800bb587 100644 --- a/app/src/main/kotlin/io/element/android/x/di/MainDaggerComponentsOwner.kt +++ b/app/src/main/kotlin/io/element/android/x/di/MainDaggerComponentsOwner.kt @@ -38,6 +38,10 @@ class MainDaggerComponentsOwner @Inject constructor(@ApplicationContext context: daggerComponents.remove(identifier) } + /** + * We expose the dagger components in the opposite order they arrived. + * So we pick the most recent component when searching with the [io.element.android.libraries.architecture.bindings] methods. + */ override val daggerComponent: Any get() = daggerComponents.values.reversed() } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt index f1d87330e1..c07a2d72e9 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -23,7 +23,6 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.ui.strings.R import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn @@ -31,7 +30,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject -import kotlin.coroutines.coroutineContext class LoggedInEventProcessor @Inject constructor( private val snackbarDispatcher: SnackbarDispatcher, diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index d23cbaabce..7a4d51f0ee 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -64,9 +64,9 @@ import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.appnavstate.api.AppNavigationStateService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @ContributesNode(AppScope::class) @@ -118,9 +118,9 @@ class LoggedInFlowNode @AssistedInject constructor( } interface LifecycleCallback : NodeLifecycleCallback { - fun onFlowCreated(client: MatrixClient) = Unit + fun onFlowCreated(identifier: String, client: MatrixClient) = Unit - fun onFlowReleased(client: MatrixClient) = Unit + fun onFlowReleased(identifier: String, client: MatrixClient) = Unit } data class Inputs( @@ -139,7 +139,7 @@ class LoggedInFlowNode @AssistedInject constructor( observeAnalyticsState() lifecycle.subscribe( onCreate = { - plugins().forEach { it.onFlowCreated(inputs.matrixClient) } + plugins().forEach { it.onFlowCreated(id, inputs.matrixClient) } val imageLoaderFactory = bindings().loggedInImageLoaderFactory() Coil.setImageLoader(imageLoaderFactory) inputs.matrixClient.startSync() @@ -151,7 +151,7 @@ class LoggedInFlowNode @AssistedInject constructor( onDestroy = { val imageLoaderFactory = bindings().notLoggedInImageLoaderFactory() Coil.setImageLoader(imageLoaderFactory) - plugins().forEach { it.onFlowReleased(inputs.matrixClient) } + plugins().forEach { it.onFlowReleased(id, inputs.matrixClient) } appNavigationStateService.onLeavingSpace(id) appNavigationStateService.onLeavingSession(id) loggedInFlowProcessor.stopObserving() @@ -239,8 +239,13 @@ class LoggedInFlowNode @AssistedInject constructor( } } else { val nodeLifecycleCallbacks = plugins() + val callback = object : RoomFlowNode.Callback { + override fun onForwardedToSingleRoom(roomId: RoomId) { + coroutineScope.launch { attachRoom(roomId) } + } + } val inputs = RoomFlowNode.Inputs(room, initialElement = navTarget.initialElement) - createNode(buildContext, plugins = listOf(inputs) + nodeLifecycleCallbacks) + createNode(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks) } } NavTarget.Settings -> { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt index 3d99f03fa0..4b89b442d7 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt @@ -63,7 +63,9 @@ class NotLoggedInFlowNode @AssistedInject constructor( object OnBoarding : NavTarget @Parcelize - object LoginFlow : NavTarget + data class LoginFlow( + val isAccountCreation: Boolean, + ) : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -71,11 +73,11 @@ class NotLoggedInFlowNode @AssistedInject constructor( NavTarget.OnBoarding -> { val callback = object : OnBoardingEntryPoint.Callback { override fun onSignUp() { - //NOOP + backstack.push(NavTarget.LoginFlow(isAccountCreation = true)) } override fun onSignIn() { - backstack.push(NavTarget.LoginFlow) + backstack.push(NavTarget.LoginFlow(isAccountCreation = false)) } } onBoardingEntryPoint @@ -83,8 +85,10 @@ class NotLoggedInFlowNode @AssistedInject constructor( .callback(callback) .build() } - NavTarget.LoginFlow -> { - loginEntryPoint.createNode(this, buildContext) + is NavTarget.LoginFlow -> { + loginEntryPoint.nodeBuilder(this, buildContext) + .params(LoginEntryPoint.Params(isAccountCreation = navTarget.isAccountCreation)) + .build() } } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt index fb3a8d566e..64c0d200fd 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt @@ -39,6 +39,7 @@ import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver @@ -67,9 +68,13 @@ class RoomFlowNode @AssistedInject constructor( plugins = plugins, ) { + interface Callback : Plugin { + fun onForwardedToSingleRoom(roomId: RoomId) + } + interface LifecycleCallback : NodeLifecycleCallback { - fun onFlowCreated(room: MatrixRoom) = Unit - fun onFlowReleased(room: MatrixRoom) = Unit + fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit + fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit } data class Inputs( @@ -78,19 +83,20 @@ class RoomFlowNode @AssistedInject constructor( ) : NodeInputs private val inputs: Inputs = inputs() + private val callbacks = plugins.filterIsInstance() init { lifecycle.subscribe( onCreate = { Timber.v("OnCreate") - plugins().forEach { it.onFlowCreated(inputs.room) } + plugins().forEach { it.onFlowCreated(id, inputs.room) } appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) fetchRoomMembers() }, onDestroy = { Timber.v("OnDestroy") inputs.room.close() - plugins().forEach { it.onFlowReleased(inputs.room) } + plugins().forEach { it.onFlowReleased(id, inputs.room) } appNavigationStateService.onLeavingRoom(id) } ) @@ -124,6 +130,10 @@ class RoomFlowNode @AssistedInject constructor( override fun onUserDataClicked(userId: UserId) { backstack.push(NavTarget.RoomMemberDetails(userId)) } + + override fun onForwardedToSingleRoom(roomId: RoomId) { + callbacks.forEach { it.onForwardedToSingleRoom(roomId) } + } } messagesEntryPoint.createNode(this, buildContext, callback) } diff --git a/changelog.d/486.feature b/changelog.d/486.feature new file mode 100644 index 0000000000..110c069cda --- /dev/null +++ b/changelog.d/486.feature @@ -0,0 +1 @@ +Allow forawrding messages from one room to another diff --git a/changelog.d/487.feature b/changelog.d/487.feature new file mode 100644 index 0000000000..ee1106eb58 --- /dev/null +++ b/changelog.d/487.feature @@ -0,0 +1 @@ +Add menu to retry sending failed messages or delete their local echoes. diff --git a/changelog.d/489.feature b/changelog.d/489.feature new file mode 100644 index 0000000000..4ffd2b7a4a --- /dev/null +++ b/changelog.d/489.feature @@ -0,0 +1 @@ +Add option to report inappropriate content diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootEvents.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/Config.kt similarity index 63% rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootEvents.kt rename to features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/Config.kt index 5aa5071876..883e0d1dc3 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootEvents.kt +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/Config.kt @@ -14,12 +14,9 @@ * limitations under the License. */ -package io.element.android.features.login.impl.root +package io.element.android.features.analytics.api -sealed interface LoginRootEvents { - object RetryFetchServerInfo : LoginRootEvents - data class SetLogin(val login: String) : LoginRootEvents - data class SetPassword(val password: String) : LoginRootEvents - object Submit : LoginRootEvents - object ClearError : LoginRootEvents +object Config { + const val POLICY_LINK = "https://element.io/cookie-policy" } + diff --git a/features/analytics/impl/build.gradle.kts b/features/analytics/impl/build.gradle.kts index b72d8dbbec..60b4887a88 100644 --- a/features/analytics/impl/build.gradle.kts +++ b/features/analytics/impl/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { api(projects.features.analytics.api) api(projects.services.analytics.api) implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.browser) ksp(libs.showkase.processor) testImplementation(libs.test.junit) diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt index aafb3d4490..ab060a51cf 100644 --- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt @@ -16,14 +16,19 @@ package io.element.android.features.analytics.impl +import android.app.Activity +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.analytics.api.Config +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.di.AppScope @ContributesNode(AppScope::class) @@ -33,12 +38,19 @@ class AnalyticsOptInNode @AssistedInject constructor( private val presenter: AnalyticsOptInPresenter, ) : Node(buildContext, plugins = plugins) { + private fun onClickTerms(activity: Activity, darkTheme: Boolean) { + activity.openUrlInChromeCustomTab(null, darkTheme, Config.POLICY_LINK) + } + @Composable override fun View(modifier: Modifier) { + val activity = LocalContext.current as Activity + val isDark = MaterialTheme.colors.isLight.not() val state = presenter.present() AnalyticsOptInView( state = state, modifier = modifier, + onClickTerms = { onClickTerms(activity, isDark) }, ) } } diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt index 899c217e00..e2af75ceca 100644 --- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt @@ -16,47 +16,46 @@ package io.element.android.features.analytics.impl -import android.graphics.Typeface -import android.text.SpannableString -import android.text.style.ForegroundColorSpan -import android.text.style.StyleSpan -import android.text.style.UnderlineSpan -import androidx.annotation.StringRes -import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.filled.Poll +import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.element.android.features.analytics.api.AnalyticsOptInEvents -import io.element.android.libraries.designsystem.LinkColor +import io.element.android.libraries.designsystem.ElementTextStyles +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart +import io.element.android.libraries.designsystem.theme.LocalColors 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.Text @@ -67,157 +66,148 @@ import io.element.android.libraries.ui.strings.R as StringR @Composable fun AnalyticsOptInView( state: AnalyticsOptInState, + onClickTerms: () -> Unit, modifier: Modifier = Modifier, ) { LogCompositions(tag = "Analytics", msg = "Root") val eventSink = state.eventSink - Box( + HeaderFooterPage( modifier = modifier .fillMaxSize() .systemBarsPadding() - .imePadding() + .imePadding(), + header = { AnalyticsOptInHeader(state, onClickTerms) }, + content = { AnalyticsOptInContent() }, + footer = { AnalyticsOptInFooter(eventSink) }) +} + +@Composable +private fun AnalyticsOptInHeader( + state: AnalyticsOptInState, + onClickTerms: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - modifier = Modifier.padding(horizontal = 16.dp), - ) { - Column(modifier = Modifier.weight(1f)) { - Image( - painterResource(id = R.drawable.element_logo_stars), - contentDescription = null, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(16.dp) - ) - Text( - text = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - fontSize = 24.sp, - color = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringResource(id = R.string.screen_analytics_prompt_help_us_improve, state.applicationName), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp), - textAlign = TextAlign.Center, - fontSize = 16.sp, - color = MaterialTheme.colorScheme.secondary, - ) - - Text( - text = buildAnnotatedStringWithColoredPart( - R.string.screen_analytics_prompt_read_terms, - R.string.screen_analytics_prompt_read_terms_content_link - ), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp), - textAlign = TextAlign.Center, - fontSize = 16.sp, - color = MaterialTheme.colorScheme.secondary, - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = Icons.Outlined.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary) - Text( - text = stringResource(id = R.string.screen_analytics_prompt_data_usage).toAnnotatedString(), - color = MaterialTheme.colorScheme.secondary, - ) - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = Icons.Outlined.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary) - Text( - text = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing).toAnnotatedString(), - color = MaterialTheme.colorScheme.secondary, - ) - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = Icons.Outlined.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary) - Text( - text = stringResource(id = R.string.screen_analytics_prompt_settings), - color = MaterialTheme.colorScheme.secondary, - ) - } - } - - Button( - onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = stringResource(id = StringR.string.action_enable)) - } - Spacer(modifier = Modifier.height(16.dp)) - TextButton( - onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(text = stringResource(id = StringR.string.action_not_now)) - } - Spacer(Modifier.height(40.dp)) - } + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 60.dp, bottom = 12.dp), + title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName), + subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve), + iconImageVector = Icons.Filled.Poll + ) + Text( + text = buildAnnotatedStringWithStyledPart( + R.string.screen_analytics_prompt_read_terms, + R.string.screen_analytics_prompt_read_terms_content_link, + color = Color.Unspecified, + underline = false, + bold = true, + ), + modifier = Modifier + .clip(shape = RoundedCornerShape(8.dp)) + .clickable { onClickTerms() } + .padding(8.dp), + style = ElementTextStyles.Regular.subheadline, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.secondary, + ) } } -fun String.toAnnotatedString(): AnnotatedString = buildAnnotatedString { - append(this@toAnnotatedString) - val spannable = SpannableString(this@toAnnotatedString) - spannable.getSpans(0, spannable.length, Any::class.java).forEach { span -> - val start = spannable.getSpanStart(span) - val end = spannable.getSpanEnd(span) - when (span) { - is StyleSpan -> when (span.style) { - Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) - Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) - Typeface.BOLD_ITALIC -> addStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), start, end) - } - is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end) - is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end) +@Composable +private fun AnalyticsOptInContent( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = BiasAlignment( + horizontalBias = 0f, + verticalBias = -0.4f + ) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + AnalyticsOptInContentRow( + text = stringResource(id = R.string.screen_analytics_prompt_data_usage), + idx = 0 + ) + AnalyticsOptInContentRow( + text = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing), + idx = 1 + ) + AnalyticsOptInContentRow( + text = stringResource(id = R.string.screen_analytics_prompt_settings), + idx = 2 + ) } } } @Composable -fun buildAnnotatedStringWithColoredPart( - @StringRes fullTextRes: Int, - @StringRes coloredTextRes: Int, - color: Color = LinkColor, - underline: Boolean = true, -) = buildAnnotatedString { - val coloredPart = stringResource(coloredTextRes) - val fullText = stringResource(fullTextRes, coloredPart) - val startIndex = fullText.indexOf(coloredPart) - append(fullText) - addStyle( - style = SpanStyle( - color = color, - textDecoration = if (underline) TextDecoration.Underline else null - ), start = startIndex, end = startIndex + coloredPart.length - ) +private fun AnalyticsOptInContentRow( + text: String, + idx: Int, + modifier: Modifier = Modifier, +) { + val radius = 14.dp + val bgShape = when (idx) { + 0 -> RoundedCornerShape(topStart = radius, topEnd = radius) + 2 -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius) + else -> RoundedCornerShape(0.dp) + } + Row( + modifier = modifier + .fillMaxWidth() + .background( + color = LocalColors.current.quinary, + shape = bgShape, + ) + .padding(vertical = 12.dp, horizontal = 20.dp), + ) { + Icon( + modifier = Modifier + .size(20.dp) + .background(color = MaterialTheme.colorScheme.background, shape = CircleShape) + .padding(2.dp), + imageVector = Icons.Rounded.Check, + contentDescription = null, + // TODO Compound, this color is not yet in the theme + tint = Color(0xFF007A61) + ) + Text( + modifier = Modifier.padding(start = 16.dp), + text = text, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +private fun AnalyticsOptInFooter( + eventSink: (AnalyticsOptInEvents) -> Unit, + modifier: Modifier = Modifier, +) { + ButtonColumnMolecule( + modifier = modifier, + ) { + Button( + onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(id = StringR.string.action_ok)) + } + TextButton( + onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(id = StringR.string.action_not_now)) + } + } } @Preview @@ -234,5 +224,8 @@ fun AnalyticsOptInViewDarkPreview(@PreviewParameter(AnalyticsOptInStateProvider: @Composable private fun ContentToPreview(state: AnalyticsOptInState) { - AnalyticsOptInView(state = state) + AnalyticsOptInView( + state = state, + onClickTerms = {}, + ) } diff --git a/features/analytics/impl/src/main/res/drawable/element_logo_stars.xml b/features/analytics/impl/src/main/res/drawable/element_logo_stars.xml deleted file mode 100644 index d982fbedc4..0000000000 --- a/features/analytics/impl/src/main/res/drawable/element_logo_stars.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/features/analytics/impl/src/main/res/values-cs/translations.xml b/features/analytics/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..b75b359216 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,10 @@ + + + "Nezaznamenáváme ani neprofilujeme žádné údaje o účtu" + "Sdílejte anonymní údaje o používání, které nám pomohou identifikovat problémy." + "Můžete si přečíst všechny naše podmínky %1$s." + "zde" + "Tuto funkci můžete kdykoli vypnout" + "Nesdílíme informace s třetími stranami" + "Pomozte vylepšit %1$s" + diff --git a/features/analytics/impl/src/main/res/values-de/translations.xml b/features/analytics/impl/src/main/res/values-de/translations.xml index b606a6d82e..fc79b4297a 100644 --- a/features/analytics/impl/src/main/res/values-de/translations.xml +++ b/features/analytics/impl/src/main/res/values-de/translations.xml @@ -1,10 +1,9 @@ "Wir erfassen und analysieren ""keine"" Account-Daten" - "Helfen Sie uns, Probleme zu identifizieren und %1$s zu verbessern, indem Sie anonyme Nutzungsdaten weitergeben." "Sie können alle unsere Nutzerbedingungen %1$s lesen." "hier" "Sie können die Analyse jederzeit in den Einstellungen deaktivieren" "Wir geben ""keine"" Informationen an Dritte weiter" "Helfen Sie %1$s zu verbessern" - \ No newline at end of file + diff --git a/features/analytics/impl/src/main/res/values-ro/translations.xml b/features/analytics/impl/src/main/res/values-ro/translations.xml index 91beaa7c52..f4db2e7416 100644 --- a/features/analytics/impl/src/main/res/values-ro/translations.xml +++ b/features/analytics/impl/src/main/res/values-ro/translations.xml @@ -1,10 +1,9 @@ "Nu"" înregistrăm sau profilăm datele contului" - "Ajutați-ne să identificăm problemele și să îmbunătățim %1$s prin partajarea datelor de utilizare anonime." "Puteți citi toate condițiile noastre %1$s." "aici" "Puteți dezactiva această opțiune oricând din setări" "Nu"" împărtășim informații cu terți" "Ajutați la îmbunătățirea %1$s" - \ No newline at end of file + diff --git a/features/analytics/impl/src/main/res/values/localazy.xml b/features/analytics/impl/src/main/res/values/localazy.xml index e6b1c6419d..a496bdd0c6 100644 --- a/features/analytics/impl/src/main/res/values/localazy.xml +++ b/features/analytics/impl/src/main/res/values/localazy.xml @@ -1,10 +1,10 @@ - "We ""don\'t"" record or profile any account data" - "Help us identify issues and improve %1$s by sharing anonymous usage data." + "We won\'t record or profile any personal data" + "Share anonymous usage data to help us identify issues." "You can read all our terms %1$s." "here" - "You can turn this off anytime in settings" - "We ""don\'t"" share information with third parties" + "You can turn this off anytime" + "We won\'t share your data with third parties" "Help improve %1$s" - \ No newline at end of file + diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt index 46de8fff91..f79284d082 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt @@ -20,6 +20,7 @@ import android.net.Uri import io.element.android.features.createroom.impl.configureroom.RoomPrivacy import io.element.android.features.createroom.impl.di.CreateRoomScope import io.element.android.features.createroom.impl.userlist.UserListDataStore +import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.di.SingleIn import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow @@ -36,7 +37,7 @@ class CreateRoomDataStore @Inject constructor( private val createRoomConfigFlow: MutableStateFlow = MutableStateFlow(CreateRoomConfig()) private var cachedAvatarUri: Uri? = null set(value) { - field?.path?.let { File(it) }?.delete() + field?.path?.let { File(it) }?.safeDelete() field = value } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt index ca714b1e59..f2a03ca2c0 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -131,6 +131,6 @@ class ConfigureRoomPresenter @Inject constructor( private suspend fun uploadAvatar(avatarUri: Uri): String { val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() val byteArray = preprocessed.file.readBytes() - return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray).getOrThrow() + return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow() } } 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 9a3030ba71..9a5d672fd4 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,4 @@ "Añadir personas" "Se ha producido un error al intentar iniciar un chat" "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 214cd9406d..ceddb71154 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,4 @@ "Aggiungi persone" "Si è verificato un errore durante il tentativo di avviare una chat" "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 90a2e4f7f4..9f68a006e5 100644 --- a/features/createroom/impl/src/main/res/values-ro/translations.xml +++ b/features/createroom/impl/src/main/res/values-ro/translations.xml @@ -1,8 +1,8 @@ "Cameră nouă" - "Invitați persoane" - "Adaugați persoane" + "Invitați prieteni în Element" + "Invitați persoane" "A apărut o eroare la crearea camerei" "Mesajele din această cameră sunt criptate. Criptarea nu poate fi dezactivată ulterior." "Cameră privată (doar pe bază de invitație)" @@ -12,4 +12,4 @@ "Subiect (opțional)" "A apărut o eroare la încercarea începerii conversației" "Creați o cameră" - \ No newline at end of file + diff --git a/features/createroom/impl/src/main/res/values/localazy.xml b/features/createroom/impl/src/main/res/values/localazy.xml index 0b6d87b8b8..68f318d385 100644 --- a/features/createroom/impl/src/main/res/values/localazy.xml +++ b/features/createroom/impl/src/main/res/values/localazy.xml @@ -12,4 +12,4 @@ "Topic (optional)" "An error occurred when trying to start a chat" "Create a room" - \ No newline at end of file + 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 7f36b9e2b9..736fca9cb4 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 @@ -21,7 +21,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.libraries.matrix.ui.media.AvatarAction import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore @@ -33,6 +32,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor @@ -226,6 +226,7 @@ class ConfigureRoomPresenterTests { val initialState = awaitItem() initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) + assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java) val stateAfterCreateRoom = awaitItem() assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java) @@ -234,7 +235,6 @@ class ConfigureRoomPresenterTests { assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Uninitialized::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Success::class.java) - } } 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 2f05503d0a..35df1cbad7 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 @@ -52,10 +52,11 @@ class CreateRoomRootPresenterTests { fakeMatrixClient = FakeMatrixClient() userRepository = FakeUserRepository() presenter = CreateRoomRootPresenter( - FakeUserListPresenterFactory(fakeUserListPresenter), - userRepository, + presenterFactory =FakeUserListPresenterFactory(fakeUserListPresenter), + userRepository= userRepository, + userListDataStore = UserListDataStore(), - fakeMatrixClient, + matrixClient = fakeMatrixClient, aBuildMeta(), ) } diff --git a/features/invitelist/impl/src/main/res/values-cs/translations.xml b/features/invitelist/impl/src/main/res/values-cs/translations.xml index b1f64dc0b4..9c0f592297 100644 --- a/features/invitelist/impl/src/main/res/values-cs/translations.xml +++ b/features/invitelist/impl/src/main/res/values-cs/translations.xml @@ -6,4 +6,4 @@ "Odmítnout chat" "Žádné pozvánky" "%1$s (%2$s) vás pozval(a)" - \ No newline at end of file + diff --git a/features/invitelist/impl/src/main/res/values-de/translations.xml b/features/invitelist/impl/src/main/res/values-de/translations.xml index 571e1da4a0..1e2fcc2e86 100644 --- a/features/invitelist/impl/src/main/res/values-de/translations.xml +++ b/features/invitelist/impl/src/main/res/values-de/translations.xml @@ -5,4 +5,5 @@ "Möchten Sie den Chat mit %1$s wirklich ablehnen?" "Chat ablehnen" "Keine Einladungen" + "%1$s (%2$s) hat dich eingeladen" diff --git a/features/invitelist/impl/src/main/res/values/localazy.xml b/features/invitelist/impl/src/main/res/values/localazy.xml index 6d52033110..966b56a625 100644 --- a/features/invitelist/impl/src/main/res/values/localazy.xml +++ b/features/invitelist/impl/src/main/res/values/localazy.xml @@ -6,4 +6,4 @@ "Decline chat" "No Invites" "%1$s (%2$s) invited you" - \ No newline at end of file + 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 462faa405f..cf4f1058e5 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 @@ -32,12 +32,11 @@ import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_NAME -import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID 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 io.element.android.libraries.matrix.test.room.FakeMatrixRoom import kotlinx.coroutines.test.runTest import org.junit.Test @@ -48,7 +47,6 @@ class InviteListPresenterTests { val invitesDataSource = FakeRoomSummaryDataSource() val presenter = InviteListPresenter( FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ), FakeSeenInvitesStore(), @@ -73,7 +71,6 @@ class InviteListPresenterTests { val invitesDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation() val presenter = InviteListPresenter( FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ), FakeSeenInvitesStore(), @@ -100,10 +97,8 @@ class InviteListPresenterTests { @Test fun `present - includes sender details for room invites`() = runTest { val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() - val presenter = InviteListPresenter( FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ), FakeSeenInvitesStore(), @@ -128,10 +123,8 @@ class InviteListPresenterTests { @Test fun `present - shows confirm dialog for declining direct chat invites`() = runTest { val invitesDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation() - val presenter = InviteListPresenter( FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ), FakeSeenInvitesStore(), @@ -154,10 +147,8 @@ class InviteListPresenterTests { @Test fun `present - shows confirm dialog for declining room invites`() = runTest { val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() - val presenter = InviteListPresenter( FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ), FakeSeenInvitesStore(), @@ -180,10 +171,8 @@ class InviteListPresenterTests { @Test fun `present - hides confirm dialog when cancelling`() = runTest { val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() - val presenter = InviteListPresenter( FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ), FakeSeenInvitesStore(), @@ -207,7 +196,6 @@ class InviteListPresenterTests { fun `present - declines invite after confirming`() = runTest { val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val client = FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ) val room = FakeMatrixRoom() @@ -234,7 +222,6 @@ class InviteListPresenterTests { fun `present - declines invite after confirming and sets state on error`() = runTest { val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val client = FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ) val room = FakeMatrixRoom() @@ -266,7 +253,6 @@ class InviteListPresenterTests { fun `present - dismisses declining error state`() = runTest { val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val client = FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ) val room = FakeMatrixRoom() @@ -299,7 +285,6 @@ class InviteListPresenterTests { fun `present - accepts invites and sets state on success`() = runTest { val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val client = FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ) val room = FakeMatrixRoom() @@ -323,7 +308,6 @@ class InviteListPresenterTests { fun `present - accepts invites and sets state on error`() = runTest { val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val client = FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ) val room = FakeMatrixRoom() @@ -349,7 +333,6 @@ class InviteListPresenterTests { fun `present - dismisses accepting error state`() = runTest { val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val client = FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ) val room = FakeMatrixRoom() @@ -379,7 +362,6 @@ class InviteListPresenterTests { val store = FakeSeenInvitesStore() val presenter = InviteListPresenter( FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ), store, @@ -416,7 +398,6 @@ class InviteListPresenterTests { store.publishRoomIds(setOf(A_ROOM_ID)) val presenter = InviteListPresenter( FakeMatrixClient( - sessionId = A_SESSION_ID, invitesDataSource = invitesDataSource, ), store, diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt index 54ab3d1dc5..03eeb3adc6 100644 --- a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt +++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt @@ -213,5 +213,5 @@ private fun TestScope.createPresenter( ): LeaveRoomPresenter = LeaveRoomPresenterImpl( client = client, roomMembershipObserver = roomMembershipObserver, - dispatchers = testCoroutineDispatchers(testScheduler, false), + dispatchers = testCoroutineDispatchers(false), ) diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt index 0eac558ba5..07a546192d 100644 --- a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt @@ -16,6 +16,19 @@ package io.element.android.features.login.api -import io.element.android.libraries.architecture.SimpleFeatureEntryPoint +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.architecture.FeatureEntryPoint -interface LoginEntryPoint : SimpleFeatureEntryPoint +interface LoginEntryPoint : FeatureEntryPoint { + data class Params( + val isAccountCreation: Boolean, + ) + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun params(params: Params): NodeBuilder + fun build(): Node + } +} diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index c43a5c2cd3..2236438e2a 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -19,6 +19,7 @@ plugins { alias(libs.plugins.anvil) alias(libs.plugins.ksp) id("kotlin-parcelize") + kotlin("plugin.serialization") version "1.8.21" } android { @@ -39,13 +40,18 @@ dependencies { implementation(projects.anvilannotations) anvil(projects.anvilcodegen) implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.network) implementation(projects.libraries.designsystem) implementation(projects.libraries.elementresources) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) implementation(libs.androidx.browser) + implementation(libs.network.retrofit) + implementation(libs.serialization.json) api(projects.features.login.api) ksp(libs.showkase.processor) @@ -55,6 +61,7 @@ dependencies { testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) androidTestImplementation(libs.test.junitext) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt index 64c30b7727..a4290825fb 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt @@ -18,6 +18,7 @@ package io.element.android.features.login.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.login.api.LoginEntryPoint import io.element.android.libraries.architecture.createNode @@ -26,7 +27,19 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultLoginEntryPoint @Inject constructor() : LoginEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext): Node { - return parentNode.createNode(buildContext) + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LoginEntryPoint.NodeBuilder { + val plugins = ArrayList() + + return object : LoginEntryPoint.NodeBuilder { + + override fun params(params: LoginEntryPoint.Params): LoginEntryPoint.NodeBuilder { + plugins += LoginFlowNode.Inputs(isAccountCreation = params.isAccountCreation) + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index 36153a33ba..33e3a66abe 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -20,7 +20,6 @@ import android.app.Activity import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.bumble.appyx.core.composable.Children @@ -29,17 +28,23 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.push +import com.bumble.appyx.navmodel.backstack.operation.singleTop import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode -import io.element.android.features.login.impl.changeserver.ChangeServerNode +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler import io.element.android.features.login.impl.oidc.webview.OidcNode -import io.element.android.features.login.impl.root.LoginRootNode +import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode +import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode +import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode +import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.inputs import io.element.android.libraries.designsystem.theme.ElementTheme import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.OidcDetails @@ -51,9 +56,10 @@ class LoginFlowNode @AssistedInject constructor( @Assisted plugins: List, private val customTabAvailabilityChecker: CustomTabAvailabilityChecker, private val customTabHandler: CustomTabHandler, + private val accountProviderDataSource: AccountProviderDataSource, ) : BackstackNode( backstack = BackStack( - initialElement = NavTarget.Root, + initialElement = NavTarget.ConfirmAccountProvider, savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, @@ -62,12 +68,24 @@ class LoginFlowNode @AssistedInject constructor( private var activity: Activity? = null private var darkTheme: Boolean = false + data class Inputs( + val isAccountCreation: Boolean, + ) : NodeInputs + + private val inputs: Inputs = inputs() + sealed interface NavTarget : Parcelable { @Parcelize - object Root : NavTarget + object ConfirmAccountProvider : NavTarget @Parcelize - object ChangeServer : NavTarget + object ChangeAccountProvider : NavTarget + + @Parcelize + object SearchAccountProvider : NavTarget + + @Parcelize + object LoginPassword : NavTarget @Parcelize data class OidcView(val oidcDetails: OidcDetails) : NavTarget @@ -75,12 +93,11 @@ class LoginFlowNode @AssistedInject constructor( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { - NavTarget.Root -> { - val callback = object : LoginRootNode.Callback { - override fun onChangeHomeServer() { - backstack.push(NavTarget.ChangeServer) - } - + NavTarget.ConfirmAccountProvider -> { + val inputs = ConfirmAccountProviderNode.Inputs( + isAccountCreation = inputs.isAccountCreation + ) + val callback = object : ConfirmAccountProviderNode.Callback { override fun onOidcDetails(oidcDetails: OidcDetails) { if (customTabAvailabilityChecker.supportCustomTab()) { // In this case open a Chrome Custom tab @@ -90,11 +107,44 @@ class LoginFlowNode @AssistedInject constructor( backstack.push(NavTarget.OidcView(oidcDetails)) } } - } - createNode(buildContext, plugins = listOf(callback)) - } - NavTarget.ChangeServer -> createNode(buildContext) + override fun onLoginPasswordNeeded() { + backstack.push(NavTarget.LoginPassword) + } + + override fun onChangeAccountProvider() { + backstack.push(NavTarget.ChangeAccountProvider) + } + } + createNode(buildContext, plugins = listOf(inputs, callback)) + } + NavTarget.ChangeAccountProvider -> { + val callback = object : ChangeAccountProviderNode.Callback { + override fun onDone() { + // Go back to the Account Provider screen + backstack.singleTop(NavTarget.ConfirmAccountProvider) + } + + override fun onOtherClicked() { + backstack.push(NavTarget.SearchAccountProvider) + } + } + + createNode(buildContext, plugins = listOf(callback)) + } + NavTarget.SearchAccountProvider -> { + val callback = object : SearchAccountProviderNode.Callback { + override fun onDone() { + // Go back to the Account Provider screen + backstack.singleTop(NavTarget.ConfirmAccountProvider) + } + } + + createNode(buildContext, plugins = listOf(callback)) + } + NavTarget.LoginPassword -> { + createNode(buildContext, plugins = listOf()) + } is NavTarget.OidcView -> { val input = OidcNode.Inputs(navTarget.oidcDetails) createNode(buildContext, plugins = listOf(input)) @@ -109,6 +159,7 @@ class LoginFlowNode @AssistedInject constructor( DisposableEffect(Unit) { onDispose { activity = null + accountProviderDataSource.reset() } } Children( diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt new file mode 100644 index 0000000000..b6aea81951 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.accountprovider + +data class AccountProvider constructor( + val title: String, + val subtitle: String? = null, + val isPublic: Boolean = false, + val isMatrixOrg: Boolean = false, + val isValid: Boolean = false, + val supportSlidingSync: Boolean = false, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt new file mode 100644 index 0000000000..ea541285df --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.accountprovider + +import io.element.android.features.login.impl.util.defaultAccountProvider +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@SingleIn(AppScope::class) +class AccountProviderDataSource @Inject constructor( +) { + private val accountProvider: MutableStateFlow = MutableStateFlow( + defaultAccountProvider + ) + + fun flow(): StateFlow { + return accountProvider.asStateFlow() + } + + fun reset() { + accountProvider.tryEmit(defaultAccountProvider) + } + + fun userSelection(data: AccountProvider) { + accountProvider.tryEmit(data) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt new file mode 100644 index 0000000000..71e1abd591 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.accountprovider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class AccountProviderProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAccountProvider(), + anAccountProvider().copy(subtitle = null), + anAccountProvider().copy(subtitle = null, title = "no.sliding.sync", supportSlidingSync = false), + anAccountProvider().copy(subtitle = null, title = "invalid", isValid = false, supportSlidingSync = false), + anAccountProvider().copy(subtitle = null, title = "Other", isPublic = false, isMatrixOrg = false), + // Add other state here + ) +} + +fun anAccountProvider() = AccountProvider( + title = "matrix.org", + subtitle = "Matrix.org is an open network for secure, decentralized communication.", + isPublic = true, + isMatrixOrg = true, + isValid = true, + supportSlidingSync = true, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt new file mode 100644 index 0000000000..0ceb65dea9 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.accountprovider + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.login.impl.R +import io.element.android.libraries.designsystem.ElementTextStyles +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817 + */ +@Composable +fun AccountProviderView( + item: AccountProvider, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Column(modifier = modifier + .fillMaxWidth() + .clickable { onClick() }) { + Divider() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 44.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (item.isMatrixOrg) { + RoundedIconAtom( + size = RoundedIconAtomSize.Medium, + resourceId = R.drawable.ic_matrix, + tint = Color.Unspecified, + ) + } else { + RoundedIconAtom( + size = RoundedIconAtomSize.Medium, + imageVector = Icons.Filled.Search, + tint = MaterialTheme.colorScheme.primary, + ) + } + Text( + modifier = Modifier + .padding(start = 16.dp) + .weight(1f), + text = item.title, + style = ElementTextStyles.Regular.headline.copy(textAlign = TextAlign.Start), + color = MaterialTheme.colorScheme.primary, + ) + if (item.isPublic) { + Icon( + modifier = Modifier + .padding(start = 10.dp) + .size(16.dp), + resourceId = R.drawable.ic_public, + contentDescription = null, + tint = Color.Unspecified, + ) + } + } + if (item.subtitle != null) { + Text( + modifier = Modifier + .padding(start = 46.dp, bottom = 12.dp, end = 26.dp), + text = item.subtitle, + style = ElementTextStyles.Regular.subheadline.copy(textAlign = TextAlign.Start), + color = MaterialTheme.colorScheme.secondary, + ) + } + } + } +} + +@Preview +@Composable +fun AccountProviderViewLightPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) = + ElementPreviewLight { ContentToPreview(item) } + +@Preview +@Composable +fun AccountProviderViewDarkPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) = + ElementPreviewDark { ContentToPreview(item) } + +@Composable +private fun ContentToPreview(item: AccountProvider) { + AccountProviderView( + item = item, + onClick = { } + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt index dd1e83c3f8..cd1cb7b4ce 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt @@ -16,8 +16,9 @@ package io.element.android.features.login.impl.changeserver +import io.element.android.features.login.impl.accountprovider.AccountProvider + sealed interface ChangeServerEvents { - data class SetServer(val server: String) : ChangeServerEvents - object Submit : ChangeServerEvents + data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvents object ClearError : ChangeServerEvents } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt index f2e688812c..2e3f9548d6 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt @@ -21,8 +21,9 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import io.element.android.features.login.impl.util.LoginConstants +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.execute @@ -33,44 +34,43 @@ import kotlinx.coroutines.launch import java.net.URL import javax.inject.Inject -class ChangeServerPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter { +class ChangeServerPresenter @Inject constructor( + private val authenticationService: MatrixAuthenticationService, + private val accountProviderDataSource: AccountProviderDataSource, +) : Presenter { @Composable override fun present(): ChangeServerState { val localCoroutineScope = rememberCoroutineScope() - val homeserver = rememberSaveable { - mutableStateOf(authenticationService.getHomeserverDetails().value?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL) - } val changeServerAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } fun handleEvents(event: ChangeServerEvents) { when (event) { - is ChangeServerEvents.SetServer -> { - homeserver.value = event.server - handleEvents(ChangeServerEvents.ClearError) - } - ChangeServerEvents.Submit -> { - localCoroutineScope.submit(homeserver, changeServerAction) - } + is ChangeServerEvents.ChangeServer -> localCoroutineScope.changeServer(event.accountProvider, changeServerAction) ChangeServerEvents.ClearError -> changeServerAction.value = Async.Uninitialized } } return ChangeServerState( - homeserver = homeserver.value, changeServerAction = changeServerAction.value, eventSink = ::handleEvents ) } - private fun CoroutineScope.submit(homeserverUrl: MutableState, changeServerAction: MutableState>) = launch { + private fun CoroutineScope.changeServer( + data: AccountProvider, + changeServerAction: MutableState>, + ) = launch { suspend { - val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value - authenticationService.setHomeserver(domain).getOrThrow() - homeserverUrl.value = domain + val domain = tryOrNull { URL(data.title) }?.host ?: data.title + authenticationService.setHomeserver(domain).map { + authenticationService.getHomeserverDetails().value!! + // Valid, remember user choice + accountProviderDataSource.userSelection(data) + }.getOrThrow() }.execute(changeServerAction, errorMapping = ChangeServerError::from) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt index 5a3ea3b856..e49fa1f2fe 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt @@ -19,9 +19,6 @@ package io.element.android.features.login.impl.changeserver import io.element.android.libraries.architecture.Async data class ChangeServerState( - val homeserver: String, val changeServerAction: Async, - val eventSink: (ChangeServerEvents) -> Unit, -) { - val submitEnabled: Boolean get() = homeserver.isNotEmpty() && (changeServerAction is Async.Uninitialized || changeServerAction is Async.Loading) -} + val eventSink: (ChangeServerEvents) -> Unit +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt index 3e6eeef05a..90c2bff455 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt @@ -17,26 +17,16 @@ package io.element.android.features.login.impl.changeserver import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.login.impl.R import io.element.android.libraries.architecture.Async open class ChangeServerStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aChangeServerState(), - aChangeServerState().copy(homeserver = "matrix.org"), - aChangeServerState().copy(homeserver = "matrix.org", changeServerAction = Async.Loading()), - aChangeServerState().copy( - homeserver = "invalid.org", - changeServerAction = Async.Failure(ChangeServerError.InlineErrorMessage(R.string.screen_change_server_error_invalid_homeserver)) - ), - aChangeServerState().copy(homeserver = "invalid.org", changeServerAction = Async.Failure(ChangeServerError.SlidingSyncAlert)), - aChangeServerState().copy(homeserver = "matrix.org", changeServerAction = Async.Success(Unit)), ) } fun aChangeServerState() = ChangeServerState( - homeserver = "", changeServerAction = Async.Uninitialized, eventSink = {} ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt index cbd2bc9308..f9e9624503 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt @@ -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,276 +16,74 @@ package io.element.android.features.login.impl.changeserver -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.ExperimentalTextApi -import androidx.compose.ui.text.ParagraphStyle -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.UrlAnnotation -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import io.element.android.features.login.impl.R -import io.element.android.features.login.impl.util.LoginConstants +import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog +import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.libraries.architecture.Async -import io.element.android.libraries.designsystem.ElementTextStyles -import io.element.android.libraries.designsystem.LinkColor -import io.element.android.libraries.designsystem.components.ClickableLinkText -import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.components.button.ButtonWithProgress -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog -import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.LocalColors -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.Text -import io.element.android.libraries.designsystem.theme.components.TextField -import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext -import io.element.android.libraries.testtags.TestTags -import io.element.android.libraries.testtags.testTag -import io.element.android.libraries.ui.strings.R as StringR -@OptIn(ExperimentalMaterial3Api::class, ExperimentalTextApi::class, ExperimentalLayoutApi::class) @Composable fun ChangeServerView( state: ChangeServerState, onLearnMoreClicked: () -> Unit, - onBackPressed: () -> Unit, + onDone: () -> Unit, modifier: Modifier = Modifier, - onChangeServerSuccess: () -> Unit = {}, ) { val eventSink = state.eventSink - val scrollState = rememberScrollState() - val isLoading by remember(state.changeServerAction) { - derivedStateOf { - state.changeServerAction is Async.Loading - } - } - val invalidHomeserverError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.InlineErrorMessage - val slidingSyncNotSupportedError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.SlidingSyncAlert - val focusManager = LocalFocusManager.current - - fun submit() { - // Clear focus to prevent keyboard issues with textfields - focusManager.clearFocus(force = true) - - eventSink(ChangeServerEvents.Submit) - } - - Scaffold( - topBar = { - TopAppBar( - title = {}, - navigationIcon = { BackButton(onClick = onBackPressed) } - ) - } - ) { padding -> - Box( - modifier = modifier - .fillMaxSize() - .imePadding() - .padding(padding) - .consumeWindowInsets(padding) - ) { - Column( - modifier = Modifier - .verticalScroll( - state = scrollState, - ) - .padding(horizontal = 16.dp) - ) { - Spacer(Modifier.height(42.dp)) - Box( - modifier = Modifier - .size(width = 70.dp, height = 70.dp) - .align(Alignment.CenterHorizontally) - .background( - color = LocalColors.current.quinary, - shape = RoundedCornerShape(14.dp) - ) - ) { - Icon( - modifier = Modifier - .align(Alignment.Center) - .size(width = 32.dp, height = 32.dp), - tint = MaterialTheme.colorScheme.secondary, - resourceId = R.drawable.ic_homeserver, - contentDescription = "", + when (state.changeServerAction) { + is Async.Failure -> { + when (val error = state.changeServerAction.error) { + is ChangeServerError.Error -> { + ErrorDialog( + modifier = modifier, + content = error.message(), + onDismiss = { + eventSink.invoke(ChangeServerEvents.ClearError) + } ) } - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(id = R.string.screen_change_server_title), - modifier = Modifier - .fillMaxWidth() - .align(Alignment.CenterHorizontally), - textAlign = TextAlign.Center, - style = ElementTextStyles.Bold.title2, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(id = R.string.screen_change_server_subtitle), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - textAlign = TextAlign.Center, - style = ElementTextStyles.Regular.subheadline, - color = MaterialTheme.colorScheme.secondary, - ) - Spacer(Modifier.height(24.dp)) - Text( - stringResource(R.string.screen_change_server_form_header), - style = ElementTextStyles.Regular.formHeader, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - var homeserverFieldState by textFieldState(stateValue = state.homeserver) - TextField( - value = homeserverFieldState, - readOnly = isLoading, - modifier = Modifier - .fillMaxWidth() - .testTag(TestTags.changeServerServer) - .onTabOrEnterKeyFocusNext(focusManager), - onValueChange = { - homeserverFieldState = it - eventSink(ChangeServerEvents.SetServer(it)) - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { submit() } - ), - singleLine = true, - maxLines = 1, - trailingIcon = if (homeserverFieldState.isNotEmpty()) { - { - IconButton(onClick = { - eventSink(ChangeServerEvents.SetServer("")) - }, enabled = !isLoading) { - Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(StringR.string.action_clear)) - } - } - } else null, - isError = invalidHomeserverError != null, - supportingText = { - if (invalidHomeserverError != null) { - Text(invalidHomeserverError.message(), color = MaterialTheme.colorScheme.error) - } else { - val footerMessage = stringResource(R.string.screen_change_server_form_notice, "") - val footerAction = stringResource(StringR.string.action_learn_more) - val footerText = buildAnnotatedString { - val defaultColor = MaterialTheme.colorScheme.tertiary - withStyle(ParagraphStyle(textAlign = TextAlign.Start)) { - withStyle(SpanStyle(color = defaultColor)) { - append(footerMessage) - } - val start = length - withStyle(SpanStyle(color = LinkColor)) { - append(footerAction) - } - addUrlAnnotation(UrlAnnotation(LoginConstants.SLIDING_SYNC_READ_MORE_URL), start, length) - } - } - ClickableLinkText( - text = footerText, - interactionSource = MutableInteractionSource(), - style = ElementTextStyles.Regular.caption1, - ) - } - } - - ) - if (slidingSyncNotSupportedError != null) { - SlidingSyncNotSupportedDialog(onLearnMoreClicked = { - onLearnMoreClicked() - eventSink(ChangeServerEvents.ClearError) - }, onDismiss = { - eventSink(ChangeServerEvents.ClearError) + is ChangeServerError.SlidingSyncAlert -> { + SlidingSyncNotSupportedDialog( + modifier = modifier, + onLearnMoreClicked = { + onLearnMoreClicked() + eventSink.invoke(ChangeServerEvents.ClearError) + }, onDismiss = { + eventSink.invoke(ChangeServerEvents.ClearError) }) } - Spacer(Modifier.height(32.dp)) - ButtonWithProgress( - text = stringResource(id = R.string.screen_change_server_submit), - showProgress = isLoading, - onClick = ::submit, - enabled = state.submitEnabled, - modifier = Modifier - .fillMaxWidth() - .testTag(TestTags.changeServerContinue) - ) - if (state.changeServerAction is Async.Success) { - onChangeServerSuccess() - } } } + is Async.Loading -> ProgressDialog() + is Async.Success -> LaunchedEffect(state.changeServerAction) { + onDone() + } + Async.Uninitialized -> Unit } } -@Composable -internal fun SlidingSyncNotSupportedDialog(onLearnMoreClicked: () -> Unit, onDismiss: () -> Unit) { - ConfirmationDialog( - onDismiss = onDismiss, - submitText = stringResource(StringR.string.action_learn_more), - onSubmitClicked = onLearnMoreClicked, - onCancelClicked = onDismiss, - emphasizeSubmitButton = true, - title = stringResource(StringR.string.dialog_title_error), - content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message), - ) -} - @Preview @Composable -internal fun ChangeServerViewLightPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = +fun ChangeServerViewLightPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -internal fun ChangeServerViewDarkPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = +fun ChangeServerViewDarkPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = ElementPreviewDark { ContentToPreview(state) } @Composable private fun ContentToPreview(state: ChangeServerState) { - ChangeServerView(state = state, onBackPressed = {}, onLearnMoreClicked = {}) + ChangeServerView( + state = state, + onLearnMoreClicked = {}, + onDone = {}, + ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt new file mode 100644 index 0000000000..32eb1b0824 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.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.features.login.impl.dialogs + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.login.impl.R +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.R as StringR + +@Composable +internal fun SlidingSyncNotSupportedDialog( + onLearnMoreClicked: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ConfirmationDialog( + modifier = modifier, + onDismiss = onDismiss, + submitText = stringResource(StringR.string.action_learn_more), + onSubmitClicked = onLearnMoreClicked, + onCancelClicked = onDismiss, + emphasizeSubmitButton = true, + title = stringResource(StringR.string.dialog_title_error), + content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message), + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerError.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt similarity index 83% rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerError.kt rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt index 954f4f10dc..444ea3d3f2 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerError.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.login.impl.changeserver +package io.element.android.features.login.impl.error import androidx.annotation.StringRes import androidx.compose.runtime.Composable @@ -23,7 +23,7 @@ import io.element.android.features.login.impl.R import io.element.android.libraries.matrix.api.auth.AuthenticationException sealed class ChangeServerError : Throwable() { - data class InlineErrorMessage(@StringRes val messageId: Int) : ChangeServerError() { + data class Error(@StringRes val messageId: Int) : ChangeServerError() { @Composable fun message(): String = stringResource(messageId) } @@ -32,7 +32,7 @@ sealed class ChangeServerError : Throwable() { companion object { fun from(error: Throwable): ChangeServerError = when (error) { is AuthenticationException.SlidingSyncNotAvailable -> SlidingSyncAlert - else -> InlineErrorMessage(R.string.screen_change_server_error_invalid_homeserver) + else -> Error(R.string.screen_change_server_error_invalid_homeserver) } } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ErrorFormatter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ErrorFormatter.kt index 68b0a70db4..ec2dc95386 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ErrorFormatter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ErrorFormatter.kt @@ -16,19 +16,21 @@ package io.element.android.features.login.impl.error +import androidx.annotation.StringRes import io.element.android.features.login.impl.R import io.element.android.libraries.matrix.api.auth.AuthErrorCode import io.element.android.libraries.matrix.api.auth.AuthenticationException import io.element.android.libraries.matrix.api.auth.errorCode -import io.element.android.libraries.ui.strings.R.string as StringR +import io.element.android.libraries.ui.strings.R as StringR +@StringRes fun loginError( throwable: Throwable ): Int { - val authException = throwable as? AuthenticationException ?: return StringR.error_unknown + val authException = throwable as? AuthenticationException ?: return StringR.string.error_unknown return when (authException.errorCode) { AuthErrorCode.FORBIDDEN -> R.string.screen_login_error_invalid_credentials AuthErrorCode.USER_DEACTIVATED -> R.string.screen_login_error_deactivated_account - AuthErrorCode.UNKNOWN -> StringR.error_unknown + AuthErrorCode.UNKNOWN -> StringR.string.error_unknown } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/CustomTabHandler.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/CustomTabHandler.kt index 407459c5bf..48c674e0a0 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/CustomTabHandler.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/CustomTabHandler.kt @@ -23,6 +23,7 @@ import android.net.Uri import androidx.browser.customtabs.CustomTabsClient import androidx.browser.customtabs.CustomTabsServiceConnection import androidx.browser.customtabs.CustomTabsSession +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.di.ApplicationContext import javax.inject.Inject diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt new file mode 100644 index 0000000000..c1f0158605 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.resolver + +data class HomeserverData constructor( + // The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url + val homeserverUrl: String, + // True if a wellknown file has been found and is valid. If false, it means that the [homeserverUrl] is valid + val isWellknownValid: Boolean, + // True if a wellknown file has been found and is valid and is claiming a sliding sync Url + val supportSlidingSync: Boolean, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt new file mode 100644 index 0000000000..7c692f5208 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.resolver + +import io.element.android.features.login.impl.resolver.network.WellknownRequest +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.core.uri.isValidUrl +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.util.Collections +import javax.inject.Inject + +/** + * Resolve homeserver base on search terms. + */ +class HomeserverResolver @Inject constructor( + private val dispatchers: CoroutineDispatchers, + private val wellknownRequest: WellknownRequest, +) { + + suspend fun resolve(userInput: String): Flow> = flow { + val flowContext = currentCoroutineContext() + val trimmedUserInput = userInput.trim() + if (trimmedUserInput.length < 4) return@flow + val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/") + val list = getUrlCandidates(candidateBase) + val currentList = Collections.synchronizedList(mutableListOf()) + // Run all the requests in parallel + withContext(dispatchers.io) { + list.map { url -> + async { + val wellKnown = tryOrNull { + withTimeout(5000) { + wellknownRequest.execute(url) + } + } + val isValid = wellKnown?.isValid().orFalse() + if (isValid) { + val supportSlidingSync = wellKnown?.supportSlidingSync().orFalse() + // Emit the list as soon as possible + currentList.add( + HomeserverData( + homeserverUrl = url, + isWellknownValid = true, + supportSlidingSync = supportSlidingSync + ) + ) + withContext(flowContext) { + emit(currentList.toList()) + } + } + } + }.awaitAll() + } + // If list is empty, and the user has entered an URL, do not block the user. + if (currentList.isEmpty() && trimmedUserInput.isValidUrl()) { + emit( + listOf( + HomeserverData( + homeserverUrl = trimmedUserInput, + isWellknownValid = false, + supportSlidingSync = false, + ) + ) + ) + } + } + + private fun getUrlCandidates(data: String): List { + return buildList { + if (data.contains(".")) { + // TLD detected? + } else { + add("${data}.org") + add("${data}.com") + add("${data}.io") + } + // Always try what the user has entered + add(data) + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/DefaultWellknownRequest.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/DefaultWellknownRequest.kt new file mode 100644 index 0000000000..7de6d26f10 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/DefaultWellknownRequest.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.features.login.impl.resolver.network + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.network.RetrofitFactory +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultWellknownRequest @Inject constructor( + private val retrofitFactory: RetrofitFactory, +) : WellknownRequest { + /** + * Return the WellKnown data, if found. + * @param baseUrl for instance https://matrix.org + */ + override suspend fun execute(baseUrl: String): WellKnown { + val wellknownApi = retrofitFactory.create(baseUrl) + .create(WellknownAPI::class.java) + return wellknownApi.getWellKnown() + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnown.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnown.kt new file mode 100644 index 0000000000..63b6e7d189 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnown.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.features.login.impl.resolver.network + +import io.element.android.libraries.core.bool.orFalse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery + *
+ * {
+ *     "m.homeserver": {
+ *         "base_url": "https://matrix.org"
+ *     },
+ *     "m.identity_server": {
+ *         "base_url": "https://vector.im"
+ *     },
+ *     "org.matrix.msc3575.proxy": {
+ *         "url": "https://slidingsync.lab.matrix.org"
+ *     }
+ * }
+ * 
+ * . + */ +@Serializable +data class WellKnown( + @SerialName("m.homeserver") + val homeServer: WellKnownBaseConfig? = null, + + @SerialName("m.identity_server") + val identityServer: WellKnownBaseConfig? = null, + + @SerialName("org.matrix.msc3575.proxy") + val slidingSyncProxy: WellKnownSlidingSyncConfig? = null, +) { + fun isValid(): Boolean { + return homeServer?.baseURL?.isNotBlank().orFalse() + } + + fun supportSlidingSync(): Boolean { + return slidingSyncProxy?.url?.isNotBlank().orFalse() + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownBaseConfig.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownBaseConfig.kt new file mode 100644 index 0000000000..87b86736fa --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownBaseConfig.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.resolver.network + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery + *
+ * {
+ *     "base_url": "https://element.io"
+ * }
+ * 
+ * . + */ +@Serializable +data class WellKnownBaseConfig( + @SerialName("base_url") + val baseURL: String? = null +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownSlidingSyncConfig.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownSlidingSyncConfig.kt new file mode 100644 index 0000000000..98c712d9ac --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownSlidingSyncConfig.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.resolver.network + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WellKnownSlidingSyncConfig( + @SerialName("url") + val url: String? = null, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownAPI.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownAPI.kt new file mode 100644 index 0000000000..04a0dfb803 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownAPI.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.features.login.impl.resolver.network + +import retrofit2.http.GET + +internal interface WellknownAPI { + @GET(".well-known/matrix/client") + suspend fun getWellKnown(): WellKnown +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownRequest.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownRequest.kt new file mode 100644 index 0000000000..570b621b83 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownRequest.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.features.login.impl.resolver.network + +interface WellknownRequest { + /** + * Return the WellKnown data, or throw an error if not found. + * @param baseUrl for instance https://matrix.org + */ + suspend fun execute(baseUrl: String): WellKnown +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootPresenter.kt deleted file mode 100644 index f55c2030e7..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootPresenter.kt +++ /dev/null @@ -1,171 +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.features.login.impl.root - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import io.element.android.features.login.api.oidc.OidcAction -import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow -import io.element.android.features.login.impl.util.LoginConstants -import io.element.android.libraries.architecture.Async -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.architecture.execute -import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService -import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import javax.inject.Inject - -class LoginRootPresenter @Inject constructor( - private val authenticationService: MatrixAuthenticationService, - private val defaultOidcActionFlow: DefaultOidcActionFlow, -) : Presenter { - - @Composable - override fun present(): LoginRootState { - val localCoroutineScope = rememberCoroutineScope() - val currentHomeServerDetails = authenticationService.getHomeserverDetails().collectAsState().value - val homeserver = currentHomeServerDetails?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL - val getHomeServerDetailsAction: MutableState> = remember { - if (currentHomeServerDetails != null) { - mutableStateOf(Async.Success(currentHomeServerDetails)) - } else { - mutableStateOf(Async.Uninitialized) - } - } - - LaunchedEffect(Unit) { - if (currentHomeServerDetails == null) { - getHomeServerDetails(homeserver, getHomeServerDetailsAction) - } - } - - val loggedInState: MutableState = remember { - mutableStateOf(LoggedInState.NotLoggedIn) - } - val formState = rememberSaveable { - mutableStateOf(LoginFormState.Default) - } - - LaunchedEffect(Unit) { - launch { - defaultOidcActionFlow.collect { - onOidcAction(it, loggedInState) - } - } - } - - fun handleEvents(event: LoginRootEvents) { - when (event) { - LoginRootEvents.RetryFetchServerInfo -> localCoroutineScope.getHomeServerDetails(homeserver, getHomeServerDetailsAction) - is LoginRootEvents.SetLogin -> updateFormState(formState) { - copy(login = event.login) - } - is LoginRootEvents.SetPassword -> updateFormState(formState) { - copy(password = event.password) - } - LoginRootEvents.Submit -> { - val homeServerDetails = getHomeServerDetailsAction.value.dataOrNull() ?: return - when { - homeServerDetails.supportsOidcLogin -> localCoroutineScope.submitOidc(loggedInState) - homeServerDetails.supportsPasswordLogin -> localCoroutineScope.submit(formState.value, loggedInState) - } - } - LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn - } - } - - return LoginRootState( - homeserverUrl = homeserver, - homeserverDetails = getHomeServerDetailsAction.value, - loggedInState = loggedInState.value, - formState = formState.value, - eventSink = ::handleEvents - ) - } - - private fun CoroutineScope.getHomeServerDetails( - homeserver: String, - state: MutableState>, - ) = launch { - suspend { - authenticationService.setHomeserver(homeserver) - .map { - authenticationService.getHomeserverDetails().value!! - } - .getOrThrow() - }.execute(state) - } - - private fun CoroutineScope.submitOidc(loggedInState: MutableState) = launch { - loggedInState.value = LoggedInState.LoggingIn - authenticationService.getOidcUrl() - .onSuccess { - loggedInState.value = LoggedInState.OidcStarted(it) - } - .onFailure { failure -> - loggedInState.value = LoggedInState.ErrorLoggingIn(failure) - } - } - - private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState) = launch { - loggedInState.value = LoggedInState.LoggingIn - authenticationService.login(formState.login.trim(), formState.password) - .onSuccess { sessionId -> - loggedInState.value = LoggedInState.LoggedIn(sessionId) - } - .onFailure { failure -> - loggedInState.value = LoggedInState.ErrorLoggingIn(failure) - } - } - - private fun updateFormState(formState: MutableState, updateLambda: LoginFormState.() -> LoginFormState) { - formState.value = updateLambda(formState.value) - } - - private suspend fun onOidcAction(oidcAction: OidcAction?, loggedInState: MutableState) { - oidcAction ?: return - loggedInState.value = LoggedInState.LoggingIn - when (oidcAction) { - OidcAction.GoBack -> { - authenticationService.cancelOidcLogin() - .onSuccess { - loggedInState.value = LoggedInState.NotLoggedIn - } - .onFailure { failure -> - loggedInState.value = LoggedInState.ErrorLoggingIn(failure) - } - } - is OidcAction.Success -> { - authenticationService.loginWithOidc(oidcAction.url) - .onSuccess { sessionId -> - loggedInState.value = LoggedInState.LoggedIn(sessionId) - } - .onFailure { failure -> - loggedInState.value = LoggedInState.ErrorLoggingIn(failure) - } - } - } - defaultOidcActionFlow.reset() - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootStateProvider.kt deleted file mode 100644 index 5f6d7c1f3a..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootStateProvider.kt +++ /dev/null @@ -1,76 +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.features.login.impl.root - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.architecture.Async -import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails -import io.element.android.libraries.matrix.api.core.SessionId - -open class LoginRootStateProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aLoginRootState(), - aLoginRootState().copy( - homeserverDetails = Async.Success( - MatrixHomeServerDetails( - "some-custom-server.com", - supportsPasswordLogin = true, - supportsOidcLogin = false - ) - ) - ), - aLoginRootState().copy(formState = LoginFormState("user", "pass")), - aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn), - aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.ErrorLoggingIn(Throwable())), - aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))), - // Oidc - aLoginRootState().copy( - homeserverUrl = "server-with-oidc.org", - homeserverDetails = Async.Success( - MatrixHomeServerDetails( - "server-with-oidc.org", - supportsPasswordLogin = false, - supportsOidcLogin = true - ) - ) - ), - // No password, no oidc support - aLoginRootState().copy( - homeserverUrl = "wrong.org", - homeserverDetails = Async.Success( - MatrixHomeServerDetails( - "wrong.org", - supportsPasswordLogin = false, - supportsOidcLogin = false - ) - ) - ), - // Loading - aLoginRootState().copy(homeserverDetails = Async.Loading()), - //Error - aLoginRootState().copy(homeserverDetails = Async.Failure(Exception("An error occurred"))), - ) -} - -fun aLoginRootState() = LoginRootState( - homeserverUrl = "matrix.org", - homeserverDetails = Async.Success(MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidcLogin = false)), - loggedInState = LoggedInState.NotLoggedIn, - formState = LoginFormState.Default, - eventSink = {} -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt similarity index 66% rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerNode.kt rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt index 11952ce5a8..45bf4489b5 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt @@ -14,49 +14,52 @@ * limitations under the License. */ -package io.element.android.features.login.impl.changeserver +package io.element.android.features.login.impl.screens.changeaccountprovider -import android.content.Context -import android.content.Intent -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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 dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode -import io.element.android.features.login.impl.util.LoginConstants -import io.element.android.libraries.core.data.tryOrNull +import io.element.android.features.login.impl.util.openLearnMorePage import io.element.android.libraries.di.AppScope @ContributesNode(AppScope::class) -class ChangeServerNode @AssistedInject constructor( +class ChangeAccountProviderNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: ChangeServerPresenter, + private val presenter: ChangeAccountProviderPresenter, ) : Node(buildContext, plugins = plugins) { - private fun onSuccess() { - navigateUp() + + interface Callback : Plugin { + fun onDone() + fun onOtherClicked() } - private fun openLearnMorePage(context: Context) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL)) - tryOrNull { context.startActivity(intent) } + private fun onDone() { + plugins().forEach { it.onDone() } + } + + private fun onOtherClicked() { + plugins().forEach { it.onOtherClicked() } } @Composable override fun View(modifier: Modifier) { val state = presenter.present() val context = LocalContext.current - ChangeServerView( + ChangeAccountProviderView( state = state, modifier = modifier, - onChangeServerSuccess = this::onSuccess, - onBackPressed = { navigateUp() }, + onBackPressed = ::navigateUp, onLearnMoreClicked = { openLearnMorePage(context) }, + onDone = ::onDone, + onOtherProviderClicked = ::onOtherClicked, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt new file mode 100644 index 0000000000..dfdb7dcf99 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.changeaccountprovider + +import androidx.compose.runtime.Composable +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.changeserver.ChangeServerPresenter +import io.element.android.libraries.architecture.Presenter +import javax.inject.Inject + +class ChangeAccountProviderPresenter @Inject constructor( + private val changeServerPresenter: ChangeServerPresenter, +) : Presenter { + + @Composable + override fun present(): ChangeAccountProviderState { + val changeServerState = changeServerPresenter.present() + return ChangeAccountProviderState( + // Just matrix.org by default for now + accountProviders = listOf( + AccountProvider( + title = "matrix.org", + subtitle = null, + isPublic = true, + isMatrixOrg = true, + isValid = true, + supportSlidingSync = true, + ) + ), + changeServerState = changeServerState, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt new file mode 100644 index 0000000000..806ce5bc64 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.changeaccountprovider + +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.changeserver.ChangeServerState + +// Do not use default value, so no member get forgotten in the presenters. +data class ChangeAccountProviderState constructor( + val accountProviders: List, + val changeServerState: ChangeServerState, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt new file mode 100644 index 0000000000..403746f227 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.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.features.login.impl.screens.changeaccountprovider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.features.login.impl.changeserver.aChangeServerState + +open class ChangeAccountProviderStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aChangeAccountProviderState(), + // Add other state here + ) +} + +fun aChangeAccountProviderState() = ChangeAccountProviderState( + accountProviders = listOf( + anAccountProvider() + ), + changeServerState = aChangeServerState(), +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt new file mode 100644 index 0000000000..0f444350c9 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) + +package io.element.android.features.login.impl.screens.changeaccountprovider + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +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 androidx.compose.ui.unit.dp +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.AccountProviderView +import io.element.android.features.login.impl.changeserver.ChangeServerEvents +import io.element.android.features.login.impl.changeserver.ChangeServerView +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +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.Scaffold +import io.element.android.libraries.designsystem.theme.components.TopAppBar + +/** + * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817 + */ +@Composable +fun ChangeAccountProviderView( + state: ChangeAccountProviderState, + onBackPressed: () -> Unit, + onLearnMoreClicked: () -> Unit, + onDone: () -> Unit, + onOtherProviderClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackPressed) } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .imePadding() + .padding(padding) + .consumeWindowInsets(padding) + ) { + Column( + modifier = Modifier + .verticalScroll(state = rememberScrollState()) + ) { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp), + iconImageVector = Icons.Filled.Home, + iconTint = MaterialTheme.colorScheme.primary, + title = stringResource(id = R.string.screen_change_account_provider_title), + subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle), + ) + + state.accountProviders.forEach { item -> + val alteredItem = if (item.isMatrixOrg) { + // Set the subtitle from the resource + item.copy( + subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle), + ) + } else { + item + } + AccountProviderView( + item = alteredItem, + onClick = { + state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(alteredItem)) + } + ) + } + // Other + AccountProviderView( + item = AccountProvider( + title = stringResource(id = R.string.screen_change_account_provider_other), + ), + onClick = onOtherProviderClicked + ) + Spacer(Modifier.height(32.dp)) + } + ChangeServerView( + state = state.changeServerState, + onLearnMoreClicked = onLearnMoreClicked, + onDone = onDone, + ) + } + } +} + +@Preview +@Composable +fun ChangeAccountProviderViewLightPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun ChangeAccountProviderViewDarkPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: ChangeAccountProviderState) { + ChangeAccountProviderView( + state = state, + onBackPressed = { }, + onLearnMoreClicked = { }, + onDone = { }, + onOtherProviderClicked = { }, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt new file mode 100644 index 0000000000..1ba3cc3028 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.confirmaccountprovider + +sealed interface ConfirmAccountProviderEvents { + object Continue : ConfirmAccountProviderEvents + object ClearError : ConfirmAccountProviderEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt new file mode 100644 index 0000000000..7cef986013 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.confirmaccountprovider + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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 dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.impl.util.openLearnMorePage +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.auth.OidcDetails + +@ContributesNode(AppScope::class) +class ConfirmAccountProviderNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: ConfirmAccountProviderPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + data class Inputs( + val isAccountCreation: Boolean, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val presenter = presenterFactory.create( + ConfirmAccountProviderPresenter.Params( + isAccountCreation = inputs.isAccountCreation, + ) + ) + + interface Callback : Plugin { + fun onLoginPasswordNeeded() + fun onOidcDetails(oidcDetails: OidcDetails) + fun onChangeAccountProvider() + } + + private fun onOidcDetails(data: OidcDetails) { + plugins().forEach { it.onOidcDetails(data) } + } + + private fun onLoginPasswordNeeded() { + plugins().forEach { it.onLoginPasswordNeeded() } + } + + private fun onChangeAccountProvider() { + plugins().forEach { it.onChangeAccountProvider() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val context = LocalContext.current + ConfirmAccountProviderView( + state = state, + modifier = modifier, + onOidcDetails = ::onOidcDetails, + onLoginPasswordNeeded = ::onLoginPasswordNeeded, + onChange = ::onChangeAccountProvider, + onLearnMoreClicked = { openLearnMorePage(context) }, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt new file mode 100644 index 0000000000..2626e56365 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.confirmaccountprovider + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.error.ChangeServerError +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.execute +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.net.URL + +class ConfirmAccountProviderPresenter @AssistedInject constructor( + @Assisted private val params: Params, + private val accountProviderDataSource: AccountProviderDataSource, + private val authenticationService: MatrixAuthenticationService +) : Presenter { + + data class Params( + val isAccountCreation: Boolean, + ) + + @AssistedFactory + interface Factory { + fun create(params: Params): ConfirmAccountProviderPresenter + } + + @Composable + override fun present(): ConfirmAccountProviderState { + val accountProvider by accountProviderDataSource.flow().collectAsState() + val localCoroutineScope = rememberCoroutineScope() + + val loginFlowAction: MutableState> = remember { + mutableStateOf(Async.Uninitialized) + } + + fun handleEvents(event: ConfirmAccountProviderEvents) { + when (event) { + ConfirmAccountProviderEvents.Continue -> { + localCoroutineScope.submit(accountProvider.title, loginFlowAction) + } + ConfirmAccountProviderEvents.ClearError -> loginFlowAction.value = Async.Uninitialized + } + } + + return ConfirmAccountProviderState( + accountProvider = accountProvider, + isAccountCreation = params.isAccountCreation, + loginFlow = loginFlowAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.submit( + homeserverUrl: String, + loginFlowAction: MutableState>, + ) = launch { + suspend { + val domain = tryOrNull { URL(homeserverUrl) }?.host ?: homeserverUrl + authenticationService.setHomeserver(domain).map { + val matrixHomeServerDetails = authenticationService.getHomeserverDetails().value!! + if (matrixHomeServerDetails.supportsOidcLogin) { + // Retrieve the details right now + LoginFlow.OidcFlow(authenticationService.getOidcUrl().getOrThrow()) + } else if (matrixHomeServerDetails.supportsPasswordLogin) { + LoginFlow.PasswordLogin + } else { + throw IllegalStateException("Unsupported login flow") + } + }.getOrThrow() + }.execute(loginFlowAction, errorMapping = ChangeServerError::from) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt new file mode 100644 index 0000000000..a870b88c58 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.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.features.login.impl.screens.confirmaccountprovider + +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.auth.OidcDetails + +// Do not use default value, so no member get forgotten in the presenters. +data class ConfirmAccountProviderState( + val accountProvider: AccountProvider, + val isAccountCreation: Boolean, + val loginFlow: Async, + val eventSink: (ConfirmAccountProviderEvents) -> Unit +) { + val submitEnabled: Boolean get() = accountProvider.title.isNotEmpty() && (loginFlow is Async.Uninitialized || loginFlow is Async.Loading) +} + +sealed interface LoginFlow { + object PasswordLogin : LoginFlow + data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt new file mode 100644 index 0000000000..d5f98f5716 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.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.features.login.impl.screens.confirmaccountprovider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.libraries.architecture.Async + +open class ConfirmAccountProviderStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aConfirmAccountProviderState(), + // Add other state here + ) +} + +fun aConfirmAccountProviderState() = ConfirmAccountProviderState( + accountProvider = anAccountProvider(), + isAccountCreation = false, + loginFlow = Async.Uninitialized, + eventSink = {} +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt new file mode 100644 index 0000000000..e6e1ce8e83 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.confirmaccountprovider + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog +import io.element.android.features.login.impl.error.ChangeServerError +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.components.button.ButtonWithProgress +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag + +@Composable +fun ConfirmAccountProviderView( + state: ConfirmAccountProviderState, + onOidcDetails: (OidcDetails) -> Unit, + onLoginPasswordNeeded: () -> Unit, + onLearnMoreClicked: () -> Unit, + onChange: () -> Unit, + modifier: Modifier = Modifier, +) { + val isLoading by remember(state.loginFlow) { + derivedStateOf { + state.loginFlow is Async.Loading + } + } + val eventSink = state.eventSink + + HeaderFooterPage( + modifier = modifier, + header = { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 60.dp), + iconImageVector = Icons.Filled.AccountCircle, + title = stringResource( + id = if (state.isAccountCreation) { + R.string.screen_account_provider_signup_title + } else { + R.string.screen_account_provider_signin_title + }, + state.accountProvider.title + ), + subTitle = stringResource( + id = if (state.isAccountCreation) { + R.string.screen_account_provider_signup_subtitle + } else { + R.string.screen_account_provider_signin_subtitle + }, + ) + ) + }, + footer = { + ButtonColumnMolecule { + ButtonWithProgress( + text = stringResource(id = R.string.screen_account_provider_continue), + showProgress = isLoading, + onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) }, + enabled = state.submitEnabled, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.loginContinue) + ) + TextButton( + onClick = onChange, + enabled = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.loginChangeServer) + ) { + Text(text = stringResource(id = R.string.screen_account_provider_change)) + } + } + } + ) { + when (state.loginFlow) { + is Async.Failure -> { + when (val error = state.loginFlow.error) { + is ChangeServerError.Error -> { + ErrorDialog( + content = error.message(), + onDismiss = { + eventSink.invoke(ConfirmAccountProviderEvents.ClearError) + } + ) + } + is ChangeServerError.SlidingSyncAlert -> { + SlidingSyncNotSupportedDialog(onLearnMoreClicked = { + onLearnMoreClicked() + eventSink(ConfirmAccountProviderEvents.ClearError) + }, onDismiss = { + eventSink(ConfirmAccountProviderEvents.ClearError) + }) + } + } + } + is Async.Loading -> Unit // The Continue button shows the loading state + is Async.Success -> { + when (val loginFlowState = state.loginFlow.state) { + is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails) + LoginFlow.PasswordLogin -> onLoginPasswordNeeded() + } + } + Async.Uninitialized -> Unit + } + } +} + +@Preview +@Composable +fun ConfirmAccountProviderViewLightPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun ConfirmAccountProviderViewDarkPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: ConfirmAccountProviderState) { + ConfirmAccountProviderView( + state = state, + onOidcDetails = {}, + onLoginPasswordNeeded = {}, + onLearnMoreClicked = {}, + onChange = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordEvents.kt new file mode 100644 index 0000000000..e6f23ca418 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordEvents.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.features.login.impl.screens.loginpassword + +sealed interface LoginPasswordEvents { + data class SetLogin(val login: String) : LoginPasswordEvents + data class SetPassword(val password: String) : LoginPasswordEvents + object Submit : LoginPasswordEvents + object ClearError : LoginPasswordEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt new file mode 100644 index 0000000000..630b08570c --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.loginpassword + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.AppScope + +@ContributesNode(AppScope::class) +class LoginPasswordNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: LoginPasswordPresenter, +) : Node(buildContext, plugins = plugins) { + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + LoginPasswordView( + state = state, + modifier = modifier, + onBackPressed = ::navigateUp + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt new file mode 100644 index 0000000000..1fc4a10bbb --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.loginpassword + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class LoginPasswordPresenter @Inject constructor( + private val authenticationService: MatrixAuthenticationService, + private val accountProviderDataSource: AccountProviderDataSource, +) : Presenter { + + @Composable + override fun present(): LoginPasswordState { + val localCoroutineScope = rememberCoroutineScope() + val loginAction: MutableState> = remember { + mutableStateOf(Async.Uninitialized) + } + + val formState = rememberSaveable { + mutableStateOf(LoginFormState.Default) + } + val accountProvider by accountProviderDataSource.flow().collectAsState() + + fun handleEvents(event: LoginPasswordEvents) { + when (event) { + is LoginPasswordEvents.SetLogin -> updateFormState(formState) { + copy(login = event.login) + } + is LoginPasswordEvents.SetPassword -> updateFormState(formState) { + copy(password = event.password) + } + LoginPasswordEvents.Submit -> { + localCoroutineScope.submit(formState.value, loginAction) + } + LoginPasswordEvents.ClearError -> loginAction.value = Async.Uninitialized + } + } + + return LoginPasswordState( + accountProvider = accountProvider, + formState = formState.value, + loginAction = loginAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState>) = launch { + loggedInState.value = Async.Loading() + authenticationService.login(formState.login.trim(), formState.password) + .onSuccess { sessionId -> + loggedInState.value = Async.Success(sessionId) + } + .onFailure { failure -> + loggedInState.value = Async.Failure(failure) + } + } + + private fun updateFormState(formState: MutableState, updateLambda: LoginFormState.() -> LoginFormState) { + formState.value = updateLambda(formState.value) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt similarity index 50% rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootState.kt rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt index 45eafa744c..c8fa2f4ad3 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt @@ -14,36 +14,23 @@ * limitations under the License. */ -package io.element.android.features.login.impl.root +package io.element.android.features.login.impl.screens.loginpassword import android.os.Parcelable +import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.libraries.architecture.Async -import io.element.android.libraries.core.bool.orFalse -import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails -import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.parcelize.Parcelize -data class LoginRootState( - val homeserverUrl: String, - val homeserverDetails: Async, - val loggedInState: LoggedInState, +data class LoginPasswordState( + val accountProvider: AccountProvider, val formState: LoginFormState, - val eventSink: (LoginRootEvents) -> Unit + val loginAction: Async, + val eventSink: (LoginPasswordEvents) -> Unit ) { - val supportPasswordLogin = (homeserverDetails as? Async.Success)?.state?.supportsPasswordLogin.orFalse() - val supportOidcLogin = (homeserverDetails as? Async.Success)?.state?.supportsOidcLogin.orFalse() val submitEnabled: Boolean - get() = loggedInState !is LoggedInState.ErrorLoggingIn && - ((formState.login.isNotEmpty() && formState.password.isNotEmpty()) || supportOidcLogin) -} - -sealed interface LoggedInState { - object NotLoggedIn : LoggedInState - object LoggingIn : LoggedInState - data class OidcStarted(val oidcDetail: OidcDetails) : LoggedInState - data class ErrorLoggingIn(val failure: Throwable) : LoggedInState - data class LoggedIn(val sessionId: SessionId) : LoggedInState + get() = loginAction !is Async.Failure && + ((formState.login.isNotEmpty() && formState.password.isNotEmpty())) } @Parcelize diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStateProvider.kt new file mode 100644 index 0000000000..b4f5a84691 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStateProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.loginpassword + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.libraries.architecture.Async + +open class LoginPasswordStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLoginPasswordState(), + // Loading + aLoginPasswordState().copy(loginAction = Async.Loading()), + // Error + aLoginPasswordState().copy(loginAction = Async.Failure(Exception("An error occurred"))), + ) +} + +fun aLoginPasswordState() = LoginPasswordState( + accountProvider = anAccountProvider(), + formState = LoginFormState.Default, + loginAction = Async.Uninitialized, + eventSink = {} +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt similarity index 60% rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt index 2444418725..9154a0792f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -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. @@ -14,15 +14,11 @@ * limitations under the License. */ -package io.element.android.features.login.impl.root +package io.element.android.features.login.impl.screens.loginpassword -import androidx.compose.foundation.background -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.ExperimentalLayoutApi -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -30,31 +26,25 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.autofill.AutofillType -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource @@ -62,7 +52,6 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -70,8 +59,7 @@ import io.element.android.features.login.impl.R import io.element.android.features.login.impl.error.loginError import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.ElementTextStyles -import io.element.android.libraries.designsystem.components.async.AsyncFailure -import io.element.android.libraries.designsystem.components.async.AsyncLoading +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.ButtonWithProgress import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog @@ -86,23 +74,20 @@ import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.autofill import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext -import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.R as StringR @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable -fun LoginRootView( - state: LoginRootState, +fun LoginPasswordView( + state: LoginPasswordState, modifier: Modifier = Modifier, - onChangeServer: () -> Unit = {}, - onOidcDetails: (OidcDetails) -> Unit = {}, onBackPressed: () -> Unit, ) { - val isLoading by remember(state.loggedInState) { + val isLoading by remember(state.loginAction) { derivedStateOf { - state.loggedInState == LoggedInState.LoggingIn + state.loginAction is Async.Loading } } val focusManager = LocalFocusManager.current @@ -111,10 +96,11 @@ fun LoginRootView( // Clear focus to prevent keyboard issues with textfields focusManager.clearFocus(force = true) - state.eventSink(LoginRootEvents.Submit) + state.eventSink(LoginPasswordEvents.Submit) } Scaffold( + modifier = modifier, topBar = { TopAppBar( title = {}, @@ -123,7 +109,7 @@ fun LoginRootView( } ) { padding -> Box( - modifier = modifier + modifier = Modifier .fillMaxSize() .imePadding() .padding(padding) @@ -136,142 +122,48 @@ fun LoginRootView( .verticalScroll(state = scrollState) .padding(horizontal = 16.dp), ) { - Spacer(Modifier.height(16.dp)) // Title - Text( - text = stringResource(id = R.string.screen_login_title), + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 20.dp), + iconImageVector = Icons.Filled.AccountCircle, + title = stringResource( + id = R.string.screen_account_provider_signin_title, + state.accountProvider.title + ), + subTitle = stringResource(id = R.string.screen_login_form_header) + ) + Spacer(Modifier.height(32.dp)) + LoginForm(state = state, + isLoading = isLoading, + onSubmit = ::submit + ) + Spacer(Modifier.height(28.dp)) + // Submit + ButtonWithProgress( + text = stringResource(R.string.screen_login_submit), + showProgress = isLoading, + onClick = ::submit, + enabled = state.submitEnabled, modifier = Modifier - .fillMaxWidth(), - style = ElementTextStyles.Bold.title1, - color = MaterialTheme.colorScheme.primary, + .fillMaxWidth() + .testTag(TestTags.loginContinue) ) - Spacer(Modifier.height(32.dp)) - - ChangeServerSection( - interactionEnabled = !isLoading, - homeserver = state.homeserverUrl, - onChangeServer = onChangeServer - ) - - Spacer(Modifier.height(32.dp)) - - when (state.homeserverDetails) { - Async.Uninitialized, - is Async.Loading -> AsyncLoading() - is Async.Failure -> AsyncFailure( - throwable = state.homeserverDetails.error, - onRetry = { - state.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo) - } - ) - is Async.Success -> ServerDetailForm(state, isLoading, ::submit) - } + Spacer(modifier = Modifier.height(32.dp)) } - when (val loggedInState = state.loggedInState) { - is LoggedInState.OidcStarted -> onOidcDetails(loggedInState.oidcDetail) - else -> Unit + + if (state.loginAction is Async.Failure) { + LoginErrorDialog(error = state.loginAction.error, onDismiss = { + state.eventSink(LoginPasswordEvents.ClearError) + }) } } } - - if (state.loggedInState is LoggedInState.ErrorLoggingIn) { - LoginErrorDialog(error = state.loggedInState.failure, onDismiss = { - state.eventSink(LoginRootEvents.ClearError) - }) - } -} - -@Composable -fun ServerDetailForm( - state: LoginRootState, - isLoading: Boolean, - submit: () -> Unit, - modifier: Modifier = Modifier, -) { - when { - state.supportOidcLogin -> { - // Oidc, in this case, just display a Spacer and the submit button - Spacer(modifier.height(28.dp)) - } - state.supportPasswordLogin -> { - LoginForm(state = state, isLoading = isLoading, onSubmit = submit, modifier = modifier) - } - else -> { - Text(modifier = modifier, text = "No supported login flow") - } - } - - Spacer(Modifier.height(28.dp)) - - if (state.supportOidcLogin || state.supportPasswordLogin) { - // Submit - ButtonWithProgress( - text = stringResource(R.string.screen_login_submit), - showProgress = isLoading, - onClick = submit, - enabled = state.submitEnabled, - modifier = Modifier - .fillMaxWidth() - .testTag(TestTags.loginContinue) - ) - Spacer(modifier = Modifier.height(32.dp)) - } -} - -@Composable -internal fun ChangeServerSection( - interactionEnabled: Boolean, - homeserver: String, - onChangeServer: () -> Unit, - modifier: Modifier = Modifier -) { - Column(modifier) { - Text( - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp), - text = stringResource(id = R.string.screen_login_server_header), - style = ElementTextStyles.Regular.formHeader, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .testTag(TestTags.loginChangeServer) - .clickable { - if (interactionEnabled) { - onChangeServer() - } - }, - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = homeserver, - style = ElementTextStyles.Bold.body, - textAlign = TextAlign.Start, - modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp, vertical = 16.dp) - ) - IconButton( - modifier = Modifier.size(24.dp), - onClick = { - if (interactionEnabled) { - onChangeServer() - } - } - ) { - Icon(imageVector = Icons.Default.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary) - } - Spacer(Modifier.width(8.dp)) - } - } } @OptIn(ExperimentalComposeUiApi::class) @Composable internal fun LoginForm( - state: LoginRootState, + state: LoginPasswordState, isLoading: Boolean, onSubmit: () -> Unit, modifier: Modifier = Modifier @@ -299,14 +191,14 @@ internal fun LoginForm( .testTag(TestTags.loginEmailUsername) .autofill(autofillTypes = listOf(AutofillType.Username), onFill = { loginFieldState = it - eventSink(LoginRootEvents.SetLogin(it)) + eventSink(LoginPasswordEvents.SetLogin(it)) }), label = { Text(text = stringResource(R.string.screen_login_username_hint)) }, onValueChange = { loginFieldState = it - eventSink(LoginRootEvents.SetLogin(it)) + eventSink(LoginPasswordEvents.SetLogin(it)) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Email, @@ -316,7 +208,6 @@ internal fun LoginForm( focusManager.moveFocus(FocusDirection.Down) }), singleLine = true, - maxLines = 1, trailingIcon = if (loginFieldState.isNotEmpty()) { { IconButton(onClick = { @@ -329,7 +220,7 @@ internal fun LoginForm( ) var passwordVisible by remember { mutableStateOf(false) } - if (state.loggedInState is LoggedInState.LoggingIn) { + if (state.loginAction is Async.Loading) { // Ensure password is hidden when user submits the form passwordVisible = false } @@ -343,11 +234,11 @@ internal fun LoginForm( .testTag(TestTags.loginPassword) .autofill(autofillTypes = listOf(AutofillType.Password), onFill = { passwordFieldState = it - eventSink(LoginRootEvents.SetPassword(it)) + eventSink(LoginPasswordEvents.SetPassword(it)) }), onValueChange = { passwordFieldState = it - eventSink(LoginRootEvents.SetPassword(it)) + eventSink(LoginPasswordEvents.SetPassword(it)) }, label = { Text(text = stringResource(R.string.screen_login_password_hint)) @@ -371,7 +262,6 @@ internal fun LoginForm( onDone = { onSubmit() } ), singleLine = true, - maxLines = 1, ) } } @@ -386,17 +276,17 @@ internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) { @Preview @Composable -internal fun LoginRootScreenLightPreview(@PreviewParameter(LoginRootStateProvider::class) state: LoginRootState) = +internal fun LoginPasswordViewLightPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -internal fun LoginRootScreenDarkPreview(@PreviewParameter(LoginRootStateProvider::class) state: LoginRootState) = +internal fun LoginPasswordViewDarkPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) = ElementPreviewDark { ContentToPreview(state) } @Composable -private fun ContentToPreview(state: LoginRootState) { - LoginRootView( +private fun ContentToPreview(state: LoginPasswordState) { + LoginPasswordView( state = state, onBackPressed = {} ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt new file mode 100644 index 0000000000..53ee45f644 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.searchaccountprovider + +sealed interface SearchAccountProviderEvents { + /** + * The user has typed something, expect to get a list of matching account provider results + * in the state. + */ + data class UserInput(val input: String) : SearchAccountProviderEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt similarity index 67% rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootNode.kt rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt index 787f5d0b48..7178a105f6 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/root/LoginRootNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt @@ -14,10 +14,11 @@ * limitations under the License. */ -package io.element.android.features.login.impl.root +package io.element.android.features.login.impl.screens.searchaccountprovider import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin @@ -25,38 +26,34 @@ import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.impl.util.openLearnMorePage import io.element.android.libraries.di.AppScope -import io.element.android.libraries.matrix.api.auth.OidcDetails @ContributesNode(AppScope::class) -class LoginRootNode @AssistedInject constructor( +class SearchAccountProviderNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: LoginRootPresenter, + private val presenter: SearchAccountProviderPresenter, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { - fun onChangeHomeServer() - fun onOidcDetails(oidcDetails: OidcDetails) + fun onDone() } - private fun onChangeHomeServer() { - plugins().forEach { it.onChangeHomeServer() } - } - - private fun onOidcDetails(oidcDetails: OidcDetails) { - plugins().forEach { it.onOidcDetails(oidcDetails) } + private fun onDone() { + plugins().forEach { it.onDone() } } @Composable override fun View(modifier: Modifier) { val state = presenter.present() - LoginRootView( + val context = LocalContext.current + SearchAccountProviderView( state = state, modifier = modifier, - onChangeServer = ::onChangeHomeServer, - onOidcDetails = ::onOidcDetails, - onBackPressed = ::navigateUp + onBackPressed = ::navigateUp, + onLearnMoreClicked = { openLearnMorePage(context) }, + onDone = ::onDone, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt new file mode 100644 index 0000000000..1d8271e394 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.searchaccountprovider + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.element.android.features.login.impl.changeserver.ChangeServerPresenter +import io.element.android.features.login.impl.resolver.HomeserverData +import io.element.android.features.login.impl.resolver.HomeserverResolver +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +class SearchAccountProviderPresenter @Inject constructor( + private val homeserverResolver: HomeserverResolver, + private val changeServerPresenter: ChangeServerPresenter, +) : Presenter { + + @Composable + override fun present(): SearchAccountProviderState { + var userInput by rememberSaveable { + mutableStateOf("") + } + val changeServerState = changeServerPresenter.present() + + val data: MutableState>> = remember { + mutableStateOf(Async.Uninitialized) + } + + LaunchedEffect(userInput) { + onUserInput(userInput, data) + } + + fun handleEvents(event: SearchAccountProviderEvents) { + when (event) { + is SearchAccountProviderEvents.UserInput -> { + userInput = event.input + } + } + } + + return SearchAccountProviderState( + userInput = userInput, + userInputResult = data.value, + changeServerState = changeServerState, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.onUserInput(userInput: String, data: MutableState>>) = launch { + data.value = Async.Uninitialized + // Debounce + delay(300) + data.value = Async.Loading() + homeserverResolver.resolve(userInput).collect { + data.value = Async.Success(it) + } + if (data.value !is Async.Success) { + data.value = Async.Uninitialized + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt new file mode 100644 index 0000000000..15859afde1 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.searchaccountprovider + +import io.element.android.features.login.impl.changeserver.ChangeServerState +import io.element.android.features.login.impl.resolver.HomeserverData +import io.element.android.libraries.architecture.Async + +// Do not use default value, so no member get forgotten in the presenters. +data class SearchAccountProviderState( + val userInput: String, + val userInputResult: Async>, + val changeServerState: ChangeServerState, + val eventSink: (SearchAccountProviderEvents) -> Unit +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt new file mode 100644 index 0000000000..b6ffac8bd1 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.searchaccountprovider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.changeserver.aChangeServerState +import io.element.android.features.login.impl.resolver.HomeserverData +import io.element.android.libraries.architecture.Async + +open class SearchAccountProviderStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSearchAccountProviderState(), + aSearchAccountProviderState(userInputResult = Async.Success(aHomeserverDataList())), + // Add other state here + ) +} + +fun aSearchAccountProviderState( + userInput: String = "", + userInputResult: Async> = Async.Uninitialized, +) = SearchAccountProviderState( + userInput = userInput, + userInputResult = userInputResult, + changeServerState = aChangeServerState(), + eventSink = {} +) + +fun aHomeserverDataList(): List { + return listOf( + aHomeserverData(isWellknownValid = true, supportSlidingSync = true), + aHomeserverData(homeserverUrl = "https://no.sliding.sync", isWellknownValid = true, supportSlidingSync = false), + aHomeserverData(homeserverUrl = "https://invalid", isWellknownValid = false, supportSlidingSync = false), + ) +} + +fun aHomeserverData( + homeserverUrl: String = "https://matrix.org", + isWellknownValid: Boolean = true, + supportSlidingSync: Boolean = true, +): HomeserverData { + return HomeserverData( + homeserverUrl = homeserverUrl, + isWellknownValid = isWellknownValid, + supportSlidingSync = supportSlidingSync, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt new file mode 100644 index 0000000000..5c280cba0e --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) + +package io.element.android.features.login.impl.screens.searchaccountprovider + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.Scaffold +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.AccountProviderView +import io.element.android.features.login.impl.changeserver.ChangeServerEvents +import io.element.android.features.login.impl.changeserver.ChangeServerView +import io.element.android.features.login.impl.resolver.HomeserverData +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +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.OutlinedTextField +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.R as StringR + +/** + * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435 + */ +@Composable +fun SearchAccountProviderView( + state: SearchAccountProviderState, + onBackPressed: () -> Unit, + onLearnMoreClicked: () -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + val eventSink = state.eventSink + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackPressed) } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .imePadding() + .padding(padding) + .consumeWindowInsets(padding) + ) { + LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) { + item { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp), + iconImageVector = Icons.Filled.Search, + title = stringResource(id = R.string.screen_account_provider_form_title), + subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle), + ) + } + item { + // TextInput + var userInputState by textFieldState(stateValue = state.userInput) + val focusManager = LocalFocusManager.current + OutlinedTextField( + value = userInputState, + // readOnly = isLoading, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .onTabOrEnterKeyFocusNext(focusManager) + .testTag(TestTags.changeServerServer), + onValueChange = { + userInputState = it + eventSink(SearchAccountProviderEvents.UserInput(it)) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { + focusManager.moveFocus(FocusDirection.Down) + }), + singleLine = true, + trailingIcon = if (userInputState.isNotEmpty()) { + { + IconButton(onClick = { + userInputState = "" + eventSink(SearchAccountProviderEvents.UserInput("")) + }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(StringR.string.action_clear) + ) + } + } + } else null, + supportingText = { + Text(text = stringResource(id = R.string.screen_account_provider_form_notice), color = MaterialTheme.colorScheme.secondary) + } + ) + } + + when (state.userInputResult) { + is Async.Failure -> { + // Ignore errors (let the user type more chars) + } + is Async.Loading -> { + item { + Box( + modifier = Modifier + .fillMaxSize() + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + } + } + is Async.Success -> { + items(state.userInputResult.state) { homeserverData -> + val item = homeserverData.toAccountProvider() + AccountProviderView( + item = item, + onClick = { + state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(item)) + } + ) + } + } + Async.Uninitialized -> Unit + } + item { + Spacer(Modifier.height(32.dp)) + } + } + ChangeServerView( + state = state.changeServerState, + onLearnMoreClicked = onLearnMoreClicked, + onDone = onDone, + ) + } + } +} + +@Composable +private fun HomeserverData.toAccountProvider(): AccountProvider { + val isMatrixOrg = homeserverUrl == "https://matrix.org" + return AccountProvider( + title = homeserverUrl.removePrefix("http://").removePrefix("https://"), + subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null, + isPublic = isMatrixOrg, // There is no need to know for other servers right now + isMatrixOrg = isMatrixOrg, + isValid = isWellknownValid, + supportSlidingSync = supportSlidingSync, + ) +} + +@Preview +@Composable +fun SearchAccountProviderViewLightPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun SearchAccountProviderViewDarkPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: SearchAccountProviderState) { + SearchAccountProviderView( + state = state, + onBackPressed = {}, + onLearnMoreClicked = {}, + onDone = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt index cb01f8095a..e8bcea990e 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt @@ -16,8 +16,18 @@ package io.element.android.features.login.impl.util +import io.element.android.features.login.impl.accountprovider.AccountProvider + object LoginConstants { + const val MATRIX_ORG_URL = "matrix.org" const val DEFAULT_HOMESERVER_URL = "matrix.org" // TODO Oidc "synapse-oidc.lab.element.dev" const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md" } + +val defaultAccountProvider = AccountProvider( + title = LoginConstants.DEFAULT_HOMESERVER_URL, + subtitle = null, + isPublic = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL, + isMatrixOrg = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt new file mode 100644 index 0000000000..261b02c1b8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import io.element.android.libraries.core.data.tryOrNull + +fun openLearnMorePage(context: Context) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL)) + tryOrNull { context.startActivity(intent) } +} diff --git a/features/login/impl/src/main/res/drawable/ic_matrix.xml b/features/login/impl/src/main/res/drawable/ic_matrix.xml new file mode 100644 index 0000000000..dbc788a031 --- /dev/null +++ b/features/login/impl/src/main/res/drawable/ic_matrix.xml @@ -0,0 +1,12 @@ + + + + diff --git a/features/login/impl/src/main/res/drawable/ic_public.xml b/features/login/impl/src/main/res/drawable/ic_public.xml new file mode 100644 index 0000000000..fc1eacbc9f --- /dev/null +++ b/features/login/impl/src/main/res/drawable/ic_public.xml @@ -0,0 +1,12 @@ + + + + diff --git a/features/login/impl/src/main/res/values-cs/translations.xml b/features/login/impl/src/main/res/values-cs/translations.xml index 03ef5b6868..300d851693 100644 --- a/features/login/impl/src/main/res/values-cs/translations.xml +++ b/features/login/impl/src/main/res/values-cs/translations.xml @@ -1,5 +1,19 @@ + "Změna poskytovatele účtu" + "Pokračovat" + "Adresa domovského serveru" + "Zadejte hledaný výraz nebo adresu domény." + "Vyhledejte společnost, komunitu nebo soukromý server." + "Najít poskytovatele účtu" + "Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily." + "Chystáte se přihlásit do %s" + "Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily." + "Chystáte se vytvořit účet na %s" + "Matrix.org je otevřená síť pro bezpečnou, decentralizovanou komunikaci." + "Jiný" + "Použijte jiného poskytovatele účtu, například vlastní soukromý server nebo pracovní účet." + "Změnit poskytovatele účtu" "Nepodařilo se nám připojit k tomuto domovskému serveru. Zkontrolujte prosím, zda jste správně zadali adresu URL domovského serveru. Pokud je adresa URL správná, obraťte se na správce domovského serveru, který vám poskytne další pomoc." "Tento server v současné době nepodporuje klouzavou synchronizaci." "Adresa URL domovského serveru" @@ -13,9 +27,15 @@ "Kde budou vaše konverzace probíhat" "Vítejte zpět!" "Přihlaste se k %1$s" + "Změnit poskytovatele účtu" + "Soukromý server pro zaměstnance Elementu." + "Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci." + "Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily." + "Chystáte se přihlásit do služby %1$s" + "Chystáte se vytvořit účet na %1$s" "Pokračovat" "Vyberte svůj server" "Heslo" "Pokračovat" "Uživatelské jméno" - \ No newline at end of file + diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml index df6d1c38ab..d1393dbaf3 100644 --- a/features/login/impl/src/main/res/values-de/translations.xml +++ b/features/login/impl/src/main/res/values-de/translations.xml @@ -1,5 +1,18 @@ + "Kontoanbieter wechseln" + "Weiter" + "Adresse des Homeservers" + "Geben Sie einen Suchbegriff oder eine Domainadresse ein." + "Suche nach einem Unternehmen, einer Community oder einem privaten Server." + "Finde einen Accountanbieter" + "Du bist dabei dich bei %s anzumelden" + "Hier werden deine Konversationen stattfinden — genauso wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren." + "Du bist dabei einen Account auf %s zu erstellen" + "Matrix.org ist ein offenes Netzwerk für sichere, dezentralisierte Kommunikation." + "Andere" + "Verwende einen anderen Kontoanbieter, z. B. deinen eigenen privaten Server oder ein Arbeitskonto." + "Kontoanbieter ändern" "Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfen Sie, ob Sie die Homeserver-URL korrekt eingegeben haben. Wenn die URL korrekt ist, wenden Sie sich an Ihren Homeserver-Administrator, um weitere Hilfe zu erhalten." "Dieser Server unterstützt derzeit keine Sliding Sync." "Homeserver-URL" @@ -13,9 +26,15 @@ "Wo deine Gespräche leben" "Willkommen zurück!" "Bei %1$s anmelden" + "Kontoanbieter wechseln" + "Ein privater Server für Element-Mitarbeiter." + "Matrix ist ein offenes Netzwerk für sichere, dezentrale Kommunikation" + "Hier werden deine Konversationen stattfinden — genau so wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren." + "Du bist dabei dich bei %1$s anzumelden" + "Du bist dabei einen Account auf %1$s zu erstellen" "Weiter" "Wählen deinen Server" "Passwort" "Weiter" "Benutzername" - \ No newline at end of file + diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml index 284527c2f5..0e55589556 100644 --- a/features/login/impl/src/main/res/values-es/translations.xml +++ b/features/login/impl/src/main/res/values-es/translations.xml @@ -17,4 +17,4 @@ "Contraseña" "Continuar" "Usuario" - \ No newline at end of file + diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml index 9d8f50e979..d56836e360 100644 --- a/features/login/impl/src/main/res/values-fr/translations.xml +++ b/features/login/impl/src/main/res/values-fr/translations.xml @@ -17,4 +17,4 @@ "Mot de passe" "Continuer" "Nom d\'utilisateur" - \ No newline at end of file + diff --git a/features/login/impl/src/main/res/values-it/translations.xml b/features/login/impl/src/main/res/values-it/translations.xml index b11875a18e..feb74db373 100644 --- a/features/login/impl/src/main/res/values-it/translations.xml +++ b/features/login/impl/src/main/res/values-it/translations.xml @@ -17,4 +17,4 @@ "Password" "Continua" "Nome utente" - \ No newline at end of file + diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml index 349e3ddc04..7d0c25b97e 100644 --- a/features/login/impl/src/main/res/values-ro/translations.xml +++ b/features/login/impl/src/main/res/values-ro/translations.xml @@ -1,5 +1,18 @@ + "Schimbați furnizorul contului" + "Continuați" + "Adresa Homeserver-ului" + "Introduceţi un termen de căutare sau o adresă de domeniu." + "Căutați o companie, o comunitate sau un server privat." + "Găsiți un furnizor de cont" + "Sunteți pe cale să vă conectați la %s" + "Aici vor trăi conversațiile - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile." + "Sunteți pe cale să creați un cont pe %s" + "Matrix.org este o rețea deschisă pentru o comunicare sigură și descentralizată." + "Altul" + "Utilizați un alt furnizor de cont, cum ar fi propriul server privat sau un cont de serviciu." + "Schimbați furnizorul contului" "Nu am putut accesa acest homeserver. Te rugăm să verifici că ai introdus corect adresa URL a homeserver-ului. Dacă adresa URL este corectă, contactează administratorul homeserver-ului pentru ajutor suplimentar." "Momentan acest server nu oferă suport pentru sliding sync." "Adresa URL a homeserver-ului" @@ -12,9 +25,16 @@ "Introduceți detaliile" "Locul unde trăiesc conversațiile tale" "Bine ați revenit!" + "Conectați-vă la %1$s" + "Schimbați furnizorul contului" + "Un server privat pentru angajații Element." + "Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată." + "Aici vor trăi conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile." + "Sunteți pe cale să vă conectați la %1$s" + "Sunteți pe cale să creați un cont pe %1$s" "Continuați" "Selectați serverul" "Parola" "Continuați" "Utilizator" - \ No newline at end of file + diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index cf59844e89..145ac2d238 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -1,5 +1,19 @@ + "Change account provider" + "Continue" + "Homeserver address" + "Enter a search term or a domain address." + "Search for a company, community, or private server." + "Find an account provider" + "This is where you conversations will live — just like you would use an email provider to keep your emails." + "You’re about to sign in to %s" + "This is where you conversations will live — just like you would use an email provider to keep your emails." + "You’re about to create an account on %s" + "Matrix.org is an open network for secure, decentralized communication." + "Other" + "Use a different account provider, such as your own private server or a work account." + "Change account provider" "We couldn\'t reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help." "This server currently doesn’t support sliding sync." "Homeserver URL" @@ -13,6 +27,12 @@ "Where your conversations live" "Welcome back!" "Sign in to %1$s" + "Change account provider" + "A private server for Element employees." + "Matrix is an open network for secure, decentralised communication." + "This is where your conversations will live — just like you would use an email provider to keep your emails." + "You’re about to sign in to %1$s" + "You’re about to create an account on %1$s" "Continue" "Select your server" "Password" 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 a30bd9449c..9aefafb382 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 @@ -20,147 +20,72 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.login.impl.util.LoginConstants +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.test.A_HOMESERVER -import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2 -import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService import kotlinx.coroutines.test.runTest import org.junit.Test class ChangeServerPresenterTest { @Test - fun `present - should start with default homeserver`() = runTest { + fun `present - initial state`() = runTest { val presenter = ChangeServerPresenter( FakeAuthenticationService(), + AccountProviderDataSource() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.homeserver).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL) - assertThat(initialState.submitEnabled).isTrue() + assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized) } } @Test - fun `present - authentication service can provide a homeserver`() = runTest { - val presenter = ChangeServerPresenter( - FakeAuthenticationService().apply { - givenHomeserver(A_HOMESERVER.copy(url = A_HOMESERVER_URL_2)) - }, - ) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER_URL_2) - assertThat(initialState.submitEnabled).isTrue() - } - } - - @Test - fun `present - disable if empty or not correct`() = runTest { - val presenter = ChangeServerPresenter( - FakeAuthenticationService(), - ) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - initialState.eventSink.invoke(ChangeServerEvents.SetServer("")) - val emptyState = awaitItem() - assertThat(emptyState.homeserver).isEqualTo("") - assertThat(emptyState.submitEnabled).isFalse() - } - } - - @Test - fun `present - submit`() = runTest { - val presenter = ChangeServerPresenter( - FakeAuthenticationService(), - ) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - initialState.eventSink.invoke(ChangeServerEvents.Submit) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java) - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java) - } - } - - @Test - fun `present - submit parses URL`() = runTest { - val presenter = ChangeServerPresenter( - FakeAuthenticationService(), - ) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val longUrl = "https://matrix.org/.well-known/" - val initialState = awaitItem() - initialState.eventSink.invoke(ChangeServerEvents.SetServer(longUrl)) - awaitItem() - initialState.eventSink.invoke(ChangeServerEvents.Submit) - val loadingState = awaitItem() - assertThat(loadingState.submitEnabled).isTrue() - assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java) - awaitItem() // Skip changing the url to the parsed domain - val successState = awaitItem() - assertThat(successState.submitEnabled).isFalse() - assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java) - assertThat(successState.homeserver).isEqualTo("matrix.org") - } - } - - @Test - fun `present - submit fails`() = runTest { - val authServer = FakeAuthenticationService() - val presenter = ChangeServerPresenter(authServer) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - authServer.givenChangeServerError(Throwable()) - initialState.eventSink.invoke(ChangeServerEvents.Submit) - skipItems(1) // Loading - val failureState = awaitItem() - assertThat(failureState.submitEnabled).isFalse() - assertThat(failureState.changeServerAction).isInstanceOf(Async.Failure::class.java) - } - } - - @Test - fun `present - clear error`() = runTest { + fun `present - change server ok`() = runTest { val authenticationService = FakeAuthenticationService() val presenter = ChangeServerPresenter( authenticationService, + AccountProviderDataSource() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { val initialState = awaitItem() + assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized) + authenticationService.givenHomeserver(A_HOMESERVER) + initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(A_HOMESERVER_URL))) + val loadingState = awaitItem() + assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.changeServerAction).isEqualTo(Async.Success(Unit)) + } + } - // Submit will return an error - authenticationService.givenChangeServerError(A_THROWABLE) - initialState.eventSink(ChangeServerEvents.Submit) - - skipItems(1) // Loading - - // Check an error was returned - val submittedState = awaitItem() - assertThat(submittedState.changeServerAction).isInstanceOf(Async.Failure::class.java) - - // Assert the error is then cleared - submittedState.eventSink(ChangeServerEvents.ClearError) - val clearedState = awaitItem() - assertThat(clearedState.changeServerAction).isEqualTo(Async.Uninitialized) + @Test + fun `present - change server error`() = runTest { + val authenticationService = FakeAuthenticationService() + val presenter = ChangeServerPresenter( + authenticationService, + AccountProviderDataSource() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized) + initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(A_HOMESERVER_URL))) + val loadingState = awaitItem() + assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java) + val failureState = awaitItem() + assertThat(failureState.changeServerAction).isInstanceOf(Async.Failure::class.java) + // Clear error + failureState.eventSink.invoke(ChangeServerEvents.ClearError) + val finalState = awaitItem() + assertThat(finalState.changeServerAction).isEqualTo(Async.Uninitialized) } } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/network/FakeWellknownRequest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/network/FakeWellknownRequest.kt new file mode 100644 index 0000000000..58c2bf82a3 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/network/FakeWellknownRequest.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.resolver.network + +class FakeWellknownRequest : WellknownRequest { + private var resultMap: Map = emptyMap() + fun givenResultMap(map: Map) { + resultMap = map + } + + override suspend fun execute(baseUrl: String): WellKnown { + return resultMap[baseUrl] ?: error("No result provided for $baseUrl") + } +} 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 deleted file mode 100644 index 0dee8d47c0..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/root/LoginRootPresenterTest.kt +++ /dev/null @@ -1,308 +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.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.api.oidc.OidcAction -import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow -import io.element.android.features.login.impl.util.LoginConstants -import io.element.android.libraries.architecture.Async -import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails -import io.element.android.libraries.matrix.test.A_HOMESERVER -import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC -import io.element.android.libraries.matrix.test.A_PASSWORD -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.A_THROWABLE -import io.element.android.libraries.matrix.test.A_USER_NAME -import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class LoginRootPresenterTest { - @Test - fun `present - initial state`() = runTest { - val presenter = LoginRootPresenter( - FakeAuthenticationService(), - DefaultOidcActionFlow(), - ) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL) - assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized) - assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn) - assertThat(initialState.formState).isEqualTo(LoginFormState.Default) - assertThat(initialState.submitEnabled).isFalse() - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - initial state server load`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL) - assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized) - assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn) - assertThat(initialState.formState).isEqualTo(LoginFormState.Default) - assertThat(initialState.submitEnabled).isFalse() - val loadingState = awaitItem() - assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading()) - authenticationService.givenHomeserver(A_HOMESERVER) - skipItems(1) - val loadedState = awaitItem() - assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER)) - } - } - - @Test - fun `present - initial state server load error and retry`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL) - assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized) - assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn) - assertThat(initialState.formState).isEqualTo(LoginFormState.Default) - assertThat(initialState.submitEnabled).isFalse() - val loadingState = awaitItem() - assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading()) - val aThrowable = Throwable("Error") - authenticationService.givenChangeServerError(aThrowable) - val errorState = awaitItem() - assertThat(errorState.homeserverDetails).isEqualTo(Async.Failure(aThrowable)) - // Retry - errorState.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo) - val loadingState2 = awaitItem() - assertThat(loadingState2.homeserverDetails).isEqualTo(Async.Loading()) - authenticationService.givenChangeServerError(null) - authenticationService.givenHomeserver(A_HOMESERVER) - skipItems(1) - val loadedState = awaitItem() - assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER)) - } - } - - @Test - fun `present - enter login and password`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME)) - val loginState = awaitItem() - assertThat(loginState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = "")) - assertThat(loginState.submitEnabled).isFalse() - initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) - val loginAndPasswordState = awaitItem() - assertThat(loginAndPasswordState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = A_PASSWORD)) - assertThat(loginAndPasswordState.submitEnabled).isTrue() - } - } - - @Test - fun `present - oidc login`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER_OIDC) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.submitEnabled).isTrue() - initialState.eventSink.invoke(LoginRootEvents.Submit) - val oidcState = awaitItem() - assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.OidcStarted(A_OIDC_DATA)) - } - } - - @Test - fun `present - oidc login error`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER_OIDC) - authenticationService.givenOidcError(A_THROWABLE) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.submitEnabled).isTrue() - initialState.eventSink.invoke(LoginRootEvents.Submit) - val oidcState = awaitItem() - assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE)) - } - } - - @Test - fun `present - oidc custom tab login`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER_OIDC) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.submitEnabled).isTrue() - initialState.eventSink.invoke(LoginRootEvents.Submit) - val oidcState = awaitItem() - assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.OidcStarted(A_OIDC_DATA)) - // Oidc cancel, sdk error - authenticationService.givenOidcCancelError(A_THROWABLE) - oidcActionFlow.post(OidcAction.GoBack) - val stateCancelSdkError = awaitItem() - assertThat(stateCancelSdkError.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE)) - // Oidc cancel, sdk OK - authenticationService.givenOidcCancelError(null) - oidcActionFlow.post(OidcAction.GoBack) - val stateCancel = awaitItem() - assertThat(stateCancel.loggedInState).isEqualTo(LoggedInState.NotLoggedIn) - // Oidc success, sdk error - authenticationService.givenLoginError(A_THROWABLE) - oidcActionFlow.post(OidcAction.Success(A_OIDC_DATA.url)) - val stateSuccessSdkErrorLoading = awaitItem() - assertThat(stateSuccessSdkErrorLoading.loggedInState).isEqualTo(LoggedInState.LoggingIn) - val stateSuccessSdkError = awaitItem() - assertThat(stateSuccessSdkError.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE)) - // Oidc success - authenticationService.givenLoginError(null) - oidcActionFlow.post(OidcAction.Success(A_OIDC_DATA.url)) - val stateSuccess = awaitItem() - assertThat(stateSuccess.loggedInState).isEqualTo(LoggedInState.LoggingIn) - val stateSuccessLoggedIn = awaitItem() - assertThat(stateSuccessLoggedIn.loggedInState).isEqualTo(LoggedInState.LoggedIn(A_SESSION_ID)) - } - } - - @Test - fun `present - submit`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME)) - initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) - skipItems(1) - val loginAndPasswordState = awaitItem() - loginAndPasswordState.eventSink.invoke(LoginRootEvents.Submit) - val submitState = awaitItem() - assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn) - val loggedInState = awaitItem() - assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.LoggedIn(A_SESSION_ID)) - } - } - - @Test - fun `present - submit with error`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME)) - initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) - skipItems(1) - val loginAndPasswordState = awaitItem() - authenticationService.givenLoginError(A_THROWABLE) - loginAndPasswordState.eventSink.invoke(LoginRootEvents.Submit) - val submitState = awaitItem() - assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn) - val loggedInState = awaitItem() - assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE)) - } - } - - @Test - fun `present - clear error`() = runTest { - val authenticationService = FakeAuthenticationService() - val oidcActionFlow = DefaultOidcActionFlow() - val presenter = LoginRootPresenter( - authenticationService, - oidcActionFlow, - ) - authenticationService.givenHomeserver(A_HOMESERVER) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - // Submit will return an error - authenticationService.givenLoginError(A_THROWABLE) - initialState.eventSink(LoginRootEvents.Submit) - awaitItem() // Skip LoggingIn state - - // Check an error was returned - val submittedState = awaitItem() - assertThat(submittedState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE)) - - // Assert the error is then cleared - submittedState.eventSink(LoginRootEvents.ClearError) - val clearedState = awaitItem() - assertThat(clearedState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn) - } - } -} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt new file mode 100644 index 0000000000..086428257a --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.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.features.login.impl.screens.changeaccountprovider + +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.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.changeserver.ChangeServerPresenter +import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ChangeAccountProviderPresenterTest { + @Test + fun `present - initial state`() = runTest { + val changeServerPresenter = ChangeServerPresenter( + FakeAuthenticationService(), + AccountProviderDataSource() + ) + val presenter = ChangeAccountProviderPresenter( + changeServerPresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.accountProviders).isEqualTo( + listOf( + AccountProvider( + title = "matrix.org", + subtitle = null, + isPublic = true, + isMatrixOrg = true, + isValid = true, + supportSlidingSync = true, + ) + ) + ) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt new file mode 100644 index 0000000000..131d0d9298 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.confirmaccountprovider + +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.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.util.defaultAccountProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.test.A_HOMESERVER +import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ConfirmAccountProviderPresenterTest { + @Test + fun `present - initial test`() = runTest { + val presenter = ConfirmAccountProviderPresenter( + ConfirmAccountProviderPresenter.Params(isAccountCreation = false), + AccountProviderDataSource(), + FakeAuthenticationService(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isAccountCreation).isFalse() + assertThat(initialState.submitEnabled).isTrue() + assertThat(initialState.accountProvider).isEqualTo(defaultAccountProvider) + assertThat(initialState.loginFlow).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - continue password login`() = runTest { + val authServer = FakeAuthenticationService() + val presenter = ConfirmAccountProviderPresenter( + ConfirmAccountProviderPresenter.Params(isAccountCreation = false), + AccountProviderDataSource(), + authServer, + ) + authServer.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginFlow).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginFlow).isInstanceOf(Async.Success::class.java) + assertThat(successState.loginFlow.dataOrNull()).isEqualTo(LoginFlow.PasswordLogin) + } + } + + @Test + fun `present - continue oidc`() = runTest { + val authServer = FakeAuthenticationService() + val presenter = ConfirmAccountProviderPresenter( + ConfirmAccountProviderPresenter.Params(isAccountCreation = false), + AccountProviderDataSource(), + authServer, + ) + authServer.givenHomeserver(A_HOMESERVER_OIDC) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginFlow).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginFlow).isInstanceOf(Async.Success::class.java) + assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java) + } + } + + @Test + fun `present - submit fails`() = runTest { + val authServer = FakeAuthenticationService() + val presenter = ConfirmAccountProviderPresenter( + ConfirmAccountProviderPresenter.Params(isAccountCreation = false), + AccountProviderDataSource(), + authServer, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + authServer.givenChangeServerError(Throwable()) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + skipItems(1) // Loading + val failureState = awaitItem() + assertThat(failureState.submitEnabled).isFalse() + assertThat(failureState.loginFlow).isInstanceOf(Async.Failure::class.java) + } + } + + @Test + fun `present - clear error`() = runTest { + val authenticationService = FakeAuthenticationService() + val presenter = ConfirmAccountProviderPresenter( + ConfirmAccountProviderPresenter.Params(isAccountCreation = false), + AccountProviderDataSource(), + authenticationService, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + // Submit will return an error + authenticationService.givenChangeServerError(A_THROWABLE) + initialState.eventSink(ConfirmAccountProviderEvents.Continue) + + skipItems(1) // Loading + + // Check an error was returned + val submittedState = awaitItem() + assertThat(submittedState.loginFlow).isInstanceOf(Async.Failure::class.java) + + // Assert the error is then cleared + submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) + val clearedState = awaitItem() + assertThat(clearedState.loginFlow).isEqualTo(Async.Uninitialized) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt new file mode 100644 index 0000000000..c4c8a97155 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.loginpassword + +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.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.util.defaultAccountProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.SessionId +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.test.runTest +import org.junit.Test + +class LoginPasswordPresenterTest { + @Test + fun `present - initial state`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.accountProvider).isEqualTo(defaultAccountProvider) + assertThat(initialState.formState).isEqualTo(LoginFormState.Default) + assertThat(initialState.loginAction).isEqualTo(Async.Uninitialized) + assertThat(initialState.submitEnabled).isFalse() + } + } + + @Test + fun `present - enter login and password`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + ) + authenticationService.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + val loginState = awaitItem() + assertThat(loginState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = "")) + assertThat(loginState.submitEnabled).isFalse() + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + val loginAndPasswordState = awaitItem() + assertThat(loginAndPasswordState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = A_PASSWORD)) + assertThat(loginAndPasswordState.submitEnabled).isTrue() + } + } + + @Test + fun `present - submit`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + ) + authenticationService.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) + val loggedInState = awaitItem() + assertThat(loggedInState.loginAction).isEqualTo(Async.Success(A_SESSION_ID)) + } + } + + @Test + fun `present - submit with error`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + ) + authenticationService.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + authenticationService.givenLoginError(A_THROWABLE) + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) + val loggedInState = awaitItem() + assertThat(loggedInState.loginAction).isEqualTo(Async.Failure(A_THROWABLE)) + } + } + + @Test + fun `present - clear error`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + ) + authenticationService.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + authenticationService.givenLoginError(A_THROWABLE) + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) + val loggedInState = awaitItem() + // Check an error was returned + assertThat(loggedInState.loginAction).isEqualTo(Async.Failure(A_THROWABLE)) + // Assert the error is then cleared + loggedInState.eventSink(LoginPasswordEvents.ClearError) + val clearedState = awaitItem() + assertThat(clearedState.loginAction).isEqualTo(Async.Uninitialized) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt new file mode 100644 index 0000000000..9163f247f5 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.searchaccountprovider + +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.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.changeserver.ChangeServerPresenter +import io.element.android.features.login.impl.resolver.HomeserverResolver +import io.element.android.features.login.impl.resolver.network.FakeWellknownRequest +import io.element.android.features.login.impl.resolver.network.WellKnown +import io.element.android.features.login.impl.resolver.network.WellKnownBaseConfig +import io.element.android.features.login.impl.resolver.network.WellKnownSlidingSyncConfig +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SearchAccountProviderPresenterTest { + @Test + fun `present - initial state`() = runTest { + val fakeWellknownRequest = FakeWellknownRequest() + val changeServerPresenter = ChangeServerPresenter( + FakeAuthenticationService(), + AccountProviderDataSource() + ) + val presenter = SearchAccountProviderPresenter( + HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), + changeServerPresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.userInput).isEmpty() + assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - enter text no result`() = runTest { + val fakeWellknownRequest = FakeWellknownRequest() + val changeServerPresenter = ChangeServerPresenter( + FakeAuthenticationService(), + AccountProviderDataSource() + ) + val presenter = SearchAccountProviderPresenter( + HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), + changeServerPresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("test") + assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - enter valid url no wellknown`() = runTest { + val fakeWellknownRequest = FakeWellknownRequest() + val changeServerPresenter = ChangeServerPresenter( + FakeAuthenticationService(), + AccountProviderDataSource() + ) + val presenter = SearchAccountProviderPresenter( + HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), + changeServerPresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("https://test.org")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("https://test.org") + assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo( + Async.Success( + listOf( + aHomeserverData(homeserverUrl = "https://test.org", isWellknownValid = false, supportSlidingSync = false) + ) + ) + ) + } + } + + @Test + fun `present - enter text one result no sliding sync`() = runTest { + val fakeWellknownRequest = FakeWellknownRequest() + fakeWellknownRequest.givenResultMap( + mapOf( + "https://test.org" to aWellKnown().copy(slidingSyncProxy = null), + ) + ) + val changeServerPresenter = ChangeServerPresenter( + FakeAuthenticationService(), + AccountProviderDataSource() + ) + val presenter = SearchAccountProviderPresenter( + HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), + changeServerPresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("test") + assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo( + Async.Success( + listOf( + aHomeserverData(homeserverUrl = "https://test.org", isWellknownValid = true, supportSlidingSync = false) + ) + ) + ) + } + } + + @Test + fun `present - enter text one result with sliding sync`() = runTest { + val fakeWellknownRequest = FakeWellknownRequest() + fakeWellknownRequest.givenResultMap( + mapOf( + "https://test.io" to aWellKnown(), + ) + ) + val changeServerPresenter = ChangeServerPresenter( + FakeAuthenticationService(), + AccountProviderDataSource() + ) + val presenter = SearchAccountProviderPresenter( + HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), + changeServerPresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("test") + assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo( + Async.Success( + listOf( + aHomeserverData(homeserverUrl = "https://test.io") + ) + ) + ) + } + } + + private fun aWellKnown(): WellKnown { + return WellKnown( + homeServer = WellKnownBaseConfig( + baseURL = A_HOMESERVER_URL + ), + identityServer = WellKnownBaseConfig( + baseURL = A_HOMESERVER_URL + ), + slidingSyncProxy = WellKnownSlidingSyncConfig( + url = A_HOMESERVER_URL + ) + ) + } +} diff --git a/features/logout/api/src/main/res/values-cs/translations.xml b/features/logout/api/src/main/res/values-cs/translations.xml index 31761ee2d9..20be439d90 100644 --- a/features/logout/api/src/main/res/values-cs/translations.xml +++ b/features/logout/api/src/main/res/values-cs/translations.xml @@ -5,4 +5,4 @@ "Odhlašování…" "Odhlásit se" "Odhlásit se" - \ 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 5b9fac1031..0cd8ac389a 100644 --- a/features/logout/api/src/main/res/values-de/translations.xml +++ b/features/logout/api/src/main/res/values-de/translations.xml @@ -5,4 +5,4 @@ "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 8028039235..5ac0656935 100644 --- a/features/logout/api/src/main/res/values-es/translations.xml +++ b/features/logout/api/src/main/res/values-es/translations.xml @@ -5,4 +5,4 @@ "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-fr/translations.xml b/features/logout/api/src/main/res/values-fr/translations.xml index 9d9ad724df..b6d5137072 100644 --- a/features/logout/api/src/main/res/values-fr/translations.xml +++ b/features/logout/api/src/main/res/values-fr/translations.xml @@ -5,4 +5,4 @@ "Déconnexion en cours…" "Se déconnecter" "Se déconnecter" - \ 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 351a5e208b..4e8217a7f2 100644 --- a/features/logout/api/src/main/res/values-it/translations.xml +++ b/features/logout/api/src/main/res/values-it/translations.xml @@ -5,4 +5,4 @@ "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 bb1e36b426..4b2c7fbe7b 100644 --- a/features/logout/api/src/main/res/values-ro/translations.xml +++ b/features/logout/api/src/main/res/values-ro/translations.xml @@ -5,4 +5,4 @@ "Deconectare în curs…" "Deconectați-vă" "Deconectați-vă" - \ No newline at end of file + diff --git a/features/logout/api/src/main/res/values/localazy.xml b/features/logout/api/src/main/res/values/localazy.xml index 514c002567..9ea4bb77fd 100644 --- a/features/logout/api/src/main/res/values/localazy.xml +++ b/features/logout/api/src/main/res/values/localazy.xml @@ -5,4 +5,4 @@ "Signing out…" "Sign out" "Sign out" - \ No newline at end of file + diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt index 7a3556389f..bed33006d6 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 @@ -23,7 +23,6 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.logout.api.LogoutPreferenceEvents import io.element.android.features.logout.api.LogoutPreferenceState 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.test.runTest @@ -33,7 +32,7 @@ class LogoutPreferencePresenterTest { @Test fun `present - initial state`() = runTest { val presenter = DefaultLogoutPreferencePresenter( - FakeMatrixClient(A_SESSION_ID), + FakeMatrixClient(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -46,7 +45,7 @@ class LogoutPreferencePresenterTest { @Test fun `present - logout`() = runTest { val presenter = DefaultLogoutPreferencePresenter( - FakeMatrixClient(A_SESSION_ID), + FakeMatrixClient(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -62,7 +61,7 @@ class LogoutPreferencePresenterTest { @Test fun `present - logout with error`() = runTest { - val matrixClient = FakeMatrixClient(A_SESSION_ID) + val matrixClient = FakeMatrixClient() val presenter = DefaultLogoutPreferencePresenter( matrixClient, ) diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt index f1ed5c18dd..482dfad8ea 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt @@ -20,6 +20,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId interface MessagesEntryPoint : FeatureEntryPoint { @@ -32,5 +33,6 @@ interface MessagesEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onRoomDetailsClicked() fun onUserDataClicked(userId: UserId) + fun onForwardedToSingleRoom(roomId: RoomId) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt index 4dbf6d42d9..2d8af99fcd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt @@ -23,4 +23,5 @@ import io.element.android.libraries.matrix.api.core.EventId sealed interface MessagesEvents { data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents data class SendReaction(val emoji: String, val eventId: EventId) : MessagesEvents + object Dismiss : MessagesEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index ec4d2f31d3..901716451f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -32,17 +32,21 @@ import io.element.android.anvilannotations.ContributesNode import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode +import io.element.android.features.messages.impl.forward.ForwardMessagesNode import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.features.messages.impl.media.viewer.MediaViewerNode +import io.element.android.features.messages.impl.report.ReportMessageNode import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.RoomScope 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 import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo @@ -78,6 +82,12 @@ class MessagesFlowNode @AssistedInject constructor( @Parcelize data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget + + @Parcelize + data class ForwardEvent(val eventId: EventId) : NavTarget + + @Parcelize + data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget } private val callback = plugins().firstOrNull() @@ -105,6 +115,14 @@ class MessagesFlowNode @AssistedInject constructor( override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) { backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) } + + override fun onForwardEventClicked(eventId: EventId) { + backstack.push(NavTarget.ForwardEvent(eventId)) + } + + override fun onReportMessage(eventId: EventId, senderId: UserId) { + backstack.push(NavTarget.ReportMessage(eventId, senderId)) + } } createNode(buildContext, listOf(callback)) } @@ -124,6 +142,19 @@ class MessagesFlowNode @AssistedInject constructor( val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo) createNode(buildContext, listOf(inputs)) } + is NavTarget.ForwardEvent -> { + val inputs = ForwardMessagesNode.Inputs(navTarget.eventId) + val callback = object : ForwardMessagesNode.Callback { + override fun onForwardedToSingleRoom(roomId: RoomId) { + this@MessagesFlowNode.callback?.onForwardedToSingleRoom(roomId) + } + } + createNode(buildContext, listOf(inputs, callback)) + } + is NavTarget.ReportMessage -> { + val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId) + createNode(buildContext, listOf(inputs)) + } } } @@ -138,7 +169,7 @@ class MessagesFlowNode @AssistedInject constructor( fileExtension = event.content.fileExtension ), mediaSource = event.content.mediaSource, - thumbnailSource = event.content.mediaSource, + thumbnailSource = event.content.thumbnailSource, ) backstack.push(navTarget) } @@ -179,6 +210,7 @@ class MessagesFlowNode @AssistedInject constructor( Children( navModel = backstack, modifier = modifier, + transitionHandler = rememberDefaultTransitionHandler(), ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt new file mode 100644 index 0000000000..201173a0bf --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo + +interface MessagesNavigator { + fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) + fun onForwardEventClicked(eventId: EventId) + fun onReportContentClicked(eventId: EventId, senderId: UserId) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 58d73a10f7..651ae8670b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -37,9 +37,10 @@ import kotlinx.collections.immutable.ImmutableList class MessagesNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: MessagesPresenter, -) : Node(buildContext, plugins = plugins) { + private val presenterFactory: MessagesPresenter.Factory, +) : Node(buildContext, plugins = plugins), MessagesNavigator { + private val presenter = presenterFactory.create(this) private val callback = plugins().firstOrNull() interface Callback : Plugin { @@ -48,6 +49,8 @@ class MessagesNode @AssistedInject constructor( fun onPreviewAttachments(attachments: ImmutableList) fun onUserDataClicked(userId: UserId) fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) + fun onForwardEventClicked(eventId: EventId) + fun onReportMessage(eventId: EventId, senderId: UserId) } private fun onRoomDetailsClicked() { @@ -65,11 +68,18 @@ class MessagesNode @AssistedInject constructor( private fun onUserDataClicked(userId: UserId) { callback?.onUserDataClicked(userId) } - - private fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) { + override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) { callback?.onShowEventDebugInfoClicked(eventId, debugInfo) } + override fun onForwardEventClicked(eventId: EventId) { + callback?.onForwardEventClicked(eventId) + } + + override fun onReportContentClicked(eventId: EventId, senderId: UserId) { + callback?.onReportMessage(eventId, senderId) + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() @@ -80,7 +90,6 @@ class MessagesNode @AssistedInject constructor( onEventClicked = this::onEventClicked, onPreviewAttachments = this::onPreviewAttachments, onUserDataClicked = this::onUserDataClicked, - onItemDebugInfoClicked = this::onShowEventDebugInfoClicked, modifier = modifier, ) } 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 de2916a6b4..e305b916cd 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 @@ -25,6 +25,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents @@ -32,6 +36,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent @@ -52,33 +58,46 @@ import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.handleSnackbarMessage import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.matrix.ui.room.canSendEventAsState import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -class MessagesPresenter @Inject constructor( +class MessagesPresenter @AssistedInject constructor( private val room: MatrixRoom, private val composerPresenter: MessageComposerPresenter, private val timelinePresenter: TimelinePresenter, private val actionListPresenter: ActionListPresenter, + private val customReactionPresenter: CustomReactionPresenter, + private val retrySendMenuPresenter: RetrySendMenuPresenter, private val networkMonitor: NetworkMonitor, private val snackbarDispatcher: SnackbarDispatcher, private val messageSummaryFormatter: MessageSummaryFormatter, private val dispatchers: CoroutineDispatchers, + @Assisted private val navigator: MessagesNavigator, ) : Presenter { + @AssistedFactory + interface Factory { + fun create(navigator: MessagesNavigator): MessagesPresenter + } + @Composable override fun present(): MessagesState { val localCoroutineScope = rememberCoroutineScope() val composerState = composerPresenter.present() val timelineState = timelinePresenter.present() val actionListState = actionListPresenter.present() + val customReactionState = customReactionPresenter.present() + val retryState = retrySendMenuPresenter.present() val syncUpdateFlow = room.syncUpdateFlow().collectAsState(0L) + val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) val roomName: MutableState = rememberSaveable { mutableStateOf(null) } @@ -105,17 +124,25 @@ class MessagesPresenter @Inject constructor( } fun handleEvents(event: MessagesEvents) { when (event) { - is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.event, composerState) - is MessagesEvents.SendReaction -> localCoroutineScope.sendReaction(event.emoji, event.eventId) + is MessagesEvents.HandleAction -> { + localCoroutineScope.handleTimelineAction(event.action, event.event, composerState) + } + is MessagesEvents.SendReaction -> { + localCoroutineScope.sendReaction(event.emoji, event.eventId) + } + is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear) } } return MessagesState( roomId = room.roomId, roomName = roomName.value, roomAvatar = roomAvatar.value, + userHasPermissionToSendMessage = userHasPermissionToSendMessage, composerState = composerState, timelineState = timelineState, actionListState = actionListState, + customReactionState = customReactionState, + retrySendMenuState = retryState, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, snackbarMessage = snackbarMessage, eventSink = ::handleEvents @@ -129,12 +156,12 @@ class MessagesPresenter @Inject constructor( ) = launch { when (action) { TimelineItemAction.Copy -> notImplementedYet() - TimelineItemAction.Forward -> notImplementedYet() TimelineItemAction.Redact -> handleActionRedact(targetEvent) TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState) TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState) - TimelineItemAction.Developer -> Unit // Handled at UI level - TimelineItemAction.ReportContent -> notImplementedYet() + TimelineItemAction.Developer -> handleShowDebugInfoAction(targetEvent) + TimelineItemAction.Forward -> handleForwardAction(targetEvent) + TimelineItemAction.ReportContent -> handleReportAction(targetEvent) } } @@ -204,4 +231,19 @@ class MessagesPresenter @Inject constructor( MessageComposerEvents.SetMode(composerMode) ) } + + private fun handleShowDebugInfoAction(event: TimelineItem.Event) { + if (event.eventId == null) return + navigator.onShowEventDebugInfoClicked(event.eventId, event.debugInfo) + } + + private fun handleForwardAction(event: TimelineItem.Event) { + if (event.eventId == null) return + navigator.onForwardEventClicked(event.eventId) + } + + private fun handleReportAction(event: TimelineItem.Event) { + if (event.eventId == null) return + navigator.onReportContentClicked(event.eventId, event.senderId) + } } 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 8c876ea49c..38c9101c9e 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 @@ -20,6 +20,8 @@ import androidx.compose.runtime.Immutable import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.timeline.TimelineState +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState 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 @@ -29,9 +31,12 @@ data class MessagesState( val roomId: RoomId, val roomName: String?, val roomAvatar: AvatarData?, + val userHasPermissionToSendMessage: Boolean, val composerState: MessageComposerState, val timelineState: TimelineState, val actionListState: ActionListState, + val customReactionState: CustomReactionState, + val retrySendMenuState: RetrySendMenuState, 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 e37fd11540..7691788e58 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 @@ -21,6 +21,8 @@ import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineState +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.designsystem.components.avatar.AvatarData @@ -33,6 +35,7 @@ open class MessagesStateProvider : PreviewParameterProvider { aMessagesState(), aMessagesState().copy(hasNetworkConnection = false), aMessagesState().copy(composerState = aMessageComposerState().copy(showAttachmentSourcePicker = true)), + aMessagesState().copy(userHasPermissionToSendMessage = false), ) } @@ -40,6 +43,7 @@ fun aMessagesState() = MessagesState( roomId = RoomId("!id:domain"), roomName = "Room name", roomAvatar = AvatarData("!id:domain", "Room name"), + userHasPermissionToSendMessage = true, composerState = aMessageComposerState().copy( text = StableCharSequence("Hello"), isFullScreen = false, @@ -48,7 +52,15 @@ fun aMessagesState() = MessagesState( timelineState = aTimelineState().copy( timelineItems = aTimelineItemList(aTimelineItemTextContent()), ), + retrySendMenuState = RetrySendMenuState( + selectedEvent = null, + eventSink = {}, + ), actionListState = anActionListState(), + customReactionState = CustomReactionState( + selectedEventId = null, + eventSink = {}, + ), 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 542f48e1ae..d9504894e8 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 @@ -16,7 +16,9 @@ package io.element.android.features.messages.impl +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row @@ -34,39 +36,33 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.element.android.features.messages.impl.actionlist.ActionListEvents -import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerView import io.element.android.features.messages.impl.timeline.TimelineView -import io.element.android.features.messages.impl.timeline.components.CustomReactionBottomSheet +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.libraries.androidutils.ui.hideKeyboard @@ -85,8 +81,8 @@ import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.launch import timber.log.Timber import io.element.android.libraries.ui.strings.R as StringsR @@ -99,16 +95,9 @@ fun MessagesView( onEventClicked: (event: TimelineItem.Event) -> Unit, onUserDataClicked: (UserId) -> Unit, onPreviewAttachments: (ImmutableList) -> Unit, - onItemDebugInfoClicked: (EventId, TimelineItemDebugInfo) -> Unit, modifier: Modifier = Modifier, ) { - val coroutineScope = rememberCoroutineScope() - val actionListViewBottomSheetState = rememberModalBottomSheetState() - val customReactionBottomSheetState = rememberModalBottomSheetState() - LogCompositions(tag = "MessagesScreen", msg = "Root") - var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) } - var isCustomReactionBottomSheetVisible by rememberSaveable { mutableStateOf(false) } AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments) @@ -128,28 +117,14 @@ fun MessagesView( Timber.v("OnMessageLongClicked= ${event.id}") localView.hideKeyboard() state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event)) - isMessageActionsBottomSheetVisible = true - } - - suspend fun onDismissActionListBottomSheet() { - state.actionListState.eventSink(ActionListEvents.Clear) - actionListViewBottomSheetState.hide() - isMessageActionsBottomSheetVisible = false } fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { - coroutineScope.launch { onDismissActionListBottomSheet() } - when (action) { - is TimelineItemAction.Developer -> if (event.eventId != null && event.debugInfo != null) { - onItemDebugInfoClicked(event.eventId, event.debugInfo) - } - else -> state.eventSink(MessagesEvents.HandleAction(action, event)) - } + state.eventSink(MessagesEvents.HandleAction(action, event)) } fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) { if (event.eventId == null) return - coroutineScope.launch { onDismissActionListBottomSheet() } state.eventSink(MessagesEvents.SendReaction(emoji, event.eventId)) } @@ -176,6 +151,11 @@ fun MessagesView( onMessageClicked = ::onMessageClicked, onMessageLongClicked = ::onMessageLongClicked, onUserDataClicked = onUserDataClicked, + onTimestampClicked = { event -> + if (event.sendState is EventSendState.SendingFailed) { + state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event)) + } + } ) }, snackbarHost = { @@ -186,45 +166,28 @@ fun MessagesView( }, ) - var reactingToEventId: EventId? by remember { mutableStateOf(null) } ActionListView( state = state.actionListState, - sheetState = actionListViewBottomSheetState, - isVisible = isMessageActionsBottomSheetVisible, - onDismiss = { coroutineScope.launch { onDismissActionListBottomSheet() } }, onActionSelected = ::onActionSelected, onCustomReactionClicked = { event -> - reactingToEventId = event.eventId - coroutineScope.launch { - onDismissActionListBottomSheet() - isCustomReactionBottomSheetVisible = true - } + state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId)) }, onEmojiReactionClicked = ::onEmojiReactionClicked, ) CustomReactionBottomSheet( - isVisible = isCustomReactionBottomSheetVisible, - sheetState = customReactionBottomSheetState, - onDismiss = { - reactingToEventId = null - coroutineScope.launch { - customReactionBottomSheetState.hide() - isCustomReactionBottomSheetVisible = false - } - }, + state = state.customReactionState, onEmojiSelected = { emoji -> - val eventId = reactingToEventId - if (eventId != null) { + state.customReactionState.selectedEventId?.let { eventId -> state.eventSink(MessagesEvents.SendReaction(emoji.unicode, eventId)) - reactingToEventId = null - coroutineScope.launch { - customReactionBottomSheetState.hide() - isCustomReactionBottomSheetVisible = false - } + state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) } } ) + + RetrySendMessageMenu( + state = state.retrySendMenuState + ) } @Composable @@ -244,10 +207,11 @@ private fun AttachmentStateView( @Composable fun MessagesViewContent( state: MessagesState, + onMessageClicked: (TimelineItem.Event) -> Unit, + onUserDataClicked: (UserId) -> Unit, + onMessageLongClicked: (TimelineItem.Event) -> Unit, + onTimestampClicked: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, - onMessageClicked: (TimelineItem.Event) -> Unit = {}, - onUserDataClicked: (UserId) -> Unit = {}, - onMessageLongClicked: (TimelineItem.Event) -> Unit = {}, ) { Column( modifier = modifier @@ -263,14 +227,19 @@ fun MessagesViewContent( onMessageClicked = onMessageClicked, onMessageLongClicked = onMessageLongClicked, onUserDataClicked = onUserDataClicked, + onTimestampClicked = onTimestampClicked, ) } - MessageComposerView( - state = state.composerState, - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight(Alignment.Bottom) - ) + if (state.userHasPermissionToSendMessage) { + MessageComposerView( + state = state.composerState, + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(Alignment.Bottom) + ) + } else { + CantSendMessageBanner() + } } } @@ -315,6 +284,28 @@ fun MessagesViewTopBar( ) } +@Composable +fun CantSendMessageBanner( + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.secondary) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(id = R.string.screen_room_no_permission_to_post), + color = MaterialTheme.colorScheme.onSecondary, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + fontStyle = FontStyle.Italic, + ) + } +} + @Preview @Composable internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) = @@ -334,6 +325,5 @@ private fun ContentToPreview(state: MessagesState) { onEventClicked = {}, onPreviewAttachments = {}, onUserDataClicked = {}, - onItemDebugInfoClicked = { _, _ -> }, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 27ec05fb51..4a88a23fa2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -42,7 +42,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -74,6 +77,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.Divider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.hide import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType @@ -82,37 +86,51 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType @Composable fun ActionListView( state: ActionListState, - isVisible: Boolean, onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit, onEmojiReactionClicked: (String, TimelineItem.Event) -> Unit, onCustomReactionClicked: (TimelineItem.Event) -> Unit, - onDismiss: () -> Unit, modifier: Modifier = Modifier, sheetState: SheetState = rememberModalBottomSheetState() ) { + val coroutineScope = rememberCoroutineScope() val targetItem = (state.target as? ActionListState.Target.Success)?.event fun onItemActionClicked( itemAction: TimelineItemAction ) { if (targetItem == null) return - onActionSelected(itemAction, targetItem) + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onActionSelected(itemAction, targetItem) + } } fun onEmojiReactionClicked(emoji: String) { if (targetItem == null) return - onEmojiReactionClicked(emoji, targetItem) + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onEmojiReactionClicked(emoji, targetItem) + } } fun onCustomReactionClicked() { if (targetItem == null) return - onCustomReactionClicked(targetItem) + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onCustomReactionClicked(targetItem) + } } - if (isVisible) { + fun onDismiss() { + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + } + } + + if (targetItem != null) { ModalBottomSheet( sheetState = sheetState, - onDismissRequest = onDismiss + onDismissRequest = ::onDismiss, ) { SheetContent( state = state, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt new file mode 100644 index 0000000000..6b74918d71 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.forward + +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails + +sealed interface ForwardMessagesEvents { + data class SetSelectedRoom(val room: RoomSummaryDetails) : ForwardMessagesEvents + // TODO remove to restore multi-selection + object RemoveSelectedRoom : ForwardMessagesEvents + object ToggleSearchActive : ForwardMessagesEvents + data class UpdateQuery(val query: String) : ForwardMessagesEvents + object ForwardEvent : ForwardMessagesEvents + object ClearError : ForwardMessagesEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt new file mode 100644 index 0000000000..13d26b9881 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.forward + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.collections.immutable.ImmutableList + +@ContributesNode(RoomScope::class) +class ForwardMessagesNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: ForwardMessagesPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onForwardedToSingleRoom(roomId: RoomId) + } + + data class Inputs(val eventId: EventId) : NodeInputs + + private val inputs = inputs() + private val presenter = presenterFactory.create(inputs.eventId.value) + private val callbacks = plugins.filterIsInstance() + + private fun onSucceeded(roomIds: ImmutableList) { + navigateUp() + if (roomIds.size == 1) { + val targetRoomId = roomIds.first() + callbacks.forEach { it.onForwardedToSingleRoom(targetRoomId) } + } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ForwardMessagesView( + state = state, + onDismiss = ::navigateUp, + onForwardingSucceeded = ::onSucceeded, + modifier = modifier + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt new file mode 100644 index 0000000000..17494f892e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.forward + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.isLoading +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +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.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class ForwardMessagesPresenter @AssistedInject constructor( + @Assisted eventId: String, + private val room: MatrixRoom, + private val matrixCoroutineScope: CoroutineScope, + private val client: MatrixClient, +) : Presenter { + + private val eventId: EventId = EventId(eventId) + + @AssistedFactory + interface Factory { + fun create(eventId: String): ForwardMessagesPresenter + } + + @Composable + override fun present(): ForwardMessagesState { + var selectedRooms by remember { mutableStateOf(persistentListOf()) } + var query by remember { mutableStateOf("") } + var isSearchActive by remember { mutableStateOf(false) } + var results: SearchBarResultState> by remember { mutableStateOf(SearchBarResultState.NotSearching()) } + val forwardingActionState: MutableState>> = remember { mutableStateOf(Async.Uninitialized) } + + val summaries by client.roomSummaryDataSource.roomSummaries().collectAsState() + + LaunchedEffect(query, summaries) { + val filteredSummaries = summaries.filterIsInstance() + .map { it.details } + .filter { it.name.contains(query, ignoreCase = true) } + .distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received + .toPersistentList() + results = if (filteredSummaries.isNotEmpty()) { + SearchBarResultState.Results(filteredSummaries) + } else { + SearchBarResultState.NoResults() + } + } + + val forwardingSucceeded by remember { + derivedStateOf { forwardingActionState.value.dataOrNull() } + } + + fun handleEvents(event: ForwardMessagesEvents) { + when (event) { + is ForwardMessagesEvents.SetSelectedRoom -> { + selectedRooms = persistentListOf(event.room) + // Restore for multi-selection +// val index = selectedRooms.indexOfFirst { it.roomId == event.room.roomId } +// selectedRooms = if (index >= 0) { +// selectedRooms.removeAt(index) +// } else { +// selectedRooms.add(event.room) +// } + } + ForwardMessagesEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf() + is ForwardMessagesEvents.UpdateQuery -> query = event.query + ForwardMessagesEvents.ToggleSearchActive -> isSearchActive = !isSearchActive + ForwardMessagesEvents.ForwardEvent -> { + isSearchActive = false + val roomIds = selectedRooms.map { it.roomId }.toPersistentList() + matrixCoroutineScope.forwardEvent(eventId, roomIds, forwardingActionState) + } + ForwardMessagesEvents.ClearError -> forwardingActionState.value = Async.Uninitialized + } + } + + return ForwardMessagesState( + resultState = results, + query = query, + isSearchActive = isSearchActive, + selectedRooms = selectedRooms, + isForwarding = forwardingActionState.value.isLoading(), + error = (forwardingActionState.value as? Async.Failure)?.error, + forwardingSucceeded = forwardingSucceeded, + eventSink = { handleEvents(it) } + ) + } + + private fun CoroutineScope.forwardEvent( + eventId: EventId, + roomIds: ImmutableList, + isForwardMessagesState: MutableState>>, + ) = launch { + isForwardMessagesState.value = Async.Loading() + room.forwardEvent(eventId, roomIds).fold( + { isForwardMessagesState.value = Async.Success(roomIds) }, + { isForwardMessagesState.value = Async.Failure(it) } + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt new file mode 100644 index 0000000000..7540766097 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.forward + +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import kotlinx.collections.immutable.ImmutableList + +data class ForwardMessagesState( + val resultState: SearchBarResultState>, + val query: String, + val isSearchActive: Boolean, + val selectedRooms: ImmutableList, + val isForwarding: Boolean, + val error: Throwable?, + val forwardingSucceeded: ImmutableList?, + val eventSink: (ForwardMessagesEvents) -> Unit +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt new file mode 100644 index 0000000000..75aacea616 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.forward + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.room.message.RoomMessage +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class ForwardMessagesStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aForwardMessagesState(), + aForwardMessagesState(query = "Test"), + aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())), + aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), query = "Test"), + aForwardMessagesState( + resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + query = "Test", + selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))) + ), + aForwardMessagesState( + resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + query = "Test", + selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))), + isForwarding = true, + ), + aForwardMessagesState( + resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + query = "Test", + selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))), + forwardingSucceeded = persistentListOf(RoomId("!room2:domain")), + ), + aForwardMessagesState( + resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + query = "Test", + selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))), + error = Throwable("error"), + ), + // Add other states here + ) +} + +fun aForwardMessagesState( + resultState: SearchBarResultState> = SearchBarResultState.NotSearching(), + query: String = "", + isSearchActive: Boolean = false, + selectedRooms: ImmutableList = persistentListOf(), + isForwarding: Boolean = false, + error: Throwable? = null, + forwardingSucceeded: ImmutableList? = null, +) = ForwardMessagesState( + resultState = resultState, + query = query, + isSearchActive = isSearchActive, + selectedRooms = selectedRooms, + isForwarding = isForwarding, + error = error, + forwardingSucceeded = forwardingSucceeded, + eventSink = {} +) + +internal fun aForwardMessagesRoomList() = listOf( + aRoomDetailsState(), + aRoomDetailsState(roomId = RoomId("!room2:domain"), canonicalAlias = "#element-x-room:matrix.org"), +) + +fun aRoomDetailsState( + roomId: RoomId = RoomId("!room:domain"), + name: String = "roomName", + canonicalAlias: String? = null, + isDirect: Boolean = true, + avatarURLString: String? = null, + lastMessage: RoomMessage? = null, + lastMessageTimestamp: Long? = null, + unreadNotificationCount: Int = 0, + inviter: RoomMember? = null, +) = RoomSummaryDetails( + roomId = roomId, + name = name, + canonicalAlias = canonicalAlias, + isDirect = isDirect, + avatarURLString = avatarURLString, + lastMessage = lastMessage, + lastMessageTimestamp = lastMessageTimestamp, + unreadNotificationCount = unreadNotificationCount, + inviter = inviter, + ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt new file mode 100644 index 0000000000..329aff2881 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.forward + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.libraries.designsystem.ElementTextStyles +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults +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.Divider +import io.element.android.libraries.designsystem.theme.components.RadioButton +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.SearchBarResultState +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.roomListRoomMessage +import io.element.android.libraries.designsystem.theme.roomListRoomName +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.ui.components.SelectedRoom +import kotlinx.collections.immutable.ImmutableList +import io.element.android.libraries.ui.strings.R as StringR + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun ForwardMessagesView( + state: ForwardMessagesState, + onDismiss: () -> Unit, + onForwardingSucceeded: (ImmutableList) -> Unit, + modifier: Modifier = Modifier, +) { + if (state.forwardingSucceeded != null) { + onForwardingSucceeded(state.forwardingSucceeded) + return + } + + fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) { + // TODO toggle selection when multi-selection is enabled + state.eventSink(ForwardMessagesEvents.RemoveSelectedRoom) + } + + @Composable + fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList) { + if (isForwarding) return + SelectedRooms( + selectedRooms = selectedRooms, + onRoomRemoved = ::onRoomRemoved, + modifier = Modifier.padding(vertical = 16.dp) + ) + } + + fun onBackButton(state: ForwardMessagesState) { + if (state.isSearchActive) { + state.eventSink(ForwardMessagesEvents.ToggleSearchActive) + } else { + onDismiss() + } + } + + BackHandler(onBack = { onBackButton(state) }) + + Scaffold( + modifier = modifier, + topBar = { + CenterAlignedTopAppBar( + title = { Text(stringResource(StringR.string.common_forward_message), style = ElementTextStyles.Bold.callout) }, + navigationIcon = { + BackButton(onClick = { onBackButton(state) }) + }, + actions = { + TextButton( + enabled = state.selectedRooms.isNotEmpty(), + onClick = { state.eventSink(ForwardMessagesEvents.ForwardEvent) } + ) { + Text(text = stringResource(StringR.string.action_send)) + } + } + ) + } + ) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + SearchBar>( + placeHolderTitle = stringResource(StringR.string.action_search), + query = state.query, + onQueryChange = { state.eventSink(ForwardMessagesEvents.UpdateQuery(it)) }, + active = state.isSearchActive, + onActiveChange = { state.eventSink(ForwardMessagesEvents.ToggleSearchActive) }, + resultState = state.resultState, + showBackButton = false, + ) { summaries -> + LazyColumn { + item { + SelectedRoomsHelper( + isForwarding = state.isForwarding, + selectedRooms = state.selectedRooms + ) + } + items(summaries, key = { it.roomId.value }) { roomSummary -> + Column { + RoomSummaryView( + roomSummary, + isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, + onSelection = { roomSummary -> + state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary)) + } + ) + Divider(modifier = Modifier.fillMaxWidth()) + } + } + } + } + + if (!state.isSearchActive) { + // TODO restore for multi-selection +// SelectedRoomsHelper( +// isForwarding = state.isForwarding, +// selectedRooms = state.selectedRooms +// ) + Spacer(modifier = Modifier.height(20.dp)) + + if (state.resultState is SearchBarResultState.Results) { + LazyColumn { + items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> + Column { + RoomSummaryView( + roomSummary, + isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, + onSelection = { roomSummary -> + state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary)) + } + ) + Divider(modifier = Modifier.fillMaxWidth()) + } + } + } + } + } + + if (state.isForwarding) { + ProgressDialog() + } + + if (state.error != null) { + ForwardingErrorDialog(onDismiss = { state.eventSink(ForwardMessagesEvents.ClearError) }) + } + } + } +} + +@Composable +internal fun SelectedRooms( + selectedRooms: ImmutableList, + onRoomRemoved: (RoomSummaryDetails) -> Unit, + modifier: Modifier = Modifier, +) { + LazyRow( + modifier, + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(32.dp) + ) { + items(selectedRooms, key = { it.roomId.value }) { roomSummary -> + SelectedRoom(roomSummary = roomSummary, onRoomRemoved = onRoomRemoved) + } + } +} + +@Composable +internal fun RoomSummaryView( + summary: RoomSummaryDetails, + isSelected: Boolean, + onSelection: (RoomSummaryDetails) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .clickable { onSelection(summary) } + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically + ) { + val roomAlias = summary.canonicalAlias ?: summary.roomId.value + Avatar( + avatarData = AvatarData(id = roomAlias, name = summary.name, url = summary.avatarURLString), + ) + Column( + modifier = Modifier + .padding(start = 12.dp, end = 4.dp, top = 8.dp, bottom = 8.dp) + .alignByBaseline() + .weight(1f) + ) { + // Name + Text( + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + text = summary.name, + color = MaterialTheme.roomListRoomName(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Id + Text( + text = roomAlias, + color = MaterialTheme.roomListRoomMessage(), + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + RadioButton(selected = isSelected, onClick = { onSelection(summary) }) + } +} + +@Composable +private fun ForwardingErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Modifier) { + ErrorDialog( + content = ErrorDialogDefaults.title, + onDismiss = onDismiss, + modifier = modifier, + ) +} + +@Preview +@Composable +fun ForwardMessagesViewLightPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun ForwardMessagesViewDarkPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: ForwardMessagesState) { + ForwardMessagesView( + state = state, + onDismiss = {}, + onForwardingSucceeded = {} + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt index afbb9bb331..9cc1ed69fd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt @@ -54,9 +54,11 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.features.messages.impl.media.local.LocalMediaView +import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.isLoading +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.preview.ElementPreviewDark @@ -140,6 +142,7 @@ fun MediaViewerView( mediaInfo = state.mediaInfo, ) ThumbnailView( + mediaInfo = state.mediaInfo, thumbnailSource = state.thumbnailSource, showThumbnail = showThumbnail, ) @@ -211,6 +214,7 @@ private fun MediaViewerTopBar( private fun ThumbnailView( thumbnailSource: MediaSource?, showThumbnail: Boolean, + mediaInfo: MediaInfo, ) { AnimatedVisibility( visible = showThumbnail, @@ -223,7 +227,7 @@ private fun ThumbnailView( ) { val mediaRequestData = MediaRequestData( source = thumbnailSource, - kind = MediaRequestData.Kind.Content + kind = MediaRequestData.Kind.File(mediaInfo.name, mediaInfo.mimeType) ) AsyncImage( modifier = Modifier.fillMaxSize(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageEvents.kt new file mode 100644 index 0000000000..ed5ee029e7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageEvents.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.features.messages.impl.report + +sealed interface ReportMessageEvents { + data class UpdateReason(val reason: String) : ReportMessageEvents + object ToggleBlockUser : ReportMessageEvents + object Report : ReportMessageEvents + object ClearError : ReportMessageEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt new file mode 100644 index 0000000000..1be4571161 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.report + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId + +@ContributesNode(RoomScope::class) +class ReportMessageNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: ReportMessagePresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + data class Inputs( + val eventId: EventId, + val senderId: UserId, + ) : NodeInputs + + private val inputs = inputs() + + private val presenter = presenterFactory.create( + ReportMessagePresenter.Inputs(inputs.eventId, inputs.senderId) + ) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ReportMessageView( + state = state, + onBackClicked = ::navigateUp, + modifier = modifier + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenter.kt new file mode 100644 index 0000000000..09cad8e33b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenter.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.report + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.executeResult +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import io.element.android.libraries.ui.strings.R as StringR + +class ReportMessagePresenter @AssistedInject constructor( + private val room: MatrixRoom, + @Assisted private val inputs: Inputs, + private val snackbarDispatcher: SnackbarDispatcher, +) : Presenter { + + data class Inputs( + val eventId: EventId, + val senderId: UserId, + ) + + @AssistedFactory + interface Factory { + fun create(inputs: Inputs): ReportMessagePresenter + } + + @Composable + override fun present(): ReportMessageState { + val coroutineScope = rememberCoroutineScope() + var reason by rememberSaveable { mutableStateOf("") } + var blockUser by rememberSaveable { mutableStateOf(false) } + var result: MutableState> = remember { mutableStateOf(Async.Uninitialized) } + + fun handleEvents(event: ReportMessageEvents) { + when (event) { + is ReportMessageEvents.UpdateReason -> reason = event.reason + ReportMessageEvents.ToggleBlockUser -> blockUser = !blockUser + ReportMessageEvents.Report -> coroutineScope.report(inputs.eventId, inputs.senderId, reason, blockUser, result) + ReportMessageEvents.ClearError -> result.value = Async.Uninitialized + } + } + + return ReportMessageState( + reason = reason, + blockUser = blockUser, + result = result.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.report( + eventId: EventId, + userId: UserId, + reason: String, + blockUser: Boolean, + result: MutableState>, + ) = launch { + suspend { + val userIdToBlock = userId.takeIf { blockUser } + room.reportContent(eventId, reason, userIdToBlock) + .onSuccess { + snackbarDispatcher.post(SnackbarMessage(StringR.string.common_report_submitted)) + } + }.executeResult(result) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageState.kt new file mode 100644 index 0000000000..809668c88f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.report + +import io.element.android.libraries.architecture.Async + +data class ReportMessageState( + val reason: String, + val blockUser: Boolean, + val result: Async, + val eventSink: (ReportMessageEvents) -> Unit +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt new file mode 100644 index 0000000000..89e6d7a220 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.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.features.messages.impl.report + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async + +open class ReportMessageStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aReportMessageState(), + aReportMessageState(reason = "This user is making the chat very toxic."), + aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true), + aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Loading()), + aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Failure(Throwable())), + aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Success(Unit)), + // Add other states here + ) +} + +fun aReportMessageState( + reason: String = "", + blockUser: Boolean = false, + result: Async = Async.Uninitialized, +) = ReportMessageState( + reason = reason, + blockUser = blockUser, + result = result, + eventSink = {} +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt new file mode 100644 index 0000000000..1beee54519 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.report + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.ElementTextStyles +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.button.ButtonWithProgress +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +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.OutlinedTextField +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.R as StringR + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun ReportMessageView( + state: ReportMessageState, + onBackClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val isSending = state.result is Async.Loading + when (state.result) { + is Async.Success -> { + LaunchedEffect(state.result) { + onBackClicked() + } + return + } + is Async.Failure -> { + ErrorDialog( + content = stringResource(StringR.string.error_unknown), + onDismiss = { state.eventSink(ReportMessageEvents.ClearError) } + ) + } + else -> Unit + } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + stringResource(StringR.string.action_report_content), + style = ElementTextStyles.Regular.callout, + fontWeight = FontWeight.Medium, + ) + }, + navigationIcon = { + BackButton(onClick = onBackClicked) + } + ) + }, + modifier = modifier + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + .imePadding() + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Spacer(modifier = Modifier.height(20.dp)) + + OutlinedTextField( + value = state.reason, + onValueChange = { state.eventSink(ReportMessageEvents.UpdateReason(it)) }, + placeholder = { Text(stringResource(StringR.string.report_content_hint)) }, + enabled = !isSending, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 90.dp) + ) + Text( + text = stringResource(StringR.string.report_content_explanation), + style = ElementTextStyles.Regular.caption1, + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.Start, + modifier = Modifier.padding(top = 4.dp, bottom = 24.dp, start = 16.dp, end = 16.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringResource(StringR.string.screen_report_content_block_user), + style = ElementTextStyles.Regular.callout, + ) + Text( + text = stringResource(StringR.string.screen_report_content_block_user_hint), + style = ElementTextStyles.Regular.bodyMD, + color = MaterialTheme.colorScheme.secondary, + ) + } + Switch( + enabled = !isSending, + checked = state.blockUser, + onCheckedChange = { state.eventSink(ReportMessageEvents.ToggleBlockUser) }, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + ButtonWithProgress( + text = stringResource(StringR.string.action_send), + enabled = state.reason.isNotBlank() && !isSending, + showProgress = isSending, + onClick = { + focusManager.clearFocus(force = true) + state.eventSink(ReportMessageEvents.Report) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + ) + } + } +} + +@Preview +@Composable +fun ReportMessageViewLightPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun ReportMessageViewDarkPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: ReportMessageState) { + ReportMessageView( + onBackClicked = {}, + state = state, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index 47e09fc577..c170886672 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -94,6 +94,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList internal fun aTimelineItemEvent( eventId: EventId = EventId("\$" + Random.nextInt().toString()), + transactionId: String? = null, isMine: Boolean = false, content: TimelineItemEventContent = aTimelineItemTextContent(), groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, @@ -104,6 +105,7 @@ internal fun aTimelineItemEvent( return TimelineItem.Event( id = eventId.value, eventId = eventId, + transactionId = transactionId, senderId = UserId("@senderId:domain"), senderAvatar = AvatarData("@senderId:domain", "sender"), content = content, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 4c6416ec67..c73cce50f4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -68,10 +68,11 @@ import kotlinx.coroutines.launch @Composable fun TimelineView( state: TimelineState, + onUserDataClicked: (UserId) -> Unit, + onMessageClicked: (TimelineItem.Event) -> Unit, + onMessageLongClicked: (TimelineItem.Event) -> Unit, + onTimestampClicked: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, - onUserDataClicked: (UserId) -> Unit = {}, - onMessageClicked: (TimelineItem.Event) -> Unit = {}, - onMessageLongClicked: (TimelineItem.Event) -> Unit = {}, ) { fun onReachedLoadMore() { state.eventSink(TimelineEvents.LoadMore) @@ -102,6 +103,7 @@ fun TimelineView( onLongClick = onMessageLongClicked, onUserDataClick = onUserDataClicked, inReplyToClick = ::inReplyToClicked, + onTimestampClicked = onTimestampClicked, ) if (index == state.timelineItems.lastIndex) { onReachedLoadMore() @@ -125,6 +127,7 @@ fun TimelineItemRow( onClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, + onTimestampClicked: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier ) { when (timelineItem) { @@ -159,6 +162,7 @@ fun TimelineItemRow( onLongClick = ::onLongClick, onUserDataClick = onUserDataClick, inReplyToClick = inReplyToClick, + onTimestampClicked = onTimestampClicked, modifier = modifier, ) } @@ -191,6 +195,7 @@ fun TimelineItemRow( onLongClick = onLongClick, inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, + onTimestampClicked = onTimestampClicked, ) } } @@ -276,6 +281,10 @@ fun TimelineViewDarkPreview( private fun ContentToPreview(content: TimelineItemEventContent) { val timelineItems = aTimelineItemList(content) TimelineView( - state = aTimelineState(timelineItems) + state = aTimelineState(timelineItems), + onMessageClicked = {}, + onTimestampClicked = {}, + onUserDataClicked = {}, + onMessageLongClicked = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt index ca96df7e7d..8c1d5e2f31 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt @@ -32,8 +32,6 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SheetState import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.runtime.Composable @@ -49,26 +47,9 @@ import com.vanniktech.emoji.google.GoogleEmojiProvider import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.Text import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CustomReactionBottomSheet( - isVisible: Boolean, - sheetState: SheetState, - onDismiss: () -> Unit, - onEmojiSelected: (Emoji) -> Unit, - modifier: Modifier = Modifier, -) { - if (isVisible) { - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) { - EmojiPicker(onEmojiSelected = onEmojiSelected, modifier = Modifier.fillMaxSize()) - } - } -} - @OptIn(ExperimentalFoundationApi::class) @Composable fun EmojiPicker( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt index ed4febf982..530f62a188 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt @@ -17,12 +17,15 @@ package io.element.android.features.messages.impl.timeline.components import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Error +import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -54,7 +57,15 @@ fun TimelineEventTimestampView( val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse() val tint = if (hasMessageSendingFailed) ElementTheme.colors.textActionCritical else null Row( - modifier = modifier.clickable(onClick = onClick), + modifier = Modifier + .clickable( + onClick = onClick, + enabled = true, + indication = rememberRipple(bounded = false), + interactionSource = MutableInteractionSource() + ) + .padding(start = 16.dp) // Add extra padding for touch target size + .then(modifier), verticalAlignment = Alignment.CenterVertically, ) { if (isMessageEdited) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index f84a899c6b..285b5cab6b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -75,6 +75,7 @@ fun TimelineItemEventRow( onLongClick: () -> Unit, onUserDataClick: (UserId) -> Unit, inReplyToClick: (EventId) -> Unit, + onTimestampClicked: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier ) { val interactionSource = remember { MutableInteractionSource() } @@ -83,7 +84,7 @@ fun TimelineItemEventRow( onUserDataClick(event.senderId) } - fun inReplayToClicked() { + fun inReplyToClicked() { val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return inReplyToClick(inReplyToEventId) } @@ -131,7 +132,10 @@ fun TimelineItemEventRow( interactionSource = interactionSource, onMessageClick = onClick, onMessageLongClick = onLongClick, - inReplyToClick = ::inReplayToClicked, + inReplyToClick = ::inReplyToClicked, + onTimestampClicked = { + onTimestampClicked(event) + } ) } TimelineItemReactionsView( @@ -177,6 +181,7 @@ private fun MessageEventBubbleContent( onMessageClick: () -> Unit, onMessageLongClick: () -> Unit, inReplyToClick: () -> Unit, + onTimestampClicked: () -> Unit, modifier: Modifier = Modifier ) { val isMediaItem = event.content is TimelineItemImageContent || event.content is TimelineItemVideoContent @@ -207,7 +212,7 @@ private fun MessageEventBubbleContent( ContentView(modifier = contentModifier) TimelineEventTimestampView( event = event, - onClick = onMessageClick, + onClick = onTimestampClicked, modifier = timestampModifier .padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding .background(LocalColors.current.gray300, RoundedCornerShape(10.0.dp)) @@ -220,7 +225,7 @@ private fun MessageEventBubbleContent( ContentView(modifier = contentModifier.padding(start = 12.dp, end = 12.dp, top = 8.dp)) TimelineEventTimestampView( event = event, - onClick = onMessageClick, + onClick = onTimestampClicked, modifier = timestampModifier .align(Alignment.End) .padding(horizontal = 8.dp, vertical = 2.dp) @@ -243,7 +248,7 @@ private fun MessageEventBubbleContent( ) { EqualWidthColumn(modifier = modifier, spacing = 8.dp) { if (inReplyToDetails != null) { - val senderName = event.senderDisplayName ?: event.senderId.value + val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails) ReplyToContent( senderName = senderName, @@ -251,6 +256,7 @@ private fun MessageEventBubbleContent( attachmentThumbnailInfo = attachmentThumbnailInfo, modifier = Modifier .padding(top = 8.dp, start = 8.dp, end = 8.dp) + .clip(RoundedCornerShape(6.dp)) .clickable(enabled = true, onClick = inReplyToClick), ) } @@ -295,7 +301,6 @@ private fun ReplyToContent( } Row( modifier - .clip(RoundedCornerShape(6.dp)) .background(MaterialTheme.colorScheme.surface) .padding(paddings) ) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt new file mode 100644 index 0000000000..6ea290cdb8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.vanniktech.emoji.Emoji +import io.element.android.features.messages.impl.timeline.components.EmojiPicker +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.hide + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomReactionBottomSheet( + state: CustomReactionState, + onEmojiSelected: (Emoji) -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + + fun onDismiss() { + sheetState.hide(coroutineScope) { + state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) + } + } + + fun onEmojiSelectedDismiss(emoji: Emoji) { + sheetState.hide(coroutineScope) { + state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) + onEmojiSelected(emoji) + } + } + + val isVisible = state.selectedEventId != null + if (isVisible) { + ModalBottomSheet( + onDismissRequest = ::onDismiss, + sheetState = sheetState, + modifier = modifier + ) { + EmojiPicker( + onEmojiSelected = ::onEmojiSelectedDismiss, + modifier = Modifier.fillMaxSize() + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt new file mode 100644 index 0000000000..b7c210553e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction + +import io.element.android.libraries.matrix.api.core.EventId + +sealed interface CustomReactionEvents { + data class UpdateSelectedEvent(val eventId: EventId?) : CustomReactionEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt new file mode 100644 index 0000000000..0a23d42085 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.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.features.messages.impl.timeline.components.customreaction + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.EventId +import javax.inject.Inject + +class CustomReactionPresenter @Inject constructor() : Presenter { + + @Composable + override fun present(): CustomReactionState { + var selectedEventId by remember { mutableStateOf(null) } + + fun handleEvents(event: CustomReactionEvents) { + when (event) { + is CustomReactionEvents.UpdateSelectedEvent -> selectedEventId = event.eventId + } + } + + return CustomReactionState(selectedEventId = selectedEventId, eventSink = ::handleEvents) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt new file mode 100644 index 0000000000..6c0c7f3599 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.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.features.messages.impl.timeline.components.customreaction + +import io.element.android.libraries.matrix.api.core.EventId + +data class CustomReactionState( + val selectedEventId: EventId?, + val eventSink: (CustomReactionEvents) -> Unit, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt index 043019cc7f..f733913bc8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt @@ -24,22 +24,24 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import kotlin.math.min + +private const val MAX_HEIGHT_IN_DP = 360f +private const val MIN_ASPECT_RATIO = 0.6f +private const val MAX_ASPECT_RATIO = 4f +private const val DEFAULT_ASPECT_RATIO = 1.33f @Composable fun TimelineItemAspectRatioBox( - height: Int?, - aspectRatio: Float, + aspectRatio: Float?, modifier: Modifier = Modifier, contentAlignment: Alignment = Alignment.TopStart, - content: @Composable BoxScope.() -> Unit, + content: @Composable (BoxScope.() -> Unit), ) { - // TODO should probably be moved to an ElementTheme.dimensions - val maxHeight = min(300, height ?: 0) + val safeAspectRatio = (aspectRatio ?: DEFAULT_ASPECT_RATIO).coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) Box( modifier = modifier - .heightIn(max = maxHeight.dp) - .aspectRatio(aspectRatio, matchHeightConstraintsFirst = true), + .heightIn(max = MAX_HEIGHT_IN_DP.dp) + .aspectRatio(safeAspectRatio, true), contentAlignment = contentAlignment, content = content ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index 566e899a36..6c7b51ddfa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -16,7 +16,6 @@ package io.element.android.features.messages.impl.timeline.components.event -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -28,25 +27,20 @@ import io.element.android.libraries.designsystem.components.BlurHashAsyncImage import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.matrix.ui.media.MediaRequestData -import kotlin.math.max @Composable fun TimelineItemImageView( content: TimelineItemImageContent, modifier: Modifier = Modifier, ) { - // TODO place this value somewhere else? - val minHeight = max(100, content.height ?: 0) TimelineItemAspectRatioBox( - height = minHeight, aspectRatio = content.aspectRatio, modifier = modifier ) { BlurHashAsyncImage( - model = MediaRequestData(content.mediaSource, MediaRequestData.Kind.Content), + model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)), blurHash = content.blurhash, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, + contentScale = ContentScale.Crop, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index aa024e1033..aeb2e7145e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -43,16 +43,14 @@ fun TimelineItemVideoView( modifier: Modifier = Modifier, ) { TimelineItemAspectRatioBox( - height = content.height, aspectRatio = content.aspectRatio, modifier = modifier, contentAlignment = Alignment.Center, ) { BlurHashAsyncImage( - model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.Content), + model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.File(content.body, content.mimeType)), blurHash = content.blurHash, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, + contentScale = ContentScale.Crop, ) Box( modifier = Modifier.roundedBackground(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuEvents.kt new file mode 100644 index 0000000000..ab6e32f078 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuEvents.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.components.retrysendmenu + +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +sealed interface RetrySendMenuEvents { + data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents + object RetrySend : RetrySendMenuEvents + object RemoveFailed : RetrySendMenuEvents + object Dismiss: RetrySendMenuEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt new file mode 100644 index 0000000000..237dc5683d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.components.retrysendmenu + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.MatrixRoom +import kotlinx.coroutines.launch +import javax.inject.Inject + +class RetrySendMenuPresenter @Inject constructor( + private val room: MatrixRoom, +) : Presenter { + + @Composable + override fun present(): RetrySendMenuState { + val coroutineScope = rememberCoroutineScope() + var selectedEvent: TimelineItem.Event? by remember { mutableStateOf(null) } + + fun handleEvent(event: RetrySendMenuEvents) { + when (event) { + is RetrySendMenuEvents.EventSelected -> { + selectedEvent = event.event + } + RetrySendMenuEvents.RetrySend -> { + coroutineScope.launch { + selectedEvent?.transactionId?.let { transactionId -> + room.retrySendMessage(transactionId) + } + selectedEvent = null + } + } + RetrySendMenuEvents.RemoveFailed -> { + coroutineScope.launch { + selectedEvent?.transactionId?.let { transactionId -> + room.cancelSend(transactionId) + } + selectedEvent = null + } + } + RetrySendMenuEvents.Dismiss -> { + selectedEvent = null + } + } + } + + return RetrySendMenuState( + selectedEvent = selectedEvent, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuState.kt new file mode 100644 index 0000000000..e10e9c752c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.components.retrysendmenu + +import androidx.compose.runtime.Immutable +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +@Immutable +data class RetrySendMenuState( + val selectedEvent: TimelineItem.Event?, + val eventSink: (RetrySendMenuEvents) -> Unit, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuStateProvider.kt new file mode 100644 index 0000000000..ccb5c26982 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuStateProvider.kt @@ -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. + */ + +package io.element.android.features.messages.impl.timeline.components.retrysendmenu + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +class RetrySendMenuStateProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + aRetrySendMenuState(event = null), + aRetrySendMenuState(event = aTimelineItemEvent()), + ) +} + +fun aRetrySendMenuState(event: TimelineItem.Event? = aTimelineItemEvent()) = + RetrySendMenuState(selectedEvent = event, eventSink = {}) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenu.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenu.kt new file mode 100644 index 0000000000..9d8c8283ca --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenu.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.components.retrysendmenu + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +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.LocalColors +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.features.messages.impl.R +import kotlinx.coroutines.launch + +@Composable +internal fun RetrySendMessageMenu( + state: RetrySendMenuState, + modifier: Modifier = Modifier, +) { + val isVisible = state.selectedEvent != null + + fun onDismiss() { + state.eventSink(RetrySendMenuEvents.Dismiss) + } + + fun onRetry() { + state.eventSink(RetrySendMenuEvents.RetrySend) + } + + fun onRemoveFailed() { + state.eventSink(RetrySendMenuEvents.RemoveFailed) + } + + RetrySendMessageMenuBottomSheet( + modifier = modifier, + isVisible = isVisible, + onRetry = ::onRetry, + onRemoveFailed = ::onRemoveFailed, + onDismiss = ::onDismiss + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RetrySendMessageMenuBottomSheet( + isVisible: Boolean, + onRetry: () -> Unit, + onRemoveFailed: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + + if (isVisible) { + ModalBottomSheet( + modifier = modifier, +// modifier = modifier.navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044 +// .imePadding() + sheetState = sheetState, + onDismissRequest = { + coroutineScope.launch { + sheetState.hide() + onDismiss() + } + } + ) { + RetrySendMenuContents(onRetry = onRetry, onRemoveFailed = onRemoveFailed) + // FIXME remove after https://issuetracker.google.com/issues/275849044 + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ColumnScope.RetrySendMenuContents( + onRetry: () -> Unit, + onRemoveFailed: () -> Unit, + sheetState: SheetState = rememberModalBottomSheetState(), +) { + val coroutineScope = rememberCoroutineScope() + + ListItem(headlineContent = { + Text(stringResource(R.string.screen_room_retry_send_menu_title), fontWeight = FontWeight.Medium) + }) + ListItem( + headlineContent = { + Text(stringResource(R.string.screen_room_retry_send_menu_send_again_action)) + }, + modifier = Modifier.clickable { + coroutineScope.launch { + sheetState.hide() + onRetry() + } + } + ) + ListItem( + headlineContent = { + Text(stringResource(R.string.screen_room_retry_send_menu_remove_action)) + }, + colors = ListItemDefaults.colors(headlineColor = LocalColors.current.textActionCritical), + modifier = Modifier.clickable { + coroutineScope.launch { + sheetState.hide() + onRemoveFailed() + } + } + ) +} + +@Preview +@Composable +internal fun RetrySendMessageMenuPreviewLight(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) { + ElementPreviewLight { + ContentToPreview(state) + } +} + +@Preview +@Composable +internal fun RetrySendMessageMenuPreviewDark(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) { + ElementPreviewDark { + ContentToPreview(state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ContentToPreview(state: RetrySendMenuState) { + // TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed + Column { + RetrySendMenuContents( + onRetry = {}, + onRemoveFailed = {}, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt index f4e771a32d..c08c8d65a3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableStateFlow @@ -45,7 +46,7 @@ class TimelineItemsFactory @Inject constructor( private val timelineItemGrouper: TimelineItemGrouper, ) { - private val timelineItems = MutableStateFlow(emptyList().toImmutableList()) + private val timelineItems = MutableStateFlow(persistentListOf()) private val timelineItemsCache = arrayListOf() // Items from rust sdk, used for diffing @@ -95,7 +96,7 @@ class TimelineItemsFactory @Inject constructor( Timber.v("Time to apply diff on new list of ${newTimelineItems.size} items: $timeToDiff ms") } - private suspend fun buildAndCacheItem( + private fun buildAndCacheItem( timelineItems: List, index: Int ): TimelineItem? { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index f1a29ebcf9..c99ab46ab1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -54,6 +54,7 @@ class TimelineItemContentMessageFactory @Inject constructor( TimelineItemImageContent( body = messageType.body, mediaSource = messageType.source, + thumbnailSource = messageType.info?.thumbnailSource, mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, blurhash = messageType.info?.blurhash, width = messageType.info?.width?.toInt(), @@ -72,7 +73,7 @@ class TimelineItemContentMessageFactory @Inject constructor( mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, width = messageType.info?.width?.toInt(), height = messageType.info?.height?.toInt(), - duration = messageType.info?.duration ?: 0L, + duration = messageType.info?.duration?.toMillis() ?: 0L, blurHash = messageType.info?.blurhash, aspectRatio = aspectRatio, formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), @@ -101,11 +102,11 @@ class TimelineItemContentMessageFactory @Inject constructor( } } - private fun aspectRatioOf(width: Long?, height: Long?): Float { + private fun aspectRatioOf(width: Long?, height: Long?): Float? { return if (height != null && width != null) { width.toFloat() / height.toFloat() } else { - 0.7f + null } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index ce9a558b97..62aca35d6e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -72,6 +72,7 @@ class TimelineItemEventFactory @Inject constructor( return TimelineItem.Event( id = currentTimelineItem.uniqueId, eventId = currentTimelineItem.eventId, + transactionId = currentTimelineItem.transactionId, senderId = currentSender, senderDisplayName = senderDisplayName, senderAvatar = senderAvatarData, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index 0328bf6603..08f9df0535 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -52,6 +52,7 @@ sealed interface TimelineItem { data class Event( val id: String, val eventId: EventId? = null, + val transactionId: String? = null, val senderId: UserId, val senderDisplayName: String?, val senderAvatar: AvatarData, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt index a5ef890c82..342e0a336b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt @@ -16,18 +16,26 @@ package io.element.android.features.messages.impl.timeline.model.event +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MediaSource data class TimelineItemImageContent( val body: String, val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, val formattedFileSize: String, val fileExtension: String, val mimeType: String, val blurhash: String?, val width: Int?, val height: Int?, - val aspectRatio: Float + val aspectRatio: Float? ) : TimelineItemEventContent { override val type: String = "TimelineItemImageContent" + + val preferredMediaSource = if (mimeType == MimeTypes.Gif) { + mediaSource + } else { + thumbnailSource ?: mediaSource + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt index 519f1e58a4..004bac390a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt @@ -32,6 +32,7 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider"Natočit video" "Příloha" "Knihovna fotografií a videí" + "Nepodařilo se načíst údaje o uživateli" + "Chtěli byste je pozvat zpět?" + "V tomto chatu jste sami" + "Nemáte oprávnění vkládat příspěvky do této místnosti" + "Odeslat znovu" + "Vaši zprávu se nepodařilo odeslat" "Nahrání média se nezdařilo, zkuste to prosím znovu." - \ No newline at end of file + "Odstranit" + diff --git a/features/messages/impl/src/main/res/values-de/translations.xml b/features/messages/impl/src/main/res/values-de/translations.xml index 78088386ad..b8811cb203 100644 --- a/features/messages/impl/src/main/res/values-de/translations.xml +++ b/features/messages/impl/src/main/res/values-de/translations.xml @@ -9,4 +9,9 @@ "Video aufnehmen" "Anhang" "Foto- & Video-Bibliothek" - \ No newline at end of file + "Benutzerdetails konnten nicht abgerufen werden" + "Erneut senden" + "Ihre Nachricht konnte nicht gesendet werden" + "Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuchen Sie es erneut." + "Entfernen" + diff --git a/features/messages/impl/src/main/res/values-es/translations.xml b/features/messages/impl/src/main/res/values-es/translations.xml index 7cd4b6e764..5d41b319bd 100644 --- a/features/messages/impl/src/main/res/values-es/translations.xml +++ b/features/messages/impl/src/main/res/values-es/translations.xml @@ -4,4 +4,5 @@ "%1$d cambio en la sala" "%1$d cambios en la sala" - \ No newline at end of file + "Eliminar" + diff --git a/features/messages/impl/src/main/res/values-fr/translations.xml b/features/messages/impl/src/main/res/values-fr/translations.xml index 2eb6016f2f..8a873e08f9 100644 --- a/features/messages/impl/src/main/res/values-fr/translations.xml +++ b/features/messages/impl/src/main/res/values-fr/translations.xml @@ -5,4 +5,5 @@ "%1$d changements dans la conversation" "Prendre une photo" - \ No newline at end of file + "Supprimer" + diff --git a/features/messages/impl/src/main/res/values-it/translations.xml b/features/messages/impl/src/main/res/values-it/translations.xml index 649a91405b..694de002fe 100644 --- a/features/messages/impl/src/main/res/values-it/translations.xml +++ b/features/messages/impl/src/main/res/values-it/translations.xml @@ -4,4 +4,5 @@ "%1$d modifica alla stanza" "%1$d modifiche alla stanza" - \ No newline at end of file + "Rimuovi" + diff --git a/features/messages/impl/src/main/res/values-ro/translations.xml b/features/messages/impl/src/main/res/values-ro/translations.xml index 0a2aa20456..f8ed777638 100644 --- a/features/messages/impl/src/main/res/values-ro/translations.xml +++ b/features/messages/impl/src/main/res/values-ro/translations.xml @@ -10,5 +10,7 @@ "Înregistrați un videoclip" "Atașament" "Bibliotecă foto și video" + "Nu am putut găsi detaliile utilizatorului" "Procesarea datelor media a eșuat, vă rugăm să încercați din nou." - \ No newline at end of file + "Ștergeți" + diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index d94f32a88f..557b6ccd90 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -10,5 +10,11 @@ "Attachment" "Photo & Video Library" "Could not retrieve user details" + "Would you like to invite them back?" + "You are alone in this chat" + "You do not have permission to post to this room" + "Send again" + "Your message failed to send" "Failed processing media to upload, please try again." - \ No newline at end of file + "Remove" + diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/FakeMessagesNavigator.kt new file mode 100644 index 0000000000..8a374e5bcb --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/FakeMessagesNavigator.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages + +import io.element.android.features.messages.impl.MessagesNavigator +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo + +class FakeMessagesNavigator : MessagesNavigator { + var onShowEventDebugInfoClickedCount = 0 + private set + + var onForwardEventClickedCount = 0 + private set + + var onReportContentClickedCount = 0 + private set + + override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) { + onShowEventDebugInfoClickedCount++ + } + + override fun onForwardEventClicked(eventId: EventId) { + onForwardEventClickedCount++ + } + + override fun onReportContentClicked(eventId: EventId, senderId: UserId) { + onReportContentClickedCount++ + } +} 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 d517b261e2..40af424406 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 @@ -26,15 +26,19 @@ import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.MessagesEvents import io.element.android.features.messages.impl.MessagesPresenter 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.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.mimetype.MimeTypes @@ -42,6 +46,7 @@ import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -53,7 +58,6 @@ import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test @@ -75,8 +79,9 @@ class MessagesPresenterTest { @Test fun `present - handle sending a reaction`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) val room = FakeMatrixRoom() - val presenter = createMessagePresenter(matrixRoom = room) + val presenter = createMessagePresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -89,19 +94,22 @@ class MessagesPresenterTest { room.givenSendReactionResult(Result.failure(IllegalStateException("Failed to send reaction"))) initialState.eventSink.invoke(MessagesEvents.SendReaction("👍", AN_EVENT_ID)) assertThat(room.sendReactionCount).isEqualTo(2) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @Test fun `present - handle action forward`() = runTest { - val presenter = createMessagePresenter() + val navigator = FakeMessagesNavigator() + val presenter = createMessagePresenter(navigator = navigator) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent())) - // Still a TODO in the code + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + assertThat(navigator.onForwardEventClickedCount).isEqualTo(1) } } @@ -114,7 +122,7 @@ class MessagesPresenterTest { skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, aMessageEvent())) - // Still a TODO in the code + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -130,6 +138,7 @@ class MessagesPresenterTest { skipItems(1) val finalState = awaitItem() assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -142,7 +151,7 @@ class MessagesPresenterTest { skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null))) - skipItems(1) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) // Otherwise we would have some extra items here ensureAllEventsConsumed() } @@ -160,6 +169,7 @@ class MessagesPresenterTest { content = TimelineItemImageContent( body = "image.jpg", mediaSource = MediaSource(AN_AVATAR_URL), + thumbnailSource = null, mimeType = MimeTypes.Jpeg, blurhash = null, width = 20, @@ -175,6 +185,7 @@ class MessagesPresenterTest { assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) val replyMode = finalState.composerState.mode as MessageComposerMode.Reply assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -207,6 +218,7 @@ class MessagesPresenterTest { assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) val replyMode = finalState.composerState.mode as MessageComposerMode.Reply assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -234,6 +246,7 @@ class MessagesPresenterTest { assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) val replyMode = finalState.composerState.mode as MessageComposerMode.Reply assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -249,13 +262,15 @@ class MessagesPresenterTest { skipItems(1) val finalState = awaitItem() assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Edit::class.java) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @Test fun `present - handle action redact`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) val matrixRoom = FakeMatrixRoom() - val presenter = createMessagePresenter(matrixRoom) + val presenter = createMessagePresenter(matrixRoom = matrixRoom, coroutineDispatchers = coroutineDispatchers) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -263,37 +278,83 @@ class MessagesPresenterTest { val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent())) assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @Test fun `present - handle action report content`() = runTest { - val presenter = createMessagePresenter() + val navigator = FakeMessagesNavigator() + val presenter = createMessagePresenter(navigator = navigator) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent())) - // Still a TODO in the code + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + assertThat(navigator.onReportContentClickedCount).isEqualTo(1) } } @Test - fun `present - handle action show developer info`() = runTest { + fun `present - handle dismiss action`() = runTest { val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.Dismiss) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - handle action show developer info`() = runTest { + val navigator = FakeMessagesNavigator() + val presenter = createMessagePresenter(navigator = navigator) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent())) - // Still a TODO in the code + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + assertThat(navigator.onShowEventDebugInfoClickedCount).isEqualTo(1) + } + } + + @Test + fun `present - permission to post`() = runTest { + val matrixRoom = FakeMatrixRoom() + matrixRoom.givenCanSendEventResult(MessageEventType.ROOM_MESSAGE, Result.success(true)) + val presenter = createMessagePresenter(matrixRoom = matrixRoom) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + assertThat(awaitItem().userHasPermissionToSendMessage).isTrue() + } + } + + @Test + fun `present - no permission to post`() = runTest { + val matrixRoom = FakeMatrixRoom() + matrixRoom.givenCanSendEventResult(MessageEventType.ROOM_MESSAGE, Result.success(false)) + val presenter = createMessagePresenter(matrixRoom = matrixRoom) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + assertThat(awaitItem().userHasPermissionToSendMessage).isFalse() } } private fun TestScope.createMessagePresenter( - matrixRoom: MatrixRoom = FakeMatrixRoom() + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + matrixRoom: MatrixRoom = FakeMatrixRoom(), + navigator: FakeMessagesNavigator = FakeMessagesNavigator(), ): MessagesPresenter { val messageComposerPresenter = MessageComposerPresenter( appCoroutineScope = this, @@ -322,15 +383,20 @@ class MessagesPresenterTest { flavorShortDescription = "", ) val actionListPresenter = ActionListPresenter(buildMeta = buildMeta) + val customReactionPresenter = CustomReactionPresenter() + val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom) return MessagesPresenter( room = matrixRoom, composerPresenter = messageComposerPresenter, timelinePresenter = timelinePresenter, actionListPresenter = actionListPresenter, + customReactionPresenter = customReactionPresenter, + retrySendMenuPresenter = retrySendMenuPresenter, networkMonitor = FakeNetworkMonitor(), snackbarDispatcher = SnackbarDispatcher(), messageSummaryFormatter = FakeMessageSummaryFormatter(), - dispatchers = testCoroutineDispatchers(testScheduler), + navigator = navigator, + dispatchers = coroutineDispatchers, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt index 3789c36146..18f5ae8da3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt @@ -19,7 +19,6 @@ package io.element.android.features.messages.attachments import android.net.Uri -import androidx.media3.common.MimeTypes import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test @@ -31,7 +30,6 @@ import io.element.android.features.messages.impl.attachments.preview.Attachments import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender @@ -58,7 +56,6 @@ class AttachmentsPreviewPresenterTest { initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) val loadingState = awaitItem() assertThat(loadingState.sendActionState).isEqualTo(Async.Loading()) - testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS) val successState = awaitItem() assertThat(successState.sendActionState).isEqualTo(Async.Success(Unit)) assertThat(room.sendMediaCount).isEqualTo(1) @@ -79,7 +76,6 @@ class AttachmentsPreviewPresenterTest { initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) val loadingState = awaitItem() assertThat(loadingState.sendActionState).isEqualTo(Async.Loading()) - testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS) val failureState = awaitItem() assertThat(failureState.sendActionState).isEqualTo(Async.Failure(failure)) assertThat(room.sendMediaCount).isEqualTo(0) 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 a5b1780589..beb37f8e51 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 @@ -37,8 +37,9 @@ import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter import io.element.android.libraries.eventformatter.api.TimelineEventFormatter import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope -internal fun aTimelineItemsFactory(): TimelineItemsFactory { +internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory { val timelineEventFormatter = aTimelineEventFormatter() return TimelineItemsFactory( dispatchers = testCoroutineDispatchers(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt new file mode 100644 index 0000000000..b4efaca864 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.forward + +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.messages.impl.forward.ForwardMessagesEvents +import io.element.android.features.messages.impl.forward.ForwardMessagesPresenter +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.test.AN_EVENT_ID +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 io.element.android.libraries.matrix.test.room.aRoomSummaryDetail +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ForwardMessagesPresenterTests { + + @Test + fun `present - initial state`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.selectedRooms).isEmpty() + assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.NotSearching::class.java) + assertThat(initialState.isSearchActive).isFalse() + assertThat(initialState.isForwarding).isFalse() + assertThat(initialState.error).isNull() + assertThat(initialState.forwardingSucceeded).isNull() + + // Search is run automatically + val searchState = awaitItem() + assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) + } + } + + @Test + fun `present - toggle search active`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + val summary = aRoomSummaryDetail() + + initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive) + assertThat(awaitItem().isSearchActive).isTrue() + + initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive) + assertThat(awaitItem().isSearchActive).isFalse() + } + } + + @Test + fun `present - update query`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource().apply { + postRoomSummary(listOf(RoomSummary.Filled(aRoomSummaryDetail()))) + } + val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + val presenter = aPresenter(client = client) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetail()))) + + initialState.eventSink(ForwardMessagesEvents.UpdateQuery("string not contained")) + assertThat(awaitItem().query).isEqualTo("string not contained") + assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) + } + } + + @Test + fun `present - select a room and forward successful`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + val summary = aRoomSummaryDetail() + + initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary)) + awaitItem() + + // Test successful forwarding + initialState.eventSink(ForwardMessagesEvents.ForwardEvent) + + val forwardingState = awaitItem() + assertThat(forwardingState.isSearchActive).isFalse() + assertThat(forwardingState.isForwarding).isTrue() + + val successfulForwardState = awaitItem() + assertThat(successfulForwardState.isForwarding).isFalse() + assertThat(successfulForwardState.forwardingSucceeded).isNotNull() + } + } + + @Test + fun `present - select a room and forward failed, then clear`() = runTest { + val room = FakeMatrixRoom() + val presenter = aPresenter(fakeMatrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + val summary = aRoomSummaryDetail() + + initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary)) + awaitItem() + + // Test failed forwarding + room.givenForwardEventResult(Result.failure(Throwable("error"))) + initialState.eventSink(ForwardMessagesEvents.ForwardEvent) + skipItems(1) + + val failedForwardState = awaitItem() + assertThat(failedForwardState.isForwarding).isFalse() + assertThat(failedForwardState.error).isNotNull() + + // Then clear error + initialState.eventSink(ForwardMessagesEvents.ClearError) + assertThat(awaitItem().error).isNull() + } + } + + @Test + fun `present - select and remove a room`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + val summary = aRoomSummaryDetail() + + initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary)) + assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary)) + + initialState.eventSink(ForwardMessagesEvents.RemoveSelectedRoom) + assertThat(awaitItem().selectedRooms).isEmpty() + } + } + + private fun CoroutineScope.aPresenter( + eventId: EventId = AN_EVENT_ID, + fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(), + coroutineScope: CoroutineScope = this, + client: FakeMatrixClient = FakeMatrixClient(), + ) = ForwardMessagesPresenter(eventId.value, fakeMatrixRoom, coroutineScope, client) + +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt index 25a62e439a..5bdef5f9b1 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt @@ -19,10 +19,9 @@ package io.element.android.features.messages.media import androidx.compose.runtime.Composable import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.features.messages.impl.media.local.LocalMediaActions -import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import kotlinx.coroutines.withContext +import io.element.android.tests.testutils.simulateLongTask -class FakeLocalMediaActions(private val coroutineDispatchers: CoroutineDispatchers) : LocalMediaActions { +class FakeLocalMediaActions : LocalMediaActions { var shouldFail = false @@ -31,7 +30,7 @@ class FakeLocalMediaActions(private val coroutineDispatchers: CoroutineDispatche //NOOP } - override suspend fun saveOnDisk(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + override suspend fun saveOnDisk(localMedia: LocalMedia): Result = simulateLongTask { if (shouldFail) { Result.failure(RuntimeException()) } else { @@ -39,7 +38,7 @@ class FakeLocalMediaActions(private val coroutineDispatchers: CoroutineDispatche } } - override suspend fun share(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + override suspend fun share(localMedia: LocalMedia): Result = simulateLongTask { if (shouldFail) { Result.failure(RuntimeException()) } else { @@ -47,7 +46,7 @@ class FakeLocalMediaActions(private val coroutineDispatchers: CoroutineDispatche } } - override suspend fun open(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + override suspend fun open(localMedia: LocalMedia): Result = simulateLongTask { if (shouldFail) { Result.failure(RuntimeException()) } else { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt index ea40cfe791..2b66d2cf9b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt @@ -23,7 +23,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.features.messages.impl.media.local.MediaInfo import io.element.android.features.messages.impl.media.local.aFileInfo import io.element.android.features.messages.impl.media.viewer.MediaViewerEvents import io.element.android.features.messages.impl.media.viewer.MediaViewerNode @@ -34,7 +33,6 @@ import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.matrix.test.media.FakeMediaLoader import io.element.android.libraries.matrix.test.media.aMediaSource -import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -49,9 +47,8 @@ class MediaViewerPresenterTest { @Test fun `present - download media success scenario`() = runTest { - val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false) - val mediaLoader = FakeMediaLoader(coroutineDispatchers) - val mediaActions = FakeLocalMediaActions(coroutineDispatchers) + val mediaLoader = FakeMediaLoader() + val mediaActions = FakeLocalMediaActions() val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -70,10 +67,10 @@ class MediaViewerPresenterTest { @Test fun `present - check all actions `() = runTest { - val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false) - val mediaLoader = FakeMediaLoader(coroutineDispatchers) - val mediaActions = FakeLocalMediaActions(coroutineDispatchers) - val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) + val mediaLoader = FakeMediaLoader() + val mediaActions = FakeLocalMediaActions() + val snackbarDispatcher = SnackbarDispatcher() + val presenter = aMediaViewerPresenter(mediaLoader, mediaActions, snackbarDispatcher) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -94,34 +91,31 @@ class MediaViewerPresenterTest { state.eventSink(MediaViewerEvents.SaveOnDisk) state = awaitItem() assertThat(state.snackbarMessage).isNotNull() - state = awaitItem() - assertThat(state.snackbarMessage).isNull() + snackbarDispatcher.clear() + assertThat(awaitItem().snackbarMessage).isNull() // Check failures mediaActions.shouldFail = true state.eventSink(MediaViewerEvents.OpenWith) state = awaitItem() assertThat(state.snackbarMessage).isNotNull() - state = awaitItem() - assertThat(state.snackbarMessage).isNull() + snackbarDispatcher.clear() + assertThat(awaitItem().snackbarMessage).isNull() state.eventSink(MediaViewerEvents.Share) state = awaitItem() assertThat(state.snackbarMessage).isNotNull() - state = awaitItem() - assertThat(state.snackbarMessage).isNull() + snackbarDispatcher.clear() + assertThat(awaitItem().snackbarMessage).isNull() state.eventSink(MediaViewerEvents.SaveOnDisk) state = awaitItem() assertThat(state.snackbarMessage).isNotNull() - state = awaitItem() - assertThat(state.snackbarMessage).isNull() } } @Test fun `present - download media failure then retry with success scenario`() = runTest { - val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false) - val mediaLoader = FakeMediaLoader(coroutineDispatchers) - val mediaActions = FakeLocalMediaActions(coroutineDispatchers) + val mediaLoader = FakeMediaLoader() + val mediaActions = FakeLocalMediaActions() val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -150,6 +144,7 @@ class MediaViewerPresenterTest { private fun aMediaViewerPresenter( mediaLoader: FakeMediaLoader, localMediaActions: FakeLocalMediaActions, + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), ): MediaViewerPresenter { return MediaViewerPresenter( inputs = MediaViewerNode.Inputs( @@ -160,7 +155,7 @@ class MediaViewerPresenterTest { localMediaFactory = localMediaFactory, mediaLoader = mediaLoader, localMediaActions = localMediaActions, - snackbarDispatcher = SnackbarDispatcher() + snackbarDispatcher = snackbarDispatcher, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/report/ReportMessagePresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/report/ReportMessagePresenterTests.kt new file mode 100644 index 0000000000..090dd36dbe --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/report/ReportMessagePresenterTests.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.report + +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.messages.impl.report.ReportMessageEvents +import io.element.android.features.messages.impl.report.ReportMessagePresenter +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +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_USER_ID +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ReportMessagePresenterTests { + + @Test + fun `presenter - initial state`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.reason).isEmpty() + assertThat(initialState.blockUser).isFalse() + assertThat(initialState.result).isInstanceOf(Async.Uninitialized::class.java) + } + } + + @Test + fun `presenter - update reason`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val reason = "This user is making the chat very toxic." + initialState.eventSink(ReportMessageEvents.UpdateReason(reason)) + + assertThat(awaitItem().reason).isEqualTo(reason) + } + } + + @Test + fun `presenter - toggle block user`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ReportMessageEvents.ToggleBlockUser) + + assertThat(awaitItem().blockUser).isTrue() + + initialState.eventSink(ReportMessageEvents.ToggleBlockUser) + + assertThat(awaitItem().blockUser).isFalse() + } + } + + @Test + fun `presenter - handle successful report and block user`() = runTest { + val room = FakeMatrixRoom() + val presenter = aPresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ReportMessageEvents.ToggleBlockUser) + skipItems(1) + initialState.eventSink(ReportMessageEvents.Report) + assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().result).isInstanceOf(Async.Success::class.java) + assertThat(room.reportedContentCount).isEqualTo(1) + } + } + + @Test + fun `presenter - handle successful report`() = runTest { + val room = FakeMatrixRoom() + val presenter = aPresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ReportMessageEvents.Report) + assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().result).isInstanceOf(Async.Success::class.java) + assertThat(room.reportedContentCount).isEqualTo(1) + } + } + + @Test + fun `presenter - handle failed report`() = runTest { + val room = FakeMatrixRoom().apply { + givenReportContentResult(Result.failure(Exception("Failed to report content"))) + } + val presenter = aPresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ReportMessageEvents.Report) + assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java) + val resultState = awaitItem() + assertThat(resultState.result).isInstanceOf(Async.Failure::class.java) + assertThat(room.reportedContentCount).isEqualTo(1) + + resultState.eventSink(ReportMessageEvents.ClearError) + assertThat(awaitItem().result).isInstanceOf(Async.Uninitialized::class.java) + } + } + + private fun aPresenter( + inputs: ReportMessagePresenter.Inputs = ReportMessagePresenter.Inputs(AN_EVENT_ID, A_USER_ID), + matrixRoom: MatrixRoom = FakeMatrixRoom(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), + ) = ReportMessagePresenter( + inputs = inputs, + room = matrixRoom, + 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 8fc9b702bc..7e0cbeeba3 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 @@ -36,7 +36,6 @@ 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 @@ -50,7 +49,6 @@ import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender 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 @@ -301,16 +299,7 @@ class MessageComposerPresenterTest { thumbnailSource = null, blurhash = null, ), - thumbnailInfo = ThumbnailProcessingInfo( - file = File("/some/path"), - info = ThumbnailInfo( - width = null, - height = null, - mimetype = null, - size = null, - ), - blurhash = "", - ) + thumbnailFile = File("/some/path") ) ) ) @@ -344,16 +333,7 @@ class MessageComposerPresenterTest { thumbnailSource = null, blurhash = null, ), - thumbnailInfo = ThumbnailProcessingInfo( - file = File("/some/path"), - info = ThumbnailInfo( - width = null, - height = null, - mimetype = null, - size = null, - ), - blurhash = "", - ) + thumbnailFile = File("/some/path") ) ) ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt new file mode 100644 index 0000000000..237cb81d38 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.timeline.components.customreaction + +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.messages.impl.timeline.components.customreaction.CustomReactionEvents +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CustomReactionPresenterTests { + + private val presenter = CustomReactionPresenter() + + @Test + fun `present - handle selecting and de-selecting an event`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.selectedEventId).isNull() + + initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(AN_EVENT_ID)) + assertThat(awaitItem().selectedEventId).isEqualTo(AN_EVENT_ID) + + initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) + assertThat(awaitItem().selectedEventId).isNull() + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt new file mode 100644 index 0000000000..1e467b82af --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.timeline.components.retrysendmenu + +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.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter +import io.element.android.libraries.matrix.test.A_TRANSACTION_ID +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RetrySendMenuPresenterTests { + + private val room = FakeMatrixRoom() + private val presenter = RetrySendMenuPresenter(room) + + @Test + fun `present - handle event selected`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent() + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + + assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent) + } + } + + @Test + fun `present - handle dismiss`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent() + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.Dismiss) + assertThat(awaitItem().selectedEvent).isNull() + } + } + + @Test + fun `present - handle resend with transactionId`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.RetrySend) + assertThat(room.retrySendMessageCount).isEqualTo(1) + assertThat(awaitItem().selectedEvent).isNull() + } + } + + @Test + fun `present - handle resend without transactionId`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent(transactionId = null) + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.RetrySend) + assertThat(room.retrySendMessageCount).isEqualTo(0) + assertThat(awaitItem().selectedEvent).isNull() + } + } + + @Test + fun `present - handle resend with error`() = runTest { + room.givenRetrySendMessageResult(Result.failure(IllegalStateException("An error"))) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.RetrySend) + assertThat(room.retrySendMessageCount).isEqualTo(1) + assertThat(awaitItem().selectedEvent).isNull() + } + } + + @Test + fun `present - handle remove failed message with transactionId`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.RemoveFailed) + assertThat(room.cancelSendCount).isEqualTo(1) + assertThat(awaitItem().selectedEvent).isNull() + } + } + + @Test + fun `present - handle remove failed message without transactionId`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent(transactionId = null) + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.RemoveFailed) + assertThat(room.cancelSendCount).isEqualTo(0) + assertThat(awaitItem().selectedEvent).isNull() + } + } + + @Test + fun `present - handle remove failed message with error`() = runTest { + room.givenRetrySendMessageResult(Result.failure(IllegalStateException("An error"))) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.RemoveFailed) + assertThat(room.cancelSendCount).isEqualTo(1) + assertThat(awaitItem().selectedEvent).isNull() + } + } +} diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt index a081c0b7ab..d86623cae2 100644 --- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt +++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt @@ -53,6 +53,7 @@ class OnBoardingNode @AssistedInject constructor( state = state, modifier = modifier, onSignIn = ::onSignIn, + onCreateAccount = ::onSignUp, ) } } diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt index 6f071fd090..643ab2bfd8 100644 --- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt +++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt @@ -120,9 +120,7 @@ private fun OnBoardingButtons( ButtonColumnMolecule(modifier = modifier) { if (state.canLoginWithQrCode) { Button( - onClick = { - onSignInWithQrCode() - }, + onClick = onSignInWithQrCode, enabled = true, modifier = Modifier .fillMaxWidth() @@ -136,9 +134,7 @@ private fun OnBoardingButtons( } } Button( - onClick = { - onSignIn() - }, + onClick = onSignIn, enabled = true, modifier = Modifier .fillMaxWidth() @@ -148,9 +144,7 @@ private fun OnBoardingButtons( } if (state.canCreateAccount) { OutlinedButton( - onClick = { - onCreateAccount() - }, + onClick = onCreateAccount, enabled = true, modifier = Modifier .fillMaxWidth() diff --git a/features/onboarding/impl/src/main/res/values-cs/translations.xml b/features/onboarding/impl/src/main/res/values-cs/translations.xml index 279037e455..176c446673 100644 --- a/features/onboarding/impl/src/main/res/values-cs/translations.xml +++ b/features/onboarding/impl/src/main/res/values-cs/translations.xml @@ -4,6 +4,6 @@ "Přihlásit se pomocí QR kódu" "Vytvořit účet" "Komunikujte a spolupracujte bezpečně" - "Vítejte v %1$s Beta. Vylepšený, pro rychlost a jednoduchost." + "Vítejte v %1$s. Vylepšený, pro rychlost a jednoduchost." "Buďte ve svém živlu" - \ No newline at end of file + diff --git a/features/onboarding/impl/src/main/res/values-de/translations.xml b/features/onboarding/impl/src/main/res/values-de/translations.xml index da5178aaf2..c6d934cbf2 100644 --- a/features/onboarding/impl/src/main/res/values-de/translations.xml +++ b/features/onboarding/impl/src/main/res/values-de/translations.xml @@ -1,7 +1,9 @@ + "Manuell anmelden" "Mit QR-Code anmelden" "Konto erstellen" - "Willkommen zur %1$s Beta. Verbessert, für Geschwindigkeit und Einfachheit." + "Sicher kommunizieren und zusammenarbeiten" + "Willkommen zur %1$s. Verbessert, für Geschwindigkeit und Einfachheit." "Sei in deinem Element" - \ No newline at end of file + diff --git a/features/onboarding/impl/src/main/res/values-es/translations.xml b/features/onboarding/impl/src/main/res/values-es/translations.xml index 235fb4558a..2489344438 100644 --- a/features/onboarding/impl/src/main/res/values-es/translations.xml +++ b/features/onboarding/impl/src/main/res/values-es/translations.xml @@ -1,5 +1,5 @@ - "Bienvenido a la beta de %1$s. Vitaminado, para mayor rapidez y sencillez." + "Bienvenido a %1$s. Vitaminado, para mayor rapidez y sencillez." "Siéntente en tu Elemento" - \ No newline at end of file + diff --git a/features/onboarding/impl/src/main/res/values-fr/translations.xml b/features/onboarding/impl/src/main/res/values-fr/translations.xml index 502b464fa7..018bf21379 100644 --- a/features/onboarding/impl/src/main/res/values-fr/translations.xml +++ b/features/onboarding/impl/src/main/res/values-fr/translations.xml @@ -1,5 +1,5 @@ - "Bienvenue dans la version %1$s Beta. Affiné pour plus de rapidité et de simplicité." + "Bienvenue dans %1$s. Affiné pour plus de rapidité et de simplicité." "Soyez dans votre Element" - \ No newline at end of file + diff --git a/features/onboarding/impl/src/main/res/values-it/translations.xml b/features/onboarding/impl/src/main/res/values-it/translations.xml index cd3c6a696c..652d9e6c22 100644 --- a/features/onboarding/impl/src/main/res/values-it/translations.xml +++ b/features/onboarding/impl/src/main/res/values-it/translations.xml @@ -1,5 +1,5 @@ - "Benvenuto nella beta di %1$s. Potenziato in velocità e semplicità." + "Benvenuto su %1$s. Potenziato in velocità e semplicità." "Sii nel tuo elemento" - \ No newline at end of file + diff --git a/features/onboarding/impl/src/main/res/values-ro/translations.xml b/features/onboarding/impl/src/main/res/values-ro/translations.xml index 03d967ab75..13e5eb505b 100644 --- a/features/onboarding/impl/src/main/res/values-ro/translations.xml +++ b/features/onboarding/impl/src/main/res/values-ro/translations.xml @@ -1,5 +1,9 @@ - "Bun venit la versiunea beta a %1$s. Supraalimentat, pentru viteză și simplitate." + "Conectați-vă manual" + "Conectați-vă cu un cod QR" + "Creați un cont" + "Comunicați și colaborați în siguranță" + "Bun venit în %1$s. Supraalimentat, pentru viteză și simplitate." "Fii în Elementul tău" - \ No newline at end of file + diff --git a/features/onboarding/impl/src/main/res/values/localazy.xml b/features/onboarding/impl/src/main/res/values/localazy.xml index 54d86ba247..bad5b524da 100644 --- a/features/onboarding/impl/src/main/res/values/localazy.xml +++ b/features/onboarding/impl/src/main/res/values/localazy.xml @@ -4,6 +4,6 @@ "Sign in with QR code" "Create account" "Communicate and collaborate securely" - "Welcome to the %1$s Beta. Supercharged, for speed and simplicity." + "Welcome to %1$s. Supercharged, for speed and simplicity." "Be in your Element" - \ No newline at end of file + diff --git a/features/rageshake/api/src/main/res/values-cs/translations.xml b/features/rageshake/api/src/main/res/values-cs/translations.xml index 7ae9126f29..20d6f31ed0 100644 --- a/features/rageshake/api/src/main/res/values-cs/translations.xml +++ b/features/rageshake/api/src/main/res/values-cs/translations.xml @@ -2,4 +2,4 @@ "%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?" "Zdá se, že jste frustrovaně třásli telefonem. Chcete otevřít obrazovku pro nahlášení chyby?" - \ No newline at end of file + diff --git a/features/rageshake/api/src/main/res/values-de/translations.xml b/features/rageshake/api/src/main/res/values-de/translations.xml index 1633cd340e..f2446a4028 100644 --- a/features/rageshake/api/src/main/res/values-de/translations.xml +++ b/features/rageshake/api/src/main/res/values-de/translations.xml @@ -2,4 +2,4 @@ "%1$s ist bei der letzten Verwendung abgestürzt. Möchtest du uns einen Absturzbericht senden?" "Du scheinst frustriert das Telefon zu schütteln. Möchtest du den Fehlerberichtsbildschirm öffnen?" - \ No newline at end of file + diff --git a/features/rageshake/api/src/main/res/values-es/translations.xml b/features/rageshake/api/src/main/res/values-es/translations.xml index 26ff483b91..597ec74260 100644 --- a/features/rageshake/api/src/main/res/values-es/translations.xml +++ b/features/rageshake/api/src/main/res/values-es/translations.xml @@ -2,4 +2,4 @@ "%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?" "Parece que sacudes el teléfono con frustración. ¿Quieres abrir la pantalla de informe de errores?" - \ No newline at end of file + diff --git a/features/rageshake/api/src/main/res/values-fr/translations.xml b/features/rageshake/api/src/main/res/values-fr/translations.xml index 32bdaf4685..455ab1daef 100644 --- a/features/rageshake/api/src/main/res/values-fr/translations.xml +++ b/features/rageshake/api/src/main/res/values-fr/translations.xml @@ -2,4 +2,4 @@ "%1$s a planté la dernière fois qu\'il a été utilisé. Souhaitez-vous partager un rapport de crash avec nous ?" "Vous semblez secouer le téléphone de frustration. Voulez-vous ouvrir le formulaire de rapport de problème ?" - \ No newline at end of file + diff --git a/features/rageshake/api/src/main/res/values-it/translations.xml b/features/rageshake/api/src/main/res/values-it/translations.xml index e6ef37d287..6d5e7a74c0 100644 --- a/features/rageshake/api/src/main/res/values-it/translations.xml +++ b/features/rageshake/api/src/main/res/values-it/translations.xml @@ -2,4 +2,4 @@ "%1$s si è chiuso inaspettatamente l\'ultima volta che è stato usato. Vuoi condividere con noi un rapporto sull\'arresto anomalo?" "Sembra che tu stia scuotendo il telefono per la frustrazione. Vuoi aprire la schermata di segnalazione dei problemi?" - \ No newline at end of file + diff --git a/features/rageshake/api/src/main/res/values-ro/translations.xml b/features/rageshake/api/src/main/res/values-ro/translations.xml index 17180d5145..2c89703deb 100644 --- a/features/rageshake/api/src/main/res/values-ro/translations.xml +++ b/features/rageshake/api/src/main/res/values-ro/translations.xml @@ -2,4 +2,4 @@ "%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?" "Se pare că scuturați telefonul de frustrare. Doriți să deschdeți ecranul de raportare a unei erori?" - \ No newline at end of file + diff --git a/features/rageshake/api/src/main/res/values/localazy.xml b/features/rageshake/api/src/main/res/values/localazy.xml index 112cc427ba..bb694f2d00 100644 --- a/features/rageshake/api/src/main/res/values/localazy.xml +++ b/features/rageshake/api/src/main/res/values/localazy.xml @@ -2,4 +2,4 @@ "%1$s crashed the last time it was used. Would you like to share a crash report with us?" "You seem to be shaking the phone in frustration. Would you like to open the bug report screen?" - \ No newline at end of file + diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt index 05f3232aac..0e0ebaaab2 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt @@ -81,12 +81,9 @@ fun BugReportView( .systemBarsPadding() .imePadding() ) { - val scrollState = rememberScrollState() Column( modifier = Modifier - .verticalScroll( - state = scrollState, - ) + .verticalScroll(state = rememberScrollState()) .padding(horizontal = 16.dp), ) { val isError = state.sending is Async.Failure diff --git a/features/rageshake/impl/src/main/res/values-cs/translations.xml b/features/rageshake/impl/src/main/res/values-cs/translations.xml index 5a037a7596..5863cba70e 100644 --- a/features/rageshake/impl/src/main/res/values-cs/translations.xml +++ b/features/rageshake/impl/src/main/res/values-cs/translations.xml @@ -11,4 +11,4 @@ "Odeslat snímek obrazovky" "Aby bylo možné zkontrolovat, zda věci fungují podle očekávání, budou s vaší zprávou odeslány protokoly. Tyto budou soukromé. Chcete-li pouze odeslat zprávu, vypněte toto nastavení." "%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?" - \ No newline at end of file + diff --git a/features/rageshake/impl/src/main/res/values-de/translations.xml b/features/rageshake/impl/src/main/res/values-de/translations.xml index 8712bba1a0..437f5fff6f 100644 --- a/features/rageshake/impl/src/main/res/values-de/translations.xml +++ b/features/rageshake/impl/src/main/res/values-de/translations.xml @@ -11,4 +11,4 @@ "Bildschirmfoto senden" "Um zu überprüfen, ob alles wie vorgesehen funktioniert, werden Protokolle mit deiner Nachricht gesendet. Diese werden privat sein. Um nur Ihre Nachricht zu senden, schalte diese Einstellung aus." "%1$s ist bei der letzten Verwendung abgestürzt. Möchtest du uns einen Absturzbericht senden?" - \ No newline at end of file + diff --git a/features/rageshake/impl/src/main/res/values-es/translations.xml b/features/rageshake/impl/src/main/res/values-es/translations.xml index 0b1a374b97..4191f67596 100644 --- a/features/rageshake/impl/src/main/res/values-es/translations.xml +++ b/features/rageshake/impl/src/main/res/values-es/translations.xml @@ -11,4 +11,4 @@ "Enviar captura de pantalla" "Para comprobar que todo funciona correctamente, se enviarán registros de fallos con su mensaje. Serán privados. Para enviar sólo tu mensaje, desactiva esta opción." "%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?" - \ No newline at end of file + diff --git a/features/rageshake/impl/src/main/res/values-fr/translations.xml b/features/rageshake/impl/src/main/res/values-fr/translations.xml index e5e88975a6..84b3ad3386 100644 --- a/features/rageshake/impl/src/main/res/values-fr/translations.xml +++ b/features/rageshake/impl/src/main/res/values-fr/translations.xml @@ -11,4 +11,4 @@ "Envoyer une capture d’écran" "Pour vérifier que les choses fonctionnent comme prévu, les journaux seront envoyés avec votre message. Ceux-ci seront privées. Pour simplement envoyer votre message, désactivez ce paramètre." "%1$s a planté la dernière fois qu\'il a été utilisé. Souhaitez-vous partager un rapport de crash avec nous ?" - \ No newline at end of file + diff --git a/features/rageshake/impl/src/main/res/values-it/translations.xml b/features/rageshake/impl/src/main/res/values-it/translations.xml index c8a15eeedf..2c95849db0 100644 --- a/features/rageshake/impl/src/main/res/values-it/translations.xml +++ b/features/rageshake/impl/src/main/res/values-it/translations.xml @@ -11,4 +11,4 @@ "Invia istantanea schermo" "Per verificare che le cose funzionino come previsto, i log verranno inviati con il tuo messaggio. Questi saranno privati. Per inviare solo il tuo messaggio, disattiva questa impostazione." "%1$s si è chiuso inaspettatamente l\'ultima volta che è stato usato. Vuoi condividere con noi un rapporto sull\'arresto anomalo?" - \ 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 6d2657bc0c..db0398c0db 100644 --- a/features/rageshake/impl/src/main/res/values-ro/translations.xml +++ b/features/rageshake/impl/src/main/res/values-ro/translations.xml @@ -11,4 +11,4 @@ "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. Doriți să ne trimiteți un raport?" - \ No newline at end of file + diff --git a/features/rageshake/impl/src/main/res/values/localazy.xml b/features/rageshake/impl/src/main/res/values/localazy.xml index 192666fc1c..75db33350f 100644 --- a/features/rageshake/impl/src/main/res/values/localazy.xml +++ b/features/rageshake/impl/src/main/res/values/localazy.xml @@ -11,4 +11,4 @@ "Send screenshot" "To check things work as intended, logs will be sent with your message. These will be private. To just send your message, turn off this setting." "%1$s crashed the last time it was used. Would you like to share a crash report with us?" - \ No newline at end of file + diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt index ced5e79bbe..867d2f433d 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt @@ -147,7 +147,7 @@ private fun RoomInviteMembersSearchBar( selectedUsers: ImmutableList, active: Boolean, modifier: Modifier = Modifier, - placeHolderTitle: String = stringResource(io.element.android.libraries.ui.strings.R.string.common_search_for_someone), + placeHolderTitle: String = stringResource(StringR.string.common_search_for_someone), onActiveChanged: (Boolean) -> Unit = {}, onTextChanged: (String) -> Unit = {}, onUserToggled: (MatrixUser) -> Unit = {}, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt index 62b3d07a85..f995cb14b6 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt @@ -89,9 +89,10 @@ fun RoomMemberDetailsView( Spacer(modifier = Modifier.height(26.dp)) - SendMessageSection(onSendMessage = { - // TODO implement send DM - }) + // TODO implement send DM + // SendMessageSection(onSendMessage = { + // ... + // }) if (!state.isCurrentUser) { BlockUserSection(state) diff --git a/features/roomdetails/impl/src/main/res/values-cs/translations.xml b/features/roomdetails/impl/src/main/res/values-cs/translations.xml index fad77eb62f..745764cee9 100644 --- a/features/roomdetails/impl/src/main/res/values-cs/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-cs/translations.xml @@ -13,6 +13,8 @@ "Nelze aktualizovat místnost" "Zprávy jsou zabezpečeny zámky. Pouze vy a příjemci máte jedinečné klíče k jejich odemčení." "Šifrování zpráv povoleno" + "Pozvat lidi" + "Oznámení" "Název místnosti" "Sdílet místnost" "Aktualizace místnosti…" @@ -28,4 +30,4 @@ "Lidé" "Zabezpečení" "Téma" - \ No newline at end of file + diff --git a/features/roomdetails/impl/src/main/res/values-de/translations.xml b/features/roomdetails/impl/src/main/res/values-de/translations.xml index feb3c47866..0211825920 100644 --- a/features/roomdetails/impl/src/main/res/values-de/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-de/translations.xml @@ -9,9 +9,14 @@ "Bereits eingeladen" "Raum bearbeiten" "Wir konnten nicht alle Informationen für diesen Raum aktualisieren." + "Raum konnte nicht aktualisiert werden" "Nachrichten sind mit Schlössern gesichert. Nur du und der Empfänger haben die eindeutigen Schlüssel, um sie zu entsperren." "Nachrichtenverschlüsselung aktiviert" + "Personen einladen" + "Raumname" "Raum teilen" + "Aktualisiere Raum…" + "Ausstehend" "Raummitglieder" "Blockieren" "Blockierte Benutzer können dir keine Nachrichten senden und alle Nachrichten von ihnen werden ausgeblendet. Du kannst diese Aktion jederzeit rückgängig machen." @@ -23,4 +28,4 @@ "Personen" "Sicherheit" "Thema" - \ 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..42bce4b756 100644 --- a/features/roomdetails/impl/src/main/res/values-es/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-es/translations.xml @@ -18,4 +18,4 @@ "Personas" "Seguridad" "Tema" - \ No newline at end of file + diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml index 4c2296fd97..6037e57a7a 100644 --- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml @@ -6,6 +6,7 @@ "Les messages sont sécurisés par des verrous. Seuls vous et les destinataires possédez les clés uniques pour les déverrouiller." "Chiffrement des messages activé" + "Inviter des personnes" "Partager la salle" "Bloquer" "Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez annuler cette action à tout moment." @@ -17,4 +18,4 @@ "Personnes" "Sécurité" "Sujet" - \ No newline at end of file + 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..190eda82ee 100644 --- a/features/roomdetails/impl/src/main/res/values-it/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-it/translations.xml @@ -18,4 +18,4 @@ "Persone" "Sicurezza" "Oggetto" - \ No newline at end of file + 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 93feec60c4..61599d1ceb 100644 --- a/features/roomdetails/impl/src/main/res/values-ro/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml @@ -7,10 +7,13 @@ "Adăugare subiect" "Deja membru" "Deja invitat" + "Editați camera" "A apărut o eroare la actualizarea detaliilor camerei" + "Nu s-a putut actualiza camera" "Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca." "Criptarea mesajelor este activată" "Invitați persoane" + "Numele camerei" "Partajați camera" "Se actualizează camera…" "În așteptare" @@ -25,4 +28,4 @@ "Persoane" "Securitate" "Subiect" - \ No newline at end of file + diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml index 5fffafb51d..17e5a56825 100644 --- a/features/roomdetails/impl/src/main/res/values/localazy.xml +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -13,6 +13,7 @@ "Messages are secured with locks. Only you and the recipients have the unique keys to unlock them." "Message encryption enabled" "Invite people" + "Notification" "Room name" "Share room" "Updating room…" @@ -28,4 +29,4 @@ "People" "Security" "Topic" - \ No newline at end of file + 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 cf1d9a49ac..f85942c3ee 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 @@ -27,7 +27,6 @@ import io.element.android.features.roomdetails.impl.RoomTopicState import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter 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.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState @@ -48,7 +47,7 @@ class RoomDetailsPresenterTests { private fun aRoomDetailsPresenter(room: MatrixRoom): RoomDetailsPresenter { val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory { override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { - return RoomMemberDetailsPresenter(aMatrixClient(), room, roomMemberId) + return RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMemberId) } } return RoomDetailsPresenter(room, roomMemberDetailsPresenterFactory, LeaveRoomPresenterFake()) @@ -250,10 +249,6 @@ class RoomDetailsPresenterTests { } } -fun aMatrixClient( - sessionId: SessionId = A_SESSION_ID, -) = FakeMatrixClient() - fun aMatrixRoom( roomId: RoomId = A_ROOM_ID, name: String? = A_ROOM_NAME, diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt index c1d098dab5..df80f40e9b 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt @@ -105,7 +105,8 @@ class RoomDetailsEditPresenterTest { val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL).apply { givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(true)) givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(false)) - givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops"))) } + givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops"))) + } val presenter = aRoomDetailsEditPresenter(room) moleculeFlow(RecompositionClock.Immediate) { @@ -381,7 +382,7 @@ class RoomDetailsEditPresenterTest { initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic")) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) initialState.eventSink(RoomDetailsEditEvents.Save) - + skipItems(5) assertThat(room.newName).isEqualTo("New name") assertThat(room.newTopic).isEqualTo("New topic") assertThat(room.newAvatarData).isNull() @@ -476,7 +477,7 @@ class RoomDetailsEditPresenterTest { initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(RoomDetailsEditEvents.Save) - skipItems(2) + skipItems(3) assertThat(room.newName).isNull() assertThat(room.newTopic).isNull() @@ -501,7 +502,7 @@ class RoomDetailsEditPresenterTest { initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(RoomDetailsEditEvents.Save) - skipItems(1) + skipItems(2) assertThat(room.newName).isNull() assertThat(room.newTopic).isNull() @@ -567,7 +568,7 @@ class RoomDetailsEditPresenterTest { initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo")) initialState.eventSink(RoomDetailsEditEvents.Save) - skipItems(1) + skipItems(2) assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java) @@ -588,6 +589,7 @@ class RoomDetailsEditPresenterTest { initialState.eventSink(RoomDetailsEditEvents.Save) skipItems(1) + assertThat(awaitItem().saveAction).isInstanceOf(Async.Loading::class.java) assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java) } } @@ -599,14 +601,17 @@ class RoomDetailsEditPresenterTest { } fakePickerProvider.givenResult(anotherAvatarUri) - fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.AnyFile( - file = processedFile, - info = mockk(), - ))) + fakeMediaPreProcessor.givenResult( + Result.success( + MediaUploadInfo.AnyFile( + file = processedFile, + info = mockk(), + ) + ) + ) } companion object { private const val ANOTHER_AVATAR_URL = "example://camera/foo.jpg" } - } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt index 3baea96990..8600cefeac 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt @@ -38,6 +38,7 @@ import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.test.FakeUserRepository import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test @@ -92,9 +93,8 @@ internal class RoomInviteMembersPresenterTest { val presenter = RoomInviteMembersPresenter( userRepository = repository, roomMemberListDataSource = createDataSource(FakeMatrixRoom()), - coroutineDispatchers = testCoroutineDispatchers() + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) ) - moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -119,9 +119,8 @@ internal class RoomInviteMembersPresenterTest { val presenter = RoomInviteMembersPresenter( userRepository = repository, roomMemberListDataSource = createDataSource(FakeMatrixRoom()), - coroutineDispatchers = testCoroutineDispatchers() + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) ) - moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -156,17 +155,24 @@ internal class RoomInviteMembersPresenterTest { val invitedUser = userList[1] val repository = FakeUserRepository() + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) val presenter = RoomInviteMembersPresenter( userRepository = repository, - roomMemberListDataSource = createDataSource(FakeMatrixRoom().apply { - givenRoomMembersState(MatrixRoomMembersState.Ready(listOf( - aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN), - aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE), - ))) - }), - coroutineDispatchers = testCoroutineDispatchers() + roomMemberListDataSource = createDataSource( + matrixRoom = FakeMatrixRoom().apply { + givenRoomMembersState( + MatrixRoomMembersState.Ready( + listOf( + aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN), + aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE), + ) + ) + ) + }, + coroutineDispatchers = coroutineDispatchers, + ), + coroutineDispatchers = coroutineDispatchers ) - moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -214,12 +220,16 @@ internal class RoomInviteMembersPresenterTest { val presenter = RoomInviteMembersPresenter( userRepository = repository, roomMemberListDataSource = createDataSource(FakeMatrixRoom().apply { - givenRoomMembersState(MatrixRoomMembersState.Ready(listOf( - aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN), - aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE), - ))) + givenRoomMembersState( + MatrixRoomMembersState.Ready( + listOf( + aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN), + aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE), + ) + ) + ) }), - coroutineDispatchers = testCoroutineDispatchers() + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) ) moleculeFlow(RecompositionClock.Immediate) { @@ -286,9 +296,8 @@ internal class RoomInviteMembersPresenterTest { val presenter = RoomInviteMembersPresenter( userRepository = repository, roomMemberListDataSource = createDataSource(FakeMatrixRoom()), - coroutineDispatchers = testCoroutineDispatchers() + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) ) - moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -322,16 +331,14 @@ internal class RoomInviteMembersPresenterTest { } } - @Test fun `present - toggling a user updates existing search results`() = runTest { val repository = FakeUserRepository() val presenter = RoomInviteMembersPresenter( userRepository = repository, roomMemberListDataSource = createDataSource(FakeMatrixRoom()), - coroutineDispatchers = testCoroutineDispatchers() + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) ) - moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -368,7 +375,7 @@ internal class RoomInviteMembersPresenterTest { } } - private fun createDataSource( + private fun TestScope.createDataSource( matrixRoom: MatrixRoom = aMatrixRoom().apply { givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList())) }, 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 9625a167f7..01f60847f8 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,7 +20,6 @@ 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 @@ -35,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test @@ -165,16 +165,16 @@ class RoomMemberListPresenterTests { } @ExperimentalCoroutinesApi -private fun createDataSource( - matrixRoom: MatrixRoom = aMatrixRoom().apply { +private fun TestScope.createDataSource( + matrixRoom: MatrixRoom = FakeMatrixRoom().apply { givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList())) }, coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers() ) = RoomMemberListDataSource(matrixRoom, coroutineDispatchers) @ExperimentalCoroutinesApi -private fun createPresenter( +private fun TestScope.createPresenter( + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), matrixRoom: MatrixRoom = FakeMatrixRoom(), - roomMemberListDataSource: RoomMemberListDataSource = createDataSource(), - coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers() + roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers), ) = RoomMemberListPresenter(matrixRoom, 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 294b689ea9..912f354c89 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 @@ -20,13 +20,13 @@ 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.aMatrixClient import io.element.android.features.roomdetails.aMatrixRoom 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 import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.test.FakeMatrixClient import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test @@ -34,8 +34,6 @@ import org.junit.Test @ExperimentalCoroutinesApi class RoomMemberDetailsPresenterTests { - private val matrixClient = aMatrixClient() - @Test fun `present - returns the room member's data, then updates it if needed`() = runTest { val roomMember = aRoomMember(displayName = "Alice") @@ -44,7 +42,7 @@ class RoomMemberDetailsPresenterTests { givenUserAvatarUrlResult(Result.success("A custom avatar")) givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember))) } - val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId) + val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -53,7 +51,7 @@ class RoomMemberDetailsPresenterTests { Truth.assertThat(initialState.userName).isEqualTo(roomMember.displayName) Truth.assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl) Truth.assertThat(initialState.isBlocked).isEqualTo(roomMember.isIgnored) - + skipItems(1) val loadedState = awaitItem() Truth.assertThat(loadedState.userName).isEqualTo("A custom name") Truth.assertThat(loadedState.avatarUrl).isEqualTo("A custom avatar") @@ -68,7 +66,7 @@ class RoomMemberDetailsPresenterTests { givenUserAvatarUrlResult(Result.failure(Throwable())) givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember))) } - val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId) + val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -88,7 +86,7 @@ class RoomMemberDetailsPresenterTests { givenUserAvatarUrlResult(Result.success(null)) givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember))) } - val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId) + val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -104,7 +102,7 @@ class RoomMemberDetailsPresenterTests { fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest { val room = aMatrixRoom() val roomMember = aRoomMember() - val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId) + val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -125,7 +123,7 @@ class RoomMemberDetailsPresenterTests { fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest { val room = aMatrixRoom() val roomMember = aRoomMember() - val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId) + val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -142,7 +140,7 @@ class RoomMemberDetailsPresenterTests { fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest { val room = aMatrixRoom() val roomMember = aRoomMember() - val presenter =RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId) + val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt index 50a7a7bfbe..3eb2ab848d 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt @@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.core.RoomId class RoomListNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: RoomListPresenter, + private val presenter: RoomListPresenter, ) : Node(buildContext, plugins = plugins) { private fun onRoomClicked(roomId: RoomId) { diff --git a/features/roomlist/impl/src/main/res/values-cs/translations.xml b/features/roomlist/impl/src/main/res/values-cs/translations.xml index a222367f09..d355d2c70c 100644 --- a/features/roomlist/impl/src/main/res/values-cs/translations.xml +++ b/features/roomlist/impl/src/main/res/values-cs/translations.xml @@ -4,4 +4,4 @@ "Všechny chaty" "Zdá se, že používáte nové zařízení. Ověřte přihlášení, abyste měli přístup k zašifrovaným zprávám." "Přístup k historii zpráv" - \ No newline at end of file + diff --git a/features/roomlist/impl/src/main/res/values-de/translations.xml b/features/roomlist/impl/src/main/res/values-de/translations.xml index ab61ed4c4e..2ed1cd0263 100644 --- a/features/roomlist/impl/src/main/res/values-de/translations.xml +++ b/features/roomlist/impl/src/main/res/values-de/translations.xml @@ -4,4 +4,4 @@ "Alle Chats" "Es sieht so aus, als ob du ein neues Gerät verwendest. Verifiziere, dass du es bist, um auf deine verschlüsselten Nachrichten zuzugreifen." "Greife auf deine Nachrichten-Historie zu" - \ No newline at end of file + diff --git a/features/roomlist/impl/src/main/res/values-es/translations.xml b/features/roomlist/impl/src/main/res/values-es/translations.xml index 7edd6192a1..899b1e2cac 100644 --- a/features/roomlist/impl/src/main/res/values-es/translations.xml +++ b/features/roomlist/impl/src/main/res/values-es/translations.xml @@ -4,4 +4,4 @@ "Todos los chats" "Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados." "Accede a tu historial de mensajes" - \ No newline at end of file + diff --git a/features/roomlist/impl/src/main/res/values-fr/translations.xml b/features/roomlist/impl/src/main/res/values-fr/translations.xml index 6a049a4e72..3f22122014 100644 --- a/features/roomlist/impl/src/main/res/values-fr/translations.xml +++ b/features/roomlist/impl/src/main/res/values-fr/translations.xml @@ -4,4 +4,4 @@ "Toutes les conversations" "Il semblerait que vous utilisiez un nouvel appareil. Vérifiez que vous êtes bien autorisé à accéder à vos messages cryptés." "Accédez à l\'historique de vos messages" - \ No newline at end of file + diff --git a/features/roomlist/impl/src/main/res/values-it/translations.xml b/features/roomlist/impl/src/main/res/values-it/translations.xml index 6bfb8baa0c..cbe93e52d9 100644 --- a/features/roomlist/impl/src/main/res/values-it/translations.xml +++ b/features/roomlist/impl/src/main/res/values-it/translations.xml @@ -4,4 +4,4 @@ "Tutte le conversazioni" "Sembra che tu stia utilizzando un nuovo dispositivo. Verifica di essere tu per accedere ai tuoi messaggi crittografati." "Accedi alla cronologia dei messaggi" - \ No newline at end of file + diff --git a/features/roomlist/impl/src/main/res/values-ro/translations.xml b/features/roomlist/impl/src/main/res/values-ro/translations.xml index 7401b30b82..b8ffc57090 100644 --- a/features/roomlist/impl/src/main/res/values-ro/translations.xml +++ b/features/roomlist/impl/src/main/res/values-ro/translations.xml @@ -4,4 +4,4 @@ "Toate conversatiile" "Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea pentru acces la mesajele dumneavoastră criptate." "Accesați istoricul mesajelor" - \ No newline at end of file + diff --git a/features/roomlist/impl/src/main/res/values/localazy.xml b/features/roomlist/impl/src/main/res/values/localazy.xml index 613e6681ae..e18d4c9017 100644 --- a/features/roomlist/impl/src/main/res/values/localazy.xml +++ b/features/roomlist/impl/src/main/res/values/localazy.xml @@ -4,4 +4,4 @@ "All Chats" "Looks like you’re using a new device. Verify it’s you to access your encrypted messages." "Access your message history" - \ No newline at end of file + 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 040a5b5b50..389aee1e92 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 @@ -69,7 +69,7 @@ internal class DefaultInviteStateDataSourceTest { val client = FakeMatrixClient(invitesDataSource = matrixDataSource) val seenStore = FakeSeenInvitesStore() seenStore.publishRoomIds(setOf(A_ROOM_ID)) - val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers()) + val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers(useUnconfinedTestDispatcher = true)) moleculeFlow(RecompositionClock.Immediate) { dataSource.inviteState() @@ -86,7 +86,7 @@ internal class DefaultInviteStateDataSourceTest { val client = FakeMatrixClient(invitesDataSource = matrixDataSource) val seenStore = FakeSeenInvitesStore() seenStore.publishRoomIds(setOf(A_ROOM_ID)) - val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers()) + val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers(useUnconfinedTestDispatcher = true)) moleculeFlow(RecompositionClock.Immediate) { dataSource.inviteState() 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 fe76d9837b..37623b4f28 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 @@ -35,7 +35,6 @@ import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME -import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -51,7 +50,7 @@ class RoomListPresenterTests { @Test fun `present - should start with no user and then load user with success`() = runTest { val presenter = RoomListPresenter( - FakeMatrixClient(A_SESSION_ID), + FakeMatrixClient(), createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), @@ -77,7 +76,6 @@ class RoomListPresenterTests { fun `present - should start with no user and then load user with error`() = runTest { val presenter = RoomListPresenter( FakeMatrixClient( - A_SESSION_ID, userDisplayName = Result.failure(AN_EXCEPTION), userAvatarURLString = Result.failure(AN_EXCEPTION), ), @@ -102,7 +100,7 @@ class RoomListPresenterTests { @Test fun `present - should filter room with success`() = runTest { val presenter = RoomListPresenter( - FakeMatrixClient(A_SESSION_ID), + FakeMatrixClient(), createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), @@ -130,7 +128,6 @@ class RoomListPresenterTests { val roomSummaryDataSource = FakeRoomSummaryDataSource() val presenter = RoomListPresenter( FakeMatrixClient( - sessionId = A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource ), createDateFormatter(), @@ -163,7 +160,6 @@ class RoomListPresenterTests { val roomSummaryDataSource = FakeRoomSummaryDataSource() val presenter = RoomListPresenter( FakeMatrixClient( - sessionId = A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource ), createDateFormatter(), @@ -202,7 +198,6 @@ class RoomListPresenterTests { val roomSummaryDataSource = FakeRoomSummaryDataSource() val presenter = RoomListPresenter( FakeMatrixClient( - sessionId = A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource ), createDateFormatter(), @@ -251,7 +246,6 @@ class RoomListPresenterTests { val roomSummaryDataSource = FakeRoomSummaryDataSource() val presenter = RoomListPresenter( FakeMatrixClient( - sessionId = A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource ), createDateFormatter(), @@ -280,9 +274,7 @@ class RoomListPresenterTests { fun `present - sets invite state`() = runTest { val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites) val presenter = RoomListPresenter( - FakeMatrixClient( - sessionId = A_SESSION_ID, - ), + FakeMatrixClient(), createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), @@ -312,7 +304,7 @@ class RoomListPresenterTests { @Test fun `present - show context menu`() = runTest { val presenter = RoomListPresenter( - FakeMatrixClient(A_SESSION_ID), + FakeMatrixClient(), createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), @@ -339,7 +331,7 @@ class RoomListPresenterTests { @Test fun `present - hide context menu`() = runTest { val presenter = RoomListPresenter( - FakeMatrixClient(A_SESSION_ID), + FakeMatrixClient(), createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), @@ -371,7 +363,7 @@ class RoomListPresenterTests { fun `present - leave room calls into leave room presenter`() = runTest { val leaveRoomPresenter = LeaveRoomPresenterFake() val presenter = RoomListPresenter( - FakeMatrixClient(A_SESSION_ID), + FakeMatrixClient(), createDateFormatter(), FakeRoomLastMessageFormatter(), FakeSessionVerificationService(), diff --git a/features/verifysession/impl/src/main/res/values-cs/translations.xml b/features/verifysession/impl/src/main/res/values-cs/translations.xml index 41f95d41fe..6bf8db5ac0 100644 --- a/features/verifysession/impl/src/main/res/values-cs/translations.xml +++ b/features/verifysession/impl/src/main/res/values-cs/translations.xml @@ -16,4 +16,4 @@ "Čekání na přijetí žádosti" "Ověření zrušeno" "Začít" - \ No newline at end of file + diff --git a/features/verifysession/impl/src/main/res/values-de/translations.xml b/features/verifysession/impl/src/main/res/values-de/translations.xml index 53479844b4..f5f149cfd9 100644 --- a/features/verifysession/impl/src/main/res/values-de/translations.xml +++ b/features/verifysession/impl/src/main/res/values-de/translations.xml @@ -16,4 +16,4 @@ "Warten auf die Annahme der Anfrage" "Verifizierung abgebrochen" "Starten" - \ No newline at end of file + diff --git a/features/verifysession/impl/src/main/res/values-es/translations.xml b/features/verifysession/impl/src/main/res/values-es/translations.xml index ccc656e845..386ecfc37c 100644 --- a/features/verifysession/impl/src/main/res/values-es/translations.xml +++ b/features/verifysession/impl/src/main/res/values-es/translations.xml @@ -16,4 +16,4 @@ "A la espera de aceptar la solicitud" "Verificación cancelada" "Comenzar" - \ No newline at end of file + diff --git a/features/verifysession/impl/src/main/res/values-fr/translations.xml b/features/verifysession/impl/src/main/res/values-fr/translations.xml index cd06c5db8b..71e8015827 100644 --- a/features/verifysession/impl/src/main/res/values-fr/translations.xml +++ b/features/verifysession/impl/src/main/res/values-fr/translations.xml @@ -16,4 +16,4 @@ "En attente d\'acceptation de la demande" "Vérification annulée" "Démarrer" - \ No newline at end of file + diff --git a/features/verifysession/impl/src/main/res/values-it/translations.xml b/features/verifysession/impl/src/main/res/values-it/translations.xml index 1bf0e87ea9..7a6765adbf 100644 --- a/features/verifysession/impl/src/main/res/values-it/translations.xml +++ b/features/verifysession/impl/src/main/res/values-it/translations.xml @@ -16,4 +16,4 @@ "In attesa di accettare la richiesta" "Verifica annullata" "Inizia" - \ No newline at end of file + diff --git a/features/verifysession/impl/src/main/res/values-ro/translations.xml b/features/verifysession/impl/src/main/res/values-ro/translations.xml index 3ad0de6e56..e392438bcd 100644 --- a/features/verifysession/impl/src/main/res/values-ro/translations.xml +++ b/features/verifysession/impl/src/main/res/values-ro/translations.xml @@ -16,4 +16,4 @@ "Se așteptă acceptarea cererii" "Verificare anulată" "Începeți" - \ No newline at end of file + diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml index c217f0d2a4..67dc975128 100644 --- a/features/verifysession/impl/src/main/res/values/localazy.xml +++ b/features/verifysession/impl/src/main/res/values/localazy.xml @@ -16,4 +16,4 @@ "Waiting to accept request" "Verification cancelled" "Start" - \ No newline at end of file + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 11d6e102ff..86a18b013b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ media3 = "1.0.2" browser = "1.5.0" # Compose -compose_bom = "2023.06.00" +compose_bom = "2023.06.01" composecompiler = "1.4.7" # Coroutines @@ -50,6 +50,9 @@ telephoto = "0.4.0" dagger = "2.46.1" anvil = "2.4.6" +# Auto service +autoservice = "1.1.1" + # quality detekt = "1.23.0" dependencygraph = "0.12" @@ -61,7 +64,7 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3" kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } # https://firebase.google.com/docs/android/setup#available-libraries -google_firebase_bom = "com.google.firebase:firebase-bom:32.1.0" +google_firebase_bom = "com.google.firebase:firebase-bom:32.1.1" # AndroidX androidx_material = { module = "com.google.android.material:material", version.ref = "material" } @@ -121,8 +124,8 @@ test_mockk = "io.mockk:mockk:1.13.5" test_barista = "com.adevinta.android:barista:4.3.0" test_hamcrest = "org.hamcrest:hamcrest:2.2" test_orchestrator = "androidx.test:orchestrator:1.4.2" -test_turbine = "app.cash.turbine:turbine:0.13.0" -test_truth = "com.google.truth:truth:1.1.4" +test_turbine = "app.cash.turbine:turbine:1.0.0" +test_truth = "com.google.truth:truth:1.1.5" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.12" test_robolectric = "org.robolectric:robolectric:4.10.3" test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } @@ -139,7 +142,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.16" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.22" 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" } @@ -155,7 +158,7 @@ statemachine = "com.freeletics.flowredux:compose:1.1.0" # Analytics posthog = "com.posthog.android:posthog:2.0.3" -sentry_android = "io.sentry:sentry-android:6.22.0" +sentry_android = "io.sentry:sentry-android:6.23.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:main-SNAPSHOT" # Di @@ -165,11 +168,16 @@ dagger_compiler = { module = "com.google.dagger:dagger-compiler", version.ref = anvil_compiler_api = { module = "com.squareup.anvil:compiler-api", version.ref = "anvil" } anvil_compiler_utils = { module = "com.squareup.anvil:compiler-utils", version.ref = "anvil" } +# Auto services +google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } +google_autoservice_annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoservice" } + + # Miscellaneous # Add unused dependency to androidx.compose.compiler:compiler to let Renovate create PR to change the # value of `composecompiler` (which is used to set composeOptions.kotlinCompilerExtensionVersion. # See https://github.com/renovatebot/renovate/issues/18354 -android_composeCompiler = {module="androidx.compose.compiler:compiler", version.ref ="composecompiler"} +android_composeCompiler = { module = "androidx.compose.compiler:compiler", version.ref = "composecompiler" } [bundles] @@ -182,11 +190,11 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } anvil = { id = "com.squareup.anvil", version.ref = "anvil" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } -ktlint = "org.jlleitschuh.gradle.ktlint:11.4.0" +ktlint = "org.jlleitschuh.gradle.ktlint:11.4.2" dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" } dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" } dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyanalysis" } paparazzi = "app.cash.paparazzi:1.2.0" -sonarqube = "org.sonarqube:4.2.0.3129" +sonarqube = "org.sonarqube:4.2.1.3168" kover = "org.jetbrains.kotlinx.kover:0.6.1" sqldelight = { id = "com.squareup.sqldelight", version.ref = "sqldelight" } diff --git a/libraries/androidutils/build.gradle.kts b/libraries/androidutils/build.gradle.kts index e9a8feaa05..f98914e08a 100644 --- a/libraries/androidutils/build.gradle.kts +++ b/libraries/androidutils/build.gradle.kts @@ -28,5 +28,6 @@ dependencies { implementation(libs.androidx.activity.activity) implementation(libs.androidx.exifinterface) implementation(libs.androidx.security.crypto) + implementation(libs.androidx.browser) implementation(projects.libraries.core) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/Extensions.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt similarity index 97% rename from features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/Extensions.kt rename to libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt index be98566e7c..ec0d9662c7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/Extensions.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.features.login.impl.oidc.customtab +package io.element.android.libraries.androidutils.browser import android.app.Activity import android.content.ActivityNotFoundException 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 581d45a2b0..269407d3b5 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 @@ -35,6 +35,19 @@ fun File.safeDelete() { ) } +fun File.safeRenameTo(dest: File) { + tryOrNull( + onError = { + Timber.e(it, "Error, unable to rename file $path to ${dest.path}") + }, + operation = { + if (renameTo(dest).not()) { + Timber.w("Warning, unable to rename file $path to ${dest.path}") + } + } + ) +} + fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File { val suffix = extension?.let { ".$extension" } return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt index 3b29787285..8f942957e0 100644 --- 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 @@ -20,5 +20,9 @@ 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() } + return try { + block() + } finally { + release() + } } diff --git a/libraries/androidutils/src/main/res/values-cs/translations.xml b/libraries/androidutils/src/main/res/values-cs/translations.xml index ab592fee1d..345812c6ff 100644 --- a/libraries/androidutils/src/main/res/values-cs/translations.xml +++ b/libraries/androidutils/src/main/res/values-cs/translations.xml @@ -1,4 +1,4 @@ "Nebyla nalezena žádná kompatibilní aplikace, která by tuto akci zpracovala." - \ No newline at end of file + diff --git a/libraries/androidutils/src/main/res/values-de/translations.xml b/libraries/androidutils/src/main/res/values-de/translations.xml index a34a5b393b..d30d83f831 100644 --- a/libraries/androidutils/src/main/res/values-de/translations.xml +++ b/libraries/androidutils/src/main/res/values-de/translations.xml @@ -1,4 +1,4 @@ "Keine kompatible App für diese Aktion gefunden." - \ No newline at end of file + diff --git a/libraries/androidutils/src/main/res/values-es/translations.xml b/libraries/androidutils/src/main/res/values-es/translations.xml index 80b2b88347..d95373265c 100644 --- a/libraries/androidutils/src/main/res/values-es/translations.xml +++ b/libraries/androidutils/src/main/res/values-es/translations.xml @@ -1,4 +1,4 @@ "No se encontró ninguna aplicación compatible con esta acción." - \ No newline at end of file + diff --git a/libraries/androidutils/src/main/res/values-fr/translations.xml b/libraries/androidutils/src/main/res/values-fr/translations.xml index d564c18817..b974766fce 100644 --- a/libraries/androidutils/src/main/res/values-fr/translations.xml +++ b/libraries/androidutils/src/main/res/values-fr/translations.xml @@ -1,4 +1,4 @@ "Aucune application compatible n\'a été trouvée pour gérer cette action." - \ No newline at end of file + diff --git a/libraries/androidutils/src/main/res/values-it/translations.xml b/libraries/androidutils/src/main/res/values-it/translations.xml index 03aaf3ffd1..fcafd9fe3f 100644 --- a/libraries/androidutils/src/main/res/values-it/translations.xml +++ b/libraries/androidutils/src/main/res/values-it/translations.xml @@ -1,4 +1,4 @@ "Non è stata trovata alcuna app compatibile per gestire questa azione." - \ No newline at end of file + diff --git a/libraries/androidutils/src/main/res/values-ro/translations.xml b/libraries/androidutils/src/main/res/values-ro/translations.xml index d2149227c5..eac7dd0285 100644 --- a/libraries/androidutils/src/main/res/values-ro/translations.xml +++ b/libraries/androidutils/src/main/res/values-ro/translations.xml @@ -1,4 +1,4 @@ "Nu a fost găsită nicio aplicație capabilă să gestioneze această acțiune." - \ No newline at end of file + diff --git a/libraries/androidutils/src/main/res/values/localazy.xml b/libraries/androidutils/src/main/res/values/localazy.xml index 0599c8922b..741c1b20ec 100644 --- a/libraries/androidutils/src/main/res/values/localazy.xml +++ b/libraries/androidutils/src/main/res/values/localazy.xml @@ -1,4 +1,4 @@ "No compatible app was found to handle this action." - \ No newline at end of file + diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt index 3be961598d..301dcfd936 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt @@ -36,17 +36,20 @@ sealed interface Async { } } -suspend fun (suspend () -> T).execute(state: MutableState>, errorMapping: ((Throwable) -> Throwable)? = null) { +suspend inline fun (suspend () -> T).execute( + state: MutableState>, + errorMapping: ((Throwable) -> Throwable) = { it }, +) { try { state.value = Async.Loading() val result = this() state.value = Async.Success(result) } catch (error: Throwable) { - state.value = Async.Failure(errorMapping?.invoke(error) ?: error) + state.value = Async.Failure(errorMapping.invoke(error)) } } -suspend fun (suspend () -> Result).executeResult(state: MutableState>) { +suspend inline fun (suspend () -> Result).executeResult(state: MutableState>) { if (state.value !is Async.Success) { state.value = Async.Loading() } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt new file mode 100644 index 0000000000..752c80175d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.LocalColors +import io.element.android.libraries.designsystem.theme.components.Icon + +/** + * RoundedIconAtom is an atom which displays an icon inside a rounded container. + * + * @param modifier the modifier to apply to this layout + * @param size the size of the icon + * @param resourceId the resource id of the icon to display, exclusive with [imageVector] + * @param imageVector the image vector of the icon to display, exclusive with [resourceId] + * @param tint the tint to apply to the icon + */ +@Composable +fun RoundedIconAtom( + modifier: Modifier = Modifier, + size: RoundedIconAtomSize = RoundedIconAtomSize.Large, + resourceId: Int? = null, + imageVector: ImageVector? = null, + tint: Color = MaterialTheme.colorScheme.secondary +) { + Box( + modifier = modifier + .size(size.toContainerSize()) + .background( + color = LocalColors.current.quinary, + shape = RoundedCornerShape(size.toCornerSize()) + ) + ) { + Icon( + modifier = Modifier + .align(Alignment.Center) + .size(size.toIconSize()), + tint = tint, + resourceId = resourceId, + imageVector = imageVector, + contentDescription = "", + ) + } +} + +private fun RoundedIconAtomSize.toContainerSize(): Dp { + return when (this) { + RoundedIconAtomSize.Medium -> 30.dp + RoundedIconAtomSize.Large -> 70.dp + } +} + +private fun RoundedIconAtomSize.toCornerSize(): Dp { + return when (this) { + RoundedIconAtomSize.Medium -> 8.dp + RoundedIconAtomSize.Large -> 14.dp + } +} + +private fun RoundedIconAtomSize.toIconSize(): Dp { + return when (this) { + RoundedIconAtomSize.Medium -> 16.dp + RoundedIconAtomSize.Large -> 48.dp + } +} + +@Preview +@Composable +internal fun RoundedIconAtomLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun RoundedIconAtomDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + RoundedIconAtom( + size = RoundedIconAtomSize.Medium, + imageVector = Icons.Filled.Home, + ) + RoundedIconAtom( + size = RoundedIconAtomSize.Large, + imageVector = Icons.Filled.Home, + ) + } +} + +enum class RoundedIconAtomSize { + Medium, + Large +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt index bda10b8ba2..24adf90156 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt @@ -16,55 +16,55 @@ package io.element.android.libraries.designsystem.atomic.molecules -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.R +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.LocalColors -import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text +/** + * IconTitleSubtitleMolecule is a molecule which displays an icon, a title and a subtitle. + * + * @param title the title to display + * @param subTitle the subtitle to display + * @param modifier the modifier to apply to this layout + * @param iconResourceId the resource id of the icon to display, exclusive with [iconImageVector] + * @param iconImageVector the image vector of the icon to display, exclusive with [iconResourceId] + * @param iconTint the tint to apply to the icon + */ @Composable fun IconTitleSubtitleMolecule( - iconResourceId: Int, title: String, subTitle: String, modifier: Modifier = Modifier, + iconResourceId: Int? = null, + iconImageVector: ImageVector? = null, + iconTint: Color = MaterialTheme.colorScheme.primary, ) { Column(modifier) { - Box( + RoundedIconAtom( modifier = Modifier - .size(width = 70.dp, height = 70.dp) - .align(Alignment.CenterHorizontally) - .background( - color = LocalColors.current.quinary, - shape = RoundedCornerShape(14.dp) - ) - ) { - Icon( - modifier = Modifier - .align(Alignment.Center) - .size(width = 48.dp, height = 48.dp), - tint = MaterialTheme.colorScheme.secondary, - resourceId = iconResourceId, - contentDescription = "", - ) - } + .align(Alignment.CenterHorizontally), + size = RoundedIconAtomSize.Large, + resourceId = iconResourceId, + imageVector = iconImageVector, + tint = iconTint, + ) Spacer(modifier = Modifier.height(16.dp)) Text( text = title, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt index 81a2ca36fc..71a41c2460 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt @@ -37,8 +37,8 @@ fun LabelledTextField( value: String, modifier: Modifier = Modifier, placeholder: String? = null, - maxLines: Int = Int.MAX_VALUE, singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, onValueChange: (String) -> Unit = {}, ) { Column( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt index 5863f4c80c..e81bc140bf 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt @@ -24,12 +24,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.R as StringR @Composable fun AsyncFailure( @@ -43,11 +45,11 @@ fun AsyncFailure( .padding(vertical = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text(text = throwable.message ?: "An error occurred") + Text(text = throwable.message ?: stringResource(id = StringR.string.error_unknown)) if (onRetry != null) { Spacer(modifier = Modifier.height(24.dp)) Button(onClick = onRetry) { - Text(text = "Retry") + Text(text = stringResource(id = StringR.string.action_retry)) } } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt index c7366dcfb7..0de4dbba78 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt @@ -22,5 +22,5 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @Composable -public fun textFieldState(stateValue: String): MutableState = +fun textFieldState(stateValue: String): MutableState = remember(stateValue) { mutableStateOf(stateValue) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceScreen.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceScreen.kt index 10578ebcca..3826e6ebde 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceScreen.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceScreen.kt @@ -69,14 +69,11 @@ fun PreferenceView( ) }, content = { - val scrollState = rememberScrollState() Column( modifier = Modifier .padding(it) .consumeWindowInsets(it) - .verticalScroll( - state = scrollState, - ) + .verticalScroll(state = rememberScrollState()) ) { content() } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt new file mode 100644 index 0000000000..7d4de84301 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.designsystem.text + +import android.graphics.Typeface +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import io.element.android.libraries.designsystem.LinkColor + +fun String.toAnnotatedString(): AnnotatedString = buildAnnotatedString { + append(this@toAnnotatedString) + val spannable = SpannableString(this@toAnnotatedString) + spannable.getSpans(0, spannable.length, Any::class.java).forEach { span -> + val start = spannable.getSpanStart(span) + val end = spannable.getSpanEnd(span) + when (span) { + is StyleSpan -> when (span.style) { + Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + Typeface.BOLD_ITALIC -> addStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), start, end) + } + is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end) + is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end) + } + } +} + +/** + * Convert a string to an [AnnotatedString] with styles applied. + * + * @param fullTextRes the string resource to use as the full text. Must contain a single %s + * @param coloredTextRes the string resource to use as the colored part of the string + * @param color the color to apply to the string + * @param underline whether to underline the string + * @param bold whether to bold the string + */ +@Composable +fun buildAnnotatedStringWithStyledPart( + @StringRes fullTextRes: Int, + @StringRes coloredTextRes: Int, + color: Color = LinkColor, + underline: Boolean = true, + bold: Boolean = false, +) = buildAnnotatedString { + val coloredPart = stringResource(coloredTextRes) + val fullText = stringResource(fullTextRes, coloredPart) + val startIndex = fullText.indexOf(coloredPart) + append(fullText) + addStyle( + style = SpanStyle( + color = color, + textDecoration = if (underline) TextDecoration.Underline else null, + fontWeight = if (bold) FontWeight.Bold else null, + ), + start = startIndex, + end = startIndex + coloredPart.length, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Icon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Icon.kt index dedf2af060..24de433058 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Icon.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Icon.kt @@ -30,6 +30,54 @@ import androidx.compose.ui.tooling.preview.Preview import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +/** + * Icon is a wrapper around [androidx.compose.material3.Icon] which allows to use + * [ImageVector], [ImageBitmap] or [DrawableRes] as icon source. + * + * @param contentDescription the content description to be used for accessibility + * @param modifier the modifier to apply to this layout + * @param tint the tint to apply to the icon + * @param imageVector the image vector of the icon to display, exclusive with [bitmap] and [resourceId] + * @param bitmap the bitmap of the icon to display, exclusive with [imageVector] and [resourceId] + * @param resourceId the resource id of the icon to display, exclusive with [imageVector] and [bitmap] + */ +@Composable +fun Icon( + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, + imageVector: ImageVector? = null, + bitmap: ImageBitmap? = null, + @DrawableRes resourceId: Int? = null, +) { + when { + imageVector != null -> { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = modifier, + tint = tint + ) + } + bitmap != null -> { + Icon( + bitmap = bitmap, + contentDescription = contentDescription, + modifier = modifier, + tint = tint + ) + } + resourceId != null -> { + Icon( + resourceId = resourceId, + contentDescription = contentDescription, + modifier = modifier, + tint = tint + ) + } + } +} + @Composable fun Icon( imageVector: ImageVector, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt index 0c98caaa7d..27d9f101f7 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.BottomSheetDefaults @@ -37,6 +38,8 @@ 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.preview.PreviewGroup +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -50,6 +53,7 @@ fun ModalBottomSheet( tonalElevation: Dp = BottomSheetDefaults.Elevation, scrimColor: Color = BottomSheetDefaults.ScrimColor, dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + windowInsets: WindowInsets = BottomSheetDefaults.windowInsets, content: @Composable ColumnScope.() -> Unit, ) { androidx.compose.material3.ModalBottomSheet( @@ -62,10 +66,19 @@ fun ModalBottomSheet( tonalElevation = tonalElevation, scrimColor = scrimColor, dragHandle = dragHandle, + windowInsets = windowInsets, content = content, ) } +@OptIn(ExperimentalMaterial3Api::class) +fun SheetState.hide(coroutineScope: CoroutineScope, then: suspend () -> Unit) { + coroutineScope.launch { + hide() + then() + } +} + // This preview and its screenshots are blank, see: https://issuetracker.google.com/issues/283843380 @Preview(group = PreviewGroup.BottomSheets) @Composable diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt index 423d1e46d1..358ca2abab 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt @@ -67,7 +67,7 @@ fun OutlinedTextField( keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = false, - maxLines: Int = Int.MAX_VALUE, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, shape: Shape = OutlinedTextFieldDefaults.shape, colors: TextFieldColors = OutlinedTextFieldDefaults.colors() diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt index 8376369fba..2ad41887aa 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt @@ -68,7 +68,7 @@ fun TextField( keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = false, - maxLines: Int = Int.MAX_VALUE, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, shape: Shape = TextFieldDefaults.shape, colors: TextFieldColors = TextFieldDefaults.colors() diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt index 35b81ff324..1de46c78e0 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt @@ -22,13 +22,14 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.res.stringResource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -52,19 +53,16 @@ class SnackbarDispatcher { } } +/** Used to provide a [SnackbarDispatcher] to composable functions, it's needed for [rememberSnackbarHostState]. */ +val LocalSnackbarDispatcher = compositionLocalOf { + error("No SnackbarDispatcher provided") +} + @Composable fun handleSnackbarMessage( snackbarDispatcher: SnackbarDispatcher ): SnackbarMessage? { - val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null) - LaunchedEffect(snackbarMessage) { - if (snackbarMessage != null) { - launch { - snackbarDispatcher.clear() - } - } - } - return snackbarMessage + return snackbarDispatcher.snackbarMessage.collectAsState(initial = null).value } @Composable @@ -74,6 +72,7 @@ fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostSt val snackbarMessageText = snackbarMessage?.let { stringResource(id = snackbarMessage.messageResId) } + val dispatcher = LocalSnackbarDispatcher.current LaunchedEffect(snackbarMessage) { if (snackbarMessageText == null) return@LaunchedEffect coroutineScope.launch { @@ -81,6 +80,9 @@ fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostSt message = snackbarMessageText, duration = snackbarMessage.duration, ) + if (isActive) { + dispatcher.clear() + } } } return snackbarHostState diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt index 169ec55a9d..ccf9c2976a 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StateContent import io.element.android.services.toolbox.api.strings.StringProvider import timber.log.Timber import javax.inject.Inject +import io.element.android.libraries.ui.strings.R as StringR class StateContentFormatter @Inject constructor( private val sp: StringProvider, @@ -49,7 +50,7 @@ class StateContentFormatter @Inject constructor( sp.getString(R.string.state_event_room_created, senderDisplayName) } } - is OtherState.RoomEncryption -> sp.getString(io.element.android.libraries.ui.strings.R.string.common_encryption_enabled) + is OtherState.RoomEncryption -> sp.getString(StringR.string.common_encryption_enabled) is OtherState.RoomName -> { val hasRoomName = content.name != null when { diff --git a/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml b/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml index ebb2826b86..69179b1276 100644 --- a/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml +++ b/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml @@ -54,4 +54,4 @@ "%1$s zrušil(a) vykázání %2$s" "Zrušili jste vykázání pro %1$s" "%1$s provedl(a) neznámou změnu svého členství" - \ No newline at end of file + diff --git a/libraries/eventformatter/impl/src/main/res/values-de/translations.xml b/libraries/eventformatter/impl/src/main/res/values-de/translations.xml index 62854b7d46..0ca17bc4fe 100644 --- a/libraries/eventformatter/impl/src/main/res/values-de/translations.xml +++ b/libraries/eventformatter/impl/src/main/res/values-de/translations.xml @@ -54,4 +54,4 @@ "%1$s hat %2$s entbannt" "Du hast %1$s entbannt" "%1$s hat eine unbekannte Änderung an seiner Mitgliedschaft vorgenommen" - \ No newline at end of file + diff --git a/libraries/eventformatter/impl/src/main/res/values-es/translations.xml b/libraries/eventformatter/impl/src/main/res/values-es/translations.xml index 701f56f41c..dc732d9e97 100644 --- a/libraries/eventformatter/impl/src/main/res/values-es/translations.xml +++ b/libraries/eventformatter/impl/src/main/res/values-es/translations.xml @@ -54,4 +54,4 @@ "%1$s readmitió a %2$s" "Readmitiste a %1$s" "%1$s realizó un cambio desconocido en su membresía" - \ No newline at end of file + diff --git a/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml b/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml index bdf693e976..f69ea03050 100644 --- a/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml +++ b/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml @@ -54,4 +54,4 @@ "%1$s a débanni %2$s" "Vous avez débanni %1$s" "%1$s a apporté une modification inconnue à son adhésion" - \ No newline at end of file + diff --git a/libraries/eventformatter/impl/src/main/res/values-it/translations.xml b/libraries/eventformatter/impl/src/main/res/values-it/translations.xml index 0380d802f4..2e2e914dfe 100644 --- a/libraries/eventformatter/impl/src/main/res/values-it/translations.xml +++ b/libraries/eventformatter/impl/src/main/res/values-it/translations.xml @@ -54,4 +54,4 @@ "%1$s ha sbloccato %2$s" "Hai sbloccato %1$s" "%1$s ha apportato una modifica sconosciuta alla propria iscrizione" - \ No newline at end of file + diff --git a/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml index 2e3abf93d0..2586ad3cd2 100644 --- a/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml +++ b/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml @@ -54,4 +54,4 @@ "%1$s a anulat interdicția pentru %2$s" "Ați anulat interdicția pentru %1$s" "%1$s a făcut o modificare necunoscută asupra calității sale de membru" - \ No newline at end of file + diff --git a/libraries/eventformatter/impl/src/main/res/values/localazy.xml b/libraries/eventformatter/impl/src/main/res/values/localazy.xml index 2fd4217cd4..03a13bd29b 100644 --- a/libraries/eventformatter/impl/src/main/res/values/localazy.xml +++ b/libraries/eventformatter/impl/src/main/res/values/localazy.xml @@ -54,4 +54,4 @@ "%1$s unbanned %2$s" "You unbanned %1$s" "%1$s made an unknown change to their membership" - \ No newline at end of file + diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index f0dec42855..4a018e18da 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.matrix.api +import io.element.android.libraries.matrix.api.core.ProgressCallback 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 @@ -52,7 +53,7 @@ interface MatrixClient : Closeable { suspend fun logout() suspend fun loadUserDisplayName(): Result suspend fun loadUserAvatarURLString(): Result - suspend fun uploadMedia(mimeType: String, data: ByteArray): Result + suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result fun onSlidingSyncUpdate() fun roomMembershipObserver(): RoomMembershipObserver } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt index 4e6d468c54..bc0f0c04bc 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt @@ -34,7 +34,7 @@ object MatrixPatterns { val PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER = MATRIX_USER_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE) // regex pattern to find room ids in a string. - private const val MATRIX_ROOM_IDENTIFIER_REGEX = "![A-Z0-9-]+$DOMAIN_REGEX" + private const val MATRIX_ROOM_IDENTIFIER_REGEX = "![A-Z0-9.-]+$DOMAIN_REGEX" private val PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER = MATRIX_ROOM_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE) // regex pattern to find room aliases in a string. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt new file mode 100644 index 0000000000..2b41907eec --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.core + +interface ProgressCallback { + fun onProgress(current: Long, total: Long) +} 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 6ed6e474b6..e9708a6926 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 @@ -16,8 +16,10 @@ package io.element.android.libraries.matrix.api.media +import java.time.Duration + data class AudioInfo( - val duration: Long?, + val duration: Duration?, val size: Long?, val mimeType: String?, ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt index aa291bd653..b7af54c6b2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt @@ -16,8 +16,10 @@ package io.element.android.libraries.matrix.api.media +import java.time.Duration + data class VideoInfo( - val duration: Long?, + val duration: Duration?, val height: Long?, val width: Long?, val mimetype: String?, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/ForwardEventException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/ForwardEventException.kt new file mode 100644 index 0000000000..6b2813feb8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/ForwardEventException.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.room + +import io.element.android.libraries.matrix.api.core.RoomId + +class ForwardEventException( + val roomIds: List +) : Exception() { + + override val message: String? = "Failed to deliver event to $roomIds rooms" +} 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 92374b5b00..afd0e8ea25 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 @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.api.room import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.ProgressCallback 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 @@ -25,6 +26,7 @@ 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 io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import java.io.Closeable @@ -73,16 +75,22 @@ interface MatrixRoom : Closeable { suspend fun redactEvent(eventId: EventId, reason: String? = null): Result - suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result + suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result - suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result + suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result - suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result + suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result - suspend fun sendFile(file: File, fileInfo: FileInfo): Result + suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result suspend fun sendReaction(emoji: String, eventId: EventId): Result + suspend fun forwardEvent(eventId: EventId, rooms: List): Result + + suspend fun retrySendMessage(transactionId: String): Result + + suspend fun cancelSend(transactionId: String): Result + suspend fun leave(): Result suspend fun acceptInvitation(): Result @@ -95,6 +103,8 @@ interface MatrixRoom : Closeable { suspend fun canSendStateEvent(type: StateEventType): Result + suspend fun canSendEvent(type: MessageEventType): Result + suspend fun updateAvatar(mimeType: String, data: ByteArray): Result suspend fun removeAvatar(): Result @@ -102,4 +112,6 @@ interface MatrixRoom : Closeable { suspend fun setName(name: String): Result suspend fun setTopic(topic: String): Result + + suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt new file mode 100644 index 0000000000..109e50e602 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.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.matrix.api.room + +enum class MessageEventType { + CALL_ANSWER, + CALL_INVITE, + CALL_HANGUP, + CALL_CANDIDATES, + KEY_VERIFICATION_READY, + KEY_VERIFICATION_START, + KEY_VERIFICATION_CANCEL, + KEY_VERIFICATION_ACCEPT, + KEY_VERIFICATION_KEY, + KEY_VERIFICATION_MAC, + KEY_VERIFICATION_DONE, + REACTION_SENT, + ROOM_ENCRYPTED, + ROOM_MESSAGE, + ROOM_REDACTION, + STICKER +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt index 091691b0ec..4eaf04d775 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt @@ -42,4 +42,6 @@ interface MatrixTimeline { suspend fun editMessage(originalEventId: EventId, message: String): Result suspend fun replyMessage(inReplyToEventId: EventId, message: String): Result + + suspend fun fetchDetailsForEvent(eventId: EventId): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt index 547e593a42..f84f1875e4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt @@ -24,6 +24,7 @@ sealed interface MatrixTimelineItem { data class Event(val event: EventTimelineItem) : MatrixTimelineItem { val uniqueId: String = event.uniqueIdentifier val eventId: EventId? = event.eventId + val transactionId: String? = event.transactionId } data class Virtual(val virtual: VirtualTimelineItem) : MatrixTimelineItem diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index 05f440c413..2a5c068519 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugIn data class EventTimelineItem( val uniqueIdentifier: String, val eventId: EventId?, + val transactionId: String?, val isEditable: Boolean, val isLocal: Boolean, val isOwn: Boolean, 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 d8cd8c480f..268e09c764 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 @@ -20,6 +20,7 @@ package io.element.android.libraries.matrix.impl import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters @@ -28,6 +29,7 @@ import io.element.android.libraries.matrix.api.createroom.RoomVisibility import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.pusher.PushersService +import io.element.android.libraries.matrix.api.room.ForwardEventException import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource @@ -35,9 +37,11 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.impl.core.toProgressWatcher import io.element.android.libraries.matrix.impl.media.RustMediaLoader import io.element.android.libraries.matrix.impl.notification.RustNotificationService import io.element.android.libraries.matrix.impl.pushers.RustPushersService +import io.element.android.libraries.matrix.impl.room.RoomContentForwarder import io.element.android.libraries.matrix.impl.room.RustMatrixRoom import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy @@ -50,6 +54,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first @@ -60,6 +65,7 @@ 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.RoomMessageEventContent import org.matrix.rustcomponents.sdk.SlidingSyncList import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt @@ -197,6 +203,8 @@ class RustMatrixClient constructor( private val roomMembershipObserver = RoomMembershipObserver() + private val roomContentForwarder = RoomContentForwarder(slidingSync) + init { client.setDelegate(clientDelegate) rustRoomSummaryDataSource.init() @@ -218,6 +226,7 @@ class RustMatrixClient constructor( coroutineScope = coroutineScope, coroutineDispatchers = dispatchers, clock = clock, + roomContentForwarder = roomContentForwarder, ) } @@ -351,9 +360,9 @@ class RustMatrixClient constructor( } @OptIn(ExperimentalUnsignedTypes::class) - override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result = withContext(dispatchers.io) { + override suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result = withContext(dispatchers.io) { runCatching { - client.uploadMedia(mimeType, data.toUByteArray().toList()) + client.uploadMedia(mimeType, data.toUByteArray().toList(), progressCallback?.toProgressWatcher()) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/ProgressWatcherWrapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/ProgressWatcherWrapper.kt new file mode 100644 index 0000000000..f904e13bd6 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/ProgressWatcherWrapper.kt @@ -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. + */ + +package io.element.android.libraries.matrix.impl.core + +import io.element.android.libraries.matrix.api.core.ProgressCallback +import org.matrix.rustcomponents.sdk.ProgressWatcher +import org.matrix.rustcomponents.sdk.TransmissionProgress + +internal class ProgressWatcherWrapper(private val progressCallback: ProgressCallback) : ProgressWatcher { + override fun transmissionProgress(progress: TransmissionProgress) { + progressCallback.onProgress(progress.current.toLong(), progress.total.toLong()) + } +} + +internal fun ProgressCallback.toProgressWatcher(): ProgressWatcher { + return ProgressWatcherWrapper(this) +} 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 7c35c14fb7..2f0d6879a4 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 @@ -20,13 +20,13 @@ import io.element.android.libraries.matrix.api.media.AudioInfo import org.matrix.rustcomponents.sdk.AudioInfo as RustAudioInfo fun RustAudioInfo.map(): AudioInfo = AudioInfo( - duration = duration?.toLong(), + duration = duration, size = size?.toLong(), mimeType = mimetype ) fun AudioInfo.map(): RustAudioInfo = RustAudioInfo( - duration = duration?.toULong(), + duration = duration, size = size?.toULong(), mimetype = mimeType, ) 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 b474c2ab2e..661d1b9b33 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 @@ -20,7 +20,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo import org.matrix.rustcomponents.sdk.VideoInfo as RustVideoInfo fun RustVideoInfo.map(): VideoInfo = VideoInfo( - duration = duration?.toLong(), + duration = duration, height = height?.toLong(), width = width?.toLong(), mimetype = mimetype, @@ -31,7 +31,7 @@ fun RustVideoInfo.map(): VideoInfo = VideoInfo( ) fun VideoInfo.map(): RustVideoInfo = RustVideoInfo( - duration = duration?.toULong(), + duration = duration, height = height?.toULong(), width = width?.toULong(), mimetype = mimetype, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt index adb9dcce72..9b70582308 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt @@ -16,8 +16,6 @@ package io.element.android.libraries.matrix.impl.notification -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.notification.NotificationEvent import org.matrix.rustcomponents.sdk.MessageLikeEventContent import org.matrix.rustcomponents.sdk.MessageType @@ -105,5 +103,6 @@ private fun MessageType.toContent(): String { is MessageType.Notice -> content.body is MessageType.Text -> content.body is MessageType.Video -> content.use { it.body } + is MessageType.Location -> content.body } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt new file mode 100644 index 0000000000..a117c1d313 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.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.matrix.impl.room + +import io.element.android.libraries.matrix.api.room.MessageEventType +import org.matrix.rustcomponents.sdk.MessageLikeEventType + +fun MessageEventType.map(): MessageLikeEventType = when (this) { + MessageEventType.CALL_ANSWER -> MessageLikeEventType.CALL_ANSWER + MessageEventType.CALL_INVITE -> MessageLikeEventType.CALL_INVITE + MessageEventType.CALL_HANGUP -> MessageLikeEventType.CALL_HANGUP + MessageEventType.CALL_CANDIDATES -> MessageLikeEventType.CALL_CANDIDATES + MessageEventType.KEY_VERIFICATION_READY -> MessageLikeEventType.KEY_VERIFICATION_READY + MessageEventType.KEY_VERIFICATION_START -> MessageLikeEventType.KEY_VERIFICATION_START + MessageEventType.KEY_VERIFICATION_CANCEL -> MessageLikeEventType.KEY_VERIFICATION_CANCEL + MessageEventType.KEY_VERIFICATION_ACCEPT -> MessageLikeEventType.KEY_VERIFICATION_ACCEPT + MessageEventType.KEY_VERIFICATION_KEY -> MessageLikeEventType.KEY_VERIFICATION_KEY + MessageEventType.KEY_VERIFICATION_MAC -> MessageLikeEventType.KEY_VERIFICATION_MAC + MessageEventType.KEY_VERIFICATION_DONE -> MessageLikeEventType.KEY_VERIFICATION_DONE + MessageEventType.REACTION_SENT -> MessageLikeEventType.REACTION_SENT + MessageEventType.ROOM_ENCRYPTED -> MessageLikeEventType.ROOM_ENCRYPTED + MessageEventType.ROOM_MESSAGE -> MessageLikeEventType.ROOM_MESSAGE + MessageEventType.ROOM_REDACTION -> MessageLikeEventType.ROOM_REDACTION + MessageEventType.STICKER -> MessageLikeEventType.STICKER +} + +fun MessageLikeEventType.map(): MessageEventType = when (this) { + MessageLikeEventType.CALL_ANSWER -> MessageEventType.CALL_ANSWER + MessageLikeEventType.CALL_INVITE -> MessageEventType.CALL_INVITE + MessageLikeEventType.CALL_HANGUP -> MessageEventType.CALL_HANGUP + MessageLikeEventType.CALL_CANDIDATES -> MessageEventType.CALL_CANDIDATES + MessageLikeEventType.KEY_VERIFICATION_READY -> MessageEventType.KEY_VERIFICATION_READY + MessageLikeEventType.KEY_VERIFICATION_START -> MessageEventType.KEY_VERIFICATION_START + MessageLikeEventType.KEY_VERIFICATION_CANCEL -> MessageEventType.KEY_VERIFICATION_CANCEL + MessageLikeEventType.KEY_VERIFICATION_ACCEPT -> MessageEventType.KEY_VERIFICATION_ACCEPT + MessageLikeEventType.KEY_VERIFICATION_KEY -> MessageEventType.KEY_VERIFICATION_KEY + MessageLikeEventType.KEY_VERIFICATION_MAC -> MessageEventType.KEY_VERIFICATION_MAC + MessageLikeEventType.KEY_VERIFICATION_DONE -> MessageEventType.KEY_VERIFICATION_DONE + MessageLikeEventType.REACTION_SENT -> MessageEventType.REACTION_SENT + MessageLikeEventType.ROOM_ENCRYPTED -> MessageEventType.ROOM_ENCRYPTED + MessageLikeEventType.ROOM_MESSAGE -> MessageEventType.ROOM_MESSAGE + MessageLikeEventType.ROOM_REDACTION -> MessageEventType.ROOM_REDACTION + MessageLikeEventType.STICKER -> MessageEventType.STICKER +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt new file mode 100644 index 0000000000..1f68c14456 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.core.coroutine.parallelMap +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.room.ForwardEventException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withTimeout +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.SlidingSync +import org.matrix.rustcomponents.sdk.TimelineDiff +import org.matrix.rustcomponents.sdk.TimelineListener +import org.matrix.rustcomponents.sdk.genTransactionId +import kotlin.time.Duration.Companion.milliseconds + +/** + * Helper to forward event contents from a room to a set of other rooms. + * @param slidingSync the [SlidingSync] to fetch room instances to forward the event to + */ +class RoomContentForwarder( + private val slidingSync: SlidingSync, +) { + + /** + * Forwards the event with the given [eventId] from the [fromRoom] to the given [toRoomIds]. + * @param fromRoom the room to forward the event from + * @param eventId the id of the event to forward + * @param toRoomIds the ids of the rooms to forward the event to + * @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room + */ + suspend fun forward( + fromRoom: Room, + eventId: EventId, + toRoomIds: List, + timeoutMs: Long = 5000L + ) { + val content = fromRoom.getTimelineEventContentByEventId(eventId.value) + val targetSlidingSyncRooms = toRoomIds.mapNotNull { roomId -> slidingSync.getRoom(roomId.value) } + val targetRooms = targetSlidingSyncRooms.mapNotNull { slidingSyncRoom -> slidingSyncRoom.use { it.fullRoom() } } + val failedForwardingTo = mutableSetOf() + targetRooms.parallelMap { room -> + room.use { targetRoom -> + val result = runCatching { + // Sending a message requires a registered timeline listener + targetRoom.addTimelineListener(NoOpTimelineListener) + withTimeout(timeoutMs.milliseconds) { + targetRoom.send(content, genTransactionId()) + } + } + // After sending, we remove the timeline + targetRoom.removeTimeline() + result + }.onFailure { + failedForwardingTo.add(RoomId(room.id())) + if (it is CancellationException) { + throw it + } + } + } + + if (failedForwardingTo.isNotEmpty()) { + throw ForwardEventException(toRoomIds.toList()) + } + } + + private object NoOpTimelineListener: TimelineListener { + override fun onUpdate(diff: TimelineDiff) = Unit + } +} 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 63d54892f7..bc8525d87b 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 @@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.impl.room import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.ProgressCallback 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 @@ -27,14 +28,15 @@ 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.MessageEventType import io.element.android.libraries.matrix.api.room.StateEventType 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.core.toProgressWatcher import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -48,6 +50,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 timber.log.Timber import java.io.File class RustMatrixRoom( @@ -58,6 +61,7 @@ class RustMatrixRoom( private val coroutineScope: CoroutineScope, private val coroutineDispatchers: CoroutineDispatchers, private val clock: SystemClock, + private val roomContentForwarder: RoomContentForwarder, ) : MatrixRoom { override val membersStateFlow: StateFlow @@ -235,62 +239,103 @@ class RustMatrixRoom( } } - override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result = withContext(coroutineDispatchers.io) { + override suspend fun canSendEvent(type: MessageEventType): Result = withContext(coroutineDispatchers.io) { runCatching { - innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map()) + innerRoom.member(sessionId.value).use { it.canSendMessage(type.map()) } } } - override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result = withContext(coroutineDispatchers.io) { + override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result = withContext( + coroutineDispatchers.io + ) { runCatching { - innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map()) + innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map(), progressCallback?.toProgressWatcher()) } } - override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result = withContext(coroutineDispatchers.io) { + override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result = withContext( + coroutineDispatchers.io + ) { runCatching { - innerRoom.sendAudio(file.path, audioInfo.map()) + innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map(), progressCallback?.toProgressWatcher()) } } - override suspend fun sendFile(file: File, fileInfo: FileInfo): Result = withContext(coroutineDispatchers.io) { + override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result = withContext(coroutineDispatchers.io) { runCatching { - innerRoom.sendFile(file.path, fileInfo.map()) + innerRoom.sendAudio(file.path, audioInfo.map(), progressCallback?.toProgressWatcher()) } } - override suspend fun sendReaction(emoji: String, eventId: EventId): Result = withContext(Dispatchers.IO) { + override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result = withContext(coroutineDispatchers.io) { + runCatching { + innerRoom.sendFile(file.path, fileInfo.map(), progressCallback?.toProgressWatcher()) + } + } + + override suspend fun sendReaction(emoji: String, eventId: EventId): Result = withContext(coroutineDispatchers.io) { runCatching { innerRoom.sendReaction(key = emoji, eventId = eventId.value) } } + override suspend fun forwardEvent(eventId: EventId, roomIds: List): Result = withContext(coroutineDispatchers.io) { + runCatching { + roomContentForwarder.forward(fromRoom = innerRoom, eventId = eventId, toRoomIds = roomIds) + }.onFailure { + Timber.e(it) + } + } + + override suspend fun retrySendMessage(transactionId: String): Result = + withContext(coroutineDispatchers.io) { + runCatching { + innerRoom.retrySend(transactionId) + } + } + + override suspend fun cancelSend(transactionId: String): Result = + withContext(coroutineDispatchers.io) { + runCatching { + innerRoom.cancelSend(transactionId) + } + } + @OptIn(ExperimentalUnsignedTypes::class) override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result = - withContext(Dispatchers.IO) { + withContext(coroutineDispatchers.io) { runCatching { innerRoom.uploadAvatar(mimeType, data.toUByteArray().toList()) } } override suspend fun removeAvatar(): Result = - withContext(Dispatchers.IO) { + withContext(coroutineDispatchers.io) { runCatching { innerRoom.removeAvatar() } } override suspend fun setName(name: String): Result = - withContext(Dispatchers.IO) { + withContext(coroutineDispatchers.io) { runCatching { innerRoom.setName(name) } } override suspend fun setTopic(topic: String): Result = - withContext(Dispatchers.IO) { + withContext(coroutineDispatchers.io) { runCatching { innerRoom.setTopic(topic) } } + + override suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result = withContext(coroutineDispatchers.io) { + runCatching { + innerRoom.reportContent(eventId = eventId.value, score = null, reason = reason) + if (blockUserId != null) { + innerRoom.ignoreUser(blockUserId.value) + } + } + } } 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 ab94298418..41c602f0d2 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 @@ -39,7 +39,7 @@ import org.matrix.rustcomponents.sdk.SlidingSync import org.matrix.rustcomponents.sdk.SlidingSyncList import org.matrix.rustcomponents.sdk.SlidingSyncListRoomsListDiff import org.matrix.rustcomponents.sdk.SlidingSyncSelectiveModeBuilder -import org.matrix.rustcomponents.sdk.SlidingSyncState +import org.matrix.rustcomponents.sdk.SlidingSyncListLoadingState import org.matrix.rustcomponents.sdk.UpdateSummary import timber.log.Timber import java.io.Closeable @@ -56,7 +56,7 @@ internal class RustRoomSummaryDataSource( private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatchers.io) private val roomSummaries = MutableStateFlow>(emptyList()) - private val state = MutableStateFlow(SlidingSyncState.NOT_LOADED) + private val state = MutableStateFlow(SlidingSyncListLoadingState.NOT_LOADED) fun init() { coroutineScope.launch { @@ -75,9 +75,9 @@ internal class RustRoomSummaryDataSource( .launchIn(this) slidingSyncList.state(this) - .onEach { slidingSyncState -> - Timber.v("New sliding sync state: $slidingSyncState") - state.value = slidingSyncState + .onEach { SlidingSyncListLoadingState -> + Timber.v("New sliding sync state: $SlidingSyncListLoadingState") + state.value = SlidingSyncListLoadingState }.launchIn(this) } @@ -107,7 +107,7 @@ internal class RustRoomSummaryDataSource( private suspend fun didReceiveSyncUpdate(summary: UpdateSummary) { Timber.v("UpdateRooms with identifiers: ${summary.rooms}") - if (state.value != SlidingSyncState.FULLY_LOADED) { + if (state.value != SlidingSyncListLoadingState.FULLY_LOADED) { return } updateRoomSummaries { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncListFlows.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncListFlows.kt index 2aa0c59330..eb8019a79d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncListFlows.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncListFlows.kt @@ -21,11 +21,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import org.matrix.rustcomponents.sdk.SlidingSyncList +import org.matrix.rustcomponents.sdk.SlidingSyncListLoadingState import org.matrix.rustcomponents.sdk.SlidingSyncListRoomListObserver import org.matrix.rustcomponents.sdk.SlidingSyncListRoomsCountObserver import org.matrix.rustcomponents.sdk.SlidingSyncListRoomsListDiff import org.matrix.rustcomponents.sdk.SlidingSyncListStateObserver -import org.matrix.rustcomponents.sdk.SlidingSyncState fun SlidingSyncList.roomListDiff(scope: CoroutineScope): Flow = mxCallbackFlow { @@ -39,9 +39,9 @@ fun SlidingSyncList.roomListDiff(scope: CoroutineScope): Flow = mxCallbackFlow { +fun SlidingSyncList.state(scope: CoroutineScope): Flow = mxCallbackFlow { val observer = object : SlidingSyncListStateObserver { - override fun didReceiveUpdate(newState: SlidingSyncState) { + override fun didReceiveUpdate(newState: SlidingSyncListLoadingState) { scope.launch { send(newState) } 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 c90e672f28..f7cf728691 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 @@ -22,15 +22,13 @@ import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelin import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.TimelineItem -import timber.log.Timber class MatrixTimelineItemMapper( - private val room: Room, + private val fetchDetailsForEvent: suspend (EventId) -> Result, private val coroutineScope: CoroutineScope, private val virtualTimelineItemMapper: VirtualTimelineItemMapper = VirtualTimelineItemMapper(), - private val eventTimelineItemMapper: EventTimelineItemMapper= EventTimelineItemMapper(), + private val eventTimelineItemMapper: EventTimelineItemMapper = EventTimelineItemMapper(), ) { fun map(timelineItem: TimelineItem): MatrixTimelineItem = timelineItem.use { @@ -40,7 +38,7 @@ class MatrixTimelineItemMapper( if (eventTimelineItem.hasNotLoadedInReplyTo() && eventTimelineItem.eventId != null) { - fetchDetailsForEvent(eventTimelineItem.eventId!!) + fetchEventDetails(eventTimelineItem.eventId!!) } return MatrixTimelineItem.Event(eventTimelineItem) @@ -53,12 +51,7 @@ class MatrixTimelineItemMapper( return MatrixTimelineItem.Other } - private fun fetchDetailsForEvent(eventId: EventId) = coroutineScope.launch { - runCatching { - room.fetchDetailsForEvent(eventId.value) - }.onFailure { - Timber.e(it) - } + private fun fetchEventDetails(eventId: EventId) = coroutineScope.launch { + fetchDetailsForEvent(eventId) } - } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt index af27aedc13..4b07d59970 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt @@ -63,7 +63,7 @@ class RustMatrixTimeline( ) private val timelineItemFactory = MatrixTimelineItemMapper( - room = innerRoom, + fetchDetailsForEvent = this::fetchDetailsForEvent, coroutineScope = coroutineScope, virtualTimelineItemMapper = VirtualTimelineItemMapper(), eventTimelineItemMapper = EventTimelineItemMapper( @@ -130,6 +130,12 @@ class RustMatrixTimeline( return matrixRoom.replyMessage(inReplyToEventId, message) } + override suspend fun fetchDetailsForEvent(eventId: EventId): Result = withContext(coroutineDispatchers.io) { + runCatching { + innerRoom.fetchDetailsForEvent(eventId.value) + } + } + override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result = withContext(coroutineDispatchers.io) { runCatching { Timber.v("Start back paginating for room ${matrixRoom.roomId} ") @@ -138,7 +144,8 @@ class RustMatrixTimeline( } val paginationOptions = PaginationOptions.UntilNumItems( eventLimit = requestSize.toUShort(), - items = untilNumberOfItems.toUShort() + items = untilNumberOfItems.toUShort(), + waitForToken = true, ) innerRoom.paginateBackwards(paginationOptions) }.onFailure { 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 8a052d1a3a..d45124bf40 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 @@ -65,9 +65,11 @@ class EventMessageMapper { is MessageType.Video -> { VideoMessageType(type.content.body, type.content.source.map(), type.content.info?.map()) } + is MessageType.Location, null -> { UnknownMessageType } + } } val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId) @@ -103,7 +105,7 @@ private fun RustFormattedBody.map(): FormattedBody = FormattedBody( private fun RustMessageFormat.map(): MessageFormat { return when (this) { - RustMessageFormat.HTML -> MessageFormat.HTML - RustMessageFormat.UNKNOWN -> MessageFormat.UNKNOWN + RustMessageFormat.Html -> MessageFormat.HTML + is RustMessageFormat.Unknown -> MessageFormat.UNKNOWN } } 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 a77ddbd80f..bbb9c8fe2a 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 @@ -35,6 +35,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap EventTimelineItem( uniqueIdentifier = it.uniqueIdentifier(), eventId = it.eventId()?.let(::EventId), + transactionId = it.transactionId(), isEditable = it.isEditable(), isLocal = it.isLocal(), isOwn = it.isOwn(), diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 79a8186e1a..8b38a74457 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -16,8 +16,8 @@ package io.element.android.libraries.matrix.test -import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.ProgressCallback 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 @@ -37,18 +37,15 @@ import io.element.android.libraries.matrix.test.pushers.FakePushersService import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService -import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.test.StandardTestDispatcher class FakeMatrixClient( override val sessionId: SessionId = A_SESSION_ID, - private val coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), private val userDisplayName: Result = Result.success(A_USER_NAME), private val userAvatarURLString: Result = Result.success(AN_AVATAR_URL), override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), - override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(coroutineDispatchers), + override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(), private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), private val pushersService: FakePushersService = FakePushersService(), private val notificationService: FakeNotificationService = FakeNotificationService(), @@ -120,7 +117,11 @@ class FakeMatrixClient( return userAvatarURLString } - override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result { + override suspend fun uploadMedia( + mimeType: String, + data: ByteArray, + progressCallback: ProgressCallback? + ): Result { return uploadMediaResult } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index 7e54d8e851..4cc9422eb7 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -37,6 +37,7 @@ val A_ROOM_ID_2 = RoomId("!aRoomId2:domain") val A_THREAD_ID = ThreadId("\$aThreadId") val AN_EVENT_ID = EventId("\$anEventId") val AN_EVENT_ID_2 = EventId("\$anEventId2") +const val A_TRANSACTION_ID = "aTransactionId" const val A_UNIQUE_ID = "aUniqueId" const val A_ROOM_NAME = "A room name" @@ -54,8 +55,6 @@ const val AN_AVATAR_URL = "mxc://data" const val A_FAILURE_REASON = "There has been a failure" -const val FAKE_DELAY_IN_MS = 100L - val A_THROWABLE = Throwable(A_FAILURE_REASON) val AN_EXCEPTION = Exception(A_FAILURE_REASON) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt index 2b34a158a4..816bfc572a 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt @@ -22,8 +22,7 @@ import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_USER_ID -import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS -import kotlinx.coroutines.delay +import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -58,27 +57,24 @@ class FakeAuthenticationService : MatrixAuthenticationService { this.homeserver.value = homeserver } - override suspend fun setHomeserver(homeserver: String): Result { - delay(FAKE_DELAY_IN_MS) - return changeServerError?.let { Result.failure(it) } ?: Result.success(Unit) + override suspend fun setHomeserver(homeserver: String): Result = simulateLongTask { + changeServerError?.let { Result.failure(it) } ?: Result.success(Unit) } - override suspend fun login(username: String, password: String): Result { - delay(FAKE_DELAY_IN_MS) - return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID) + override suspend fun login(username: String, password: String): Result = simulateLongTask { + loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID) } - override suspend fun getOidcUrl(): Result { - return oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA) + override suspend fun getOidcUrl(): Result = simulateLongTask { + oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA) } override suspend fun cancelOidcLogin(): Result { return oidcCancelError?.let { Result.failure(it) } ?: Result.success(Unit) } - override suspend fun loginWithOidc(callbackUrl: String): Result { - delay(FAKE_DELAY_IN_MS) - return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID) + override suspend fun loginWithOidc(callbackUrl: String): Result = simulateLongTask { + loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID) } fun givenOidcError(throwable: Throwable?) { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt index 4282860c99..9ef0413a3a 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt @@ -16,20 +16,16 @@ package io.element.android.libraries.matrix.test.media -import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import kotlin.coroutines.coroutineContext +import io.element.android.tests.testutils.simulateLongTask -class FakeMediaLoader(private val coroutineDispatchers: CoroutineDispatchers) : MatrixMediaLoader { +class FakeMediaLoader : MatrixMediaLoader { var shouldFail = false - override suspend fun loadMediaContent(source: MediaSource): Result = withContext(coroutineDispatchers.io){ + override suspend fun loadMediaContent(source: MediaSource): Result = simulateLongTask { if (shouldFail) { Result.failure(RuntimeException()) } else { @@ -37,7 +33,7 @@ class FakeMediaLoader(private val coroutineDispatchers: CoroutineDispatchers) : } } - override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result = withContext(coroutineDispatchers.io){ + override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result = simulateLongTask { if (shouldFail) { Result.failure(RuntimeException()) } else { @@ -45,7 +41,7 @@ class FakeMediaLoader(private val coroutineDispatchers: CoroutineDispatchers) : } } - override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result = withContext(coroutineDispatchers.io){ + override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result = simulateLongTask { if (shouldFail) { Result.failure(RuntimeException()) } else { 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 1199d94ac4..d8187b0a1d 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 @@ -17,6 +17,7 @@ package io.element.android.libraries.matrix.test.room import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.ProgressCallback 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 @@ -26,13 +27,13 @@ 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.MessageEventType import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline -import kotlinx.coroutines.delay +import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow @@ -65,12 +66,17 @@ class FakeMatrixRoom( private var inviteUserResult = Result.success(Unit) private var canInviteResult = Result.success(true) private val canSendStateResults = mutableMapOf>() + private val canSendEventResults = mutableMapOf>() private var sendMediaResult = Result.success(Unit) private var setNameResult = Result.success(Unit) private var setTopicResult = Result.success(Unit) private var updateAvatarResult = Result.success(Unit) private var removeAvatarResult = Result.success(Unit) private var sendReactionResult = Result.success(Unit) + private var retrySendMessageResult = Result.success(Unit) + private var cancelSendResult = Result.success(Unit) + private var forwardEventResult = Result.success(Unit) + private var reportContentResult = Result.success(Unit) var sendMediaCount = 0 private set @@ -78,6 +84,15 @@ class FakeMatrixRoom( var sendReactionCount = 0 private set + var retrySendMessageCount: Int = 0 + private set + + var cancelSendCount: Int = 0 + private set + + var reportedContentCount: Int = 0 + private set + var isInviteAccepted: Boolean = false private set @@ -103,8 +118,8 @@ class FakeMatrixRoom( override val membersStateFlow: MutableStateFlow = MutableStateFlow(MatrixRoomMembersState.Unknown) - override suspend fun updateMembers(): Result { - return updateMembersResult + override suspend fun updateMembers(): Result = simulateLongTask { + updateMembersResult } override fun syncUpdateFlow(): Flow { @@ -115,17 +130,16 @@ class FakeMatrixRoom( return matrixTimeline } - override suspend fun userDisplayName(userId: UserId): Result { - return userDisplayNameResult + override suspend fun userDisplayName(userId: UserId): Result = simulateLongTask { + userDisplayNameResult } - override suspend fun userAvatarUrl(userId: UserId): Result { - return userAvatarUrlResult + override suspend fun userAvatarUrl(userId: UserId): Result = simulateLongTask { + userAvatarUrlResult } - override suspend fun sendMessage(message: String): Result { - delay(FAKE_DELAY_IN_MS) - return Result.success(Unit) + override suspend fun sendMessage(message: String): Result = simulateLongTask { + Result.success(Unit) } override suspend fun sendReaction(emoji: String, eventId: EventId): Result { @@ -133,12 +147,21 @@ class FakeMatrixRoom( return sendReactionResult } + override suspend fun retrySendMessage(transactionId: String): Result { + retrySendMessageCount++ + return retrySendMessageResult + } + + override suspend fun cancelSend(transactionId: String): Result { + cancelSendCount++ + return cancelSendResult + } + var editMessageParameter: String? = null private set override suspend fun editMessage(originalEventId: EventId, message: String): Result { editMessageParameter = message - delay(FAKE_DELAY_IN_MS) return Result.success(Unit) } @@ -147,7 +170,6 @@ class FakeMatrixRoom( override suspend fun replyMessage(eventId: EventId, message: String): Result { replyMessageParameter = message - delay(FAKE_DELAY_IN_MS) return Result.success(Unit) } @@ -156,11 +178,11 @@ class FakeMatrixRoom( override suspend fun redactEvent(eventId: EventId, reason: String?): Result { redactEventEventIdParam = eventId - delay(FAKE_DELAY_IN_MS) return Result.success(Unit) } override suspend fun leave(): Result = leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit) + override suspend fun acceptInvitation(): Result { isInviteAccepted = true return acceptInviteResult @@ -171,9 +193,9 @@ class FakeMatrixRoom( return rejectInviteResult } - override suspend fun inviteUserById(id: UserId): Result { + override suspend fun inviteUserById(id: UserId): Result = simulateLongTask { invitedUserId = id - return inviteUserResult + inviteUserResult } override suspend fun canInvite(): Result { @@ -184,39 +206,60 @@ class FakeMatrixRoom( return canSendStateResults[type] ?: Result.failure(IllegalStateException("No fake answer")) } - override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result = fakeSendMedia() + override suspend fun canSendEvent(type: MessageEventType): Result { + return canSendEventResults[type] ?: Result.failure(IllegalStateException("No fake answer")) + } - override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result = fakeSendMedia() + override suspend fun sendImage( + file: File, + thumbnailFile: File, + imageInfo: ImageInfo, + progressCallback: ProgressCallback? + ): Result = fakeSendMedia() - override suspend fun sendAudio(file: File, audioInfo: AudioInfo): Result = fakeSendMedia() + override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result = fakeSendMedia() - override suspend fun sendFile(file: File, fileInfo: FileInfo): Result = fakeSendMedia() + override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result = fakeSendMedia() - private suspend fun fakeSendMedia(): Result { - delay(FAKE_DELAY_IN_MS) - return sendMediaResult.onSuccess { + override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result = fakeSendMedia() + + override suspend fun forwardEvent(eventId: EventId, rooms: List): Result = simulateLongTask { + forwardEventResult + } + + private suspend fun fakeSendMedia(): Result = simulateLongTask { + sendMediaResult.onSuccess { sendMediaCount++ } } - override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result { + override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result = simulateLongTask { newAvatarData = data - return updateAvatarResult + updateAvatarResult } - override suspend fun removeAvatar(): Result { + override suspend fun removeAvatar(): Result = simulateLongTask { removedAvatar = true - return removeAvatarResult + removeAvatarResult } - override suspend fun setName(name: String): Result { + override suspend fun setName(name: String): Result = simulateLongTask { newName = name - return setNameResult + setNameResult } - override suspend fun setTopic(topic: String): Result { + override suspend fun setTopic(topic: String): Result = simulateLongTask { newTopic = topic - return setTopicResult + setTopicResult + } + + override suspend fun reportContent( + eventId: EventId, + reason: String, + blockUserId: UserId? + ): Result = simulateLongTask { + reportedContentCount++ + return reportContentResult } override fun close() = Unit @@ -261,6 +304,10 @@ class FakeMatrixRoom( canSendStateResults[type] = result } + fun givenCanSendEventResult(type: MessageEventType, result: Result) { + canSendEventResults[type] = result + } + fun givenIgnoreResult(result: Result) { ignoreResult = result } @@ -292,4 +339,20 @@ class FakeMatrixRoom( fun givenSendReactionResult(result: Result) { sendReactionResult = result } + + fun givenRetrySendMessageResult(result: Result) { + retrySendMessageResult = result + } + + fun givenCancelSendResult(result: Result) { + cancelSendResult = result + } + + fun givenForwardEventResult(result: Result) { + forwardEventResult = result + } + + fun givenReportContentResult(result: Result) { + reportContentResult = result + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt index e6ac93a3ab..e8e3ff38b9 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -89,6 +89,7 @@ fun aRoomMessage( fun anEventTimelineItem( uniqueIdentifier: String = A_UNIQUE_ID, eventId: EventId = AN_EVENT_ID, + transactionId: String? = null, isEditable: Boolean = false, isLocal: Boolean = false, isOwn: Boolean = false, @@ -103,6 +104,7 @@ fun anEventTimelineItem( ) = EventTimelineItem( uniqueIdentifier = uniqueIdentifier, eventId = eventId, + transactionId = transactionId, isEditable = isEditable, isLocal = isLocal, isOwn = isOwn, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt index bb5c0157b6..696a778df2 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt @@ -82,4 +82,8 @@ class FakeMatrixTimeline( override suspend fun replyMessage(inReplyToEventId: EventId, message: String): Result { return Result.success(Unit) } + + override suspend fun fetchDetailsForEvent(eventId: EventId): Result { + return Result.success(Unit) + } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt new file mode 100644 index 0000000000..da305ce212 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.strings.R as StringR + +@Composable +fun SelectedRoom( + roomSummary: RoomSummaryDetails, + modifier: Modifier = Modifier, + onRoomRemoved: (RoomSummaryDetails) -> Unit = {}, +) { + Box(modifier = modifier + .width(56.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Avatar(AvatarData(roomSummary.roomId.value, roomSummary.name, roomSummary.avatarURLString, AvatarSize.Custom(56.dp))) + Text( + text = roomSummary.name, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.bodyLarge, + ) + } + Surface( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .clip(CircleShape) + .size(20.dp) + .align(Alignment.TopEnd) + .clickable( + indication = rememberRipple(), + interactionSource = remember { MutableInteractionSource() }, + onClick = { onRoomRemoved(roomSummary) } + ), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(id = StringR.string.action_remove), + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(2.dp) + ) + } + } +} + +@Preview +@Composable +internal fun SelectedRoomLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun SelectedRoomDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + SelectedRoom(roomSummary = + RoomSummaryDetails( + roomId = RoomId("!room:domain"), + name = "roomName", + canonicalAlias = null, + isDirect = true, + avatarURLString = null, + lastMessage = null, + lastMessageTimestamp = null, + unreadNotificationCount = 0, + inviter = null, + ) + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt index d638db2902..8b421c6c28 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt @@ -17,42 +17,93 @@ package io.element.android.libraries.matrix.ui.media import coil.ImageLoader +import coil.decode.DataSource +import coil.decode.ImageSource import coil.fetch.FetchResult import coil.fetch.Fetcher +import coil.fetch.SourceResult import coil.request.Options import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.media.toFile +import okio.Buffer +import okio.Path.Companion.toOkioPath +import timber.log.Timber import java.nio.ByteBuffer internal class CoilMediaFetcher( private val mediaLoader: MatrixMediaLoader, private val mediaData: MediaRequestData?, - private val options: Options, - private val imageLoader: ImageLoader + private val options: Options ) : Fetcher { override suspend fun fetch(): FetchResult? { - return loadMedia() - .map { data -> - val byteBuffer = ByteBuffer.wrap(data) - imageLoader.components.newFetcher(byteBuffer, options, imageLoader)?.first?.fetch() - }.getOrThrow() - } - - private suspend fun loadMedia(): Result { - if (mediaData?.source == null) return Result.failure(IllegalStateException("No media data to fetch.")) + if (mediaData?.source == null) return null return when (mediaData.kind) { - is MediaRequestData.Kind.Content -> mediaLoader.loadMediaContent(source = mediaData.source) - is MediaRequestData.Kind.Thumbnail -> mediaLoader.loadMediaThumbnail( - source = mediaData.source, - width = mediaData.kind.width, - height = mediaData.kind.height - ) + is MediaRequestData.Kind.Content -> fetchContent(mediaData.source, options) + is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(mediaData.source, mediaData.kind, options) + is MediaRequestData.Kind.File -> fetchFile(mediaData.source, mediaData.kind) } } - class MediaRequestDataFactory(private val client: MatrixClient) : + /** + * This method is here to avoid using [MatrixMediaLoader.loadMediaContent] as too many ByteArray allocations will flood the memory and cause lots of GC. + * The MediaFile will be closed (and so destroyed from disk) when the image source is closed. + * + */ + private suspend fun fetchFile(mediaSource: MediaSource, kind: MediaRequestData.Kind.File): FetchResult? { + return mediaLoader.downloadMediaFile(mediaSource, kind.mimeType, kind.body) + .map { mediaFile -> + val file = mediaFile.toFile() + SourceResult( + source = ImageSource(file = file.toOkioPath(), closeable = mediaFile), + mimeType = null, + dataSource = DataSource.DISK + ) + } + .onFailure { + Timber.e(it) + } + .getOrNull() + } + + private suspend fun fetchContent(mediaSource: MediaSource, options: Options): FetchResult? { + return mediaLoader.loadMediaContent( + source = mediaSource, + ).map { byteArray -> + byteArray.asSourceResult(options) + }.getOrNull() + } + + private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail, options: Options): FetchResult? { + return mediaLoader.loadMediaThumbnail( + source = mediaSource, + width = kind.width, + height = kind.height + ).map { byteArray -> + byteArray.asSourceResult(options) + }.getOrNull() + } + + private fun ByteArray.asSourceResult(options: Options): SourceResult { + val byteBuffer = ByteBuffer.wrap(this) + val bufferedSource = try { + Buffer().apply { write(byteBuffer) } + } finally { + byteBuffer.position(0) + } + return SourceResult( + source = ImageSource(bufferedSource, options.context), + mimeType = null, + dataSource = DataSource.MEMORY + ) + } + + class MediaRequestDataFactory( + private val client: MatrixClient + ) : Fetcher.Factory { override fun create( data: MediaRequestData, @@ -62,13 +113,14 @@ internal class CoilMediaFetcher( return CoilMediaFetcher( mediaLoader = client.mediaLoader, mediaData = data, - options = options, - imageLoader = imageLoader + options = options ) } } - class AvatarFactory(private val client: MatrixClient) : + class AvatarFactory( + private val client: MatrixClient + ) : Fetcher.Factory { override fun create( @@ -79,8 +131,7 @@ internal class CoilMediaFetcher( return CoilMediaFetcher( mediaLoader = client.mediaLoader, mediaData = data.toMediaRequestData(), - options = options, - imageLoader = imageLoader + options = options ) } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt index 02a7ed4e8c..f2593766bc 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt @@ -18,17 +18,28 @@ package io.element.android.libraries.matrix.ui.media import io.element.android.libraries.matrix.api.media.MediaSource +/** + * Can be use with [coil.compose.AsyncImage] to load a [MediaSource]. + * This will go internally through our [CoilMediaFetcher]. + * + * Example of usage: + * AsyncImage( + * model = MediaRequestData(mediaSource, MediaRequestData.Kind.Content), + * contentScale = ContentScale.Fit, + * ) + * + */ data class MediaRequestData( val source: MediaSource?, val kind: Kind ) { sealed interface Kind { + object Content : Kind + data class File(val body: String?, val mimeType: String) : Kind data class Thumbnail(val width: Long, val height: Long) : Kind { constructor(size: Long) : this(size, size) } - - object Content : Kind } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt new file mode 100644 index 0000000000..4533f1f5ef --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt @@ -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. + */ + +package io.element.android.libraries.matrix.ui.room + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MessageEventType + +@Composable +fun MatrixRoom.canSendEventAsState(type: MessageEventType, updateKey: Long): State { + return produceState(initialValue = true, key1 = updateKey) { + value = canSendEvent(type).getOrElse { true } + } +} + diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index 585670d939..9f27824858 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -44,15 +44,29 @@ class MediaSender @Inject constructor( ): Result { return when (info) { is MediaUploadInfo.Image -> { - sendImage(info.file, info.thumbnailInfo.file, info.info) + sendImage( + file = info.file, + thumbnailFile = info.thumbnailFile, + imageInfo = info.info, + progressCallback = null + ) } is MediaUploadInfo.Video -> { - sendVideo(info.file, info.thumbnailInfo.file, info.info) + sendVideo( + file = info.file, + thumbnailFile = info.thumbnailFile, + videoInfo = info.info, + progressCallback = null + ) } is MediaUploadInfo.AnyFile -> { - sendFile(info.file, info.info) + sendFile( + file = info.file, + fileInfo = info.info, + progressCallback = null + ) } else -> Result.failure(IllegalStateException("Unexpected MediaUploadInfo format: $info")) } 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 47fa26ae79..5da3d36c44 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 @@ -19,7 +19,6 @@ 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 @@ -27,14 +26,8 @@ sealed interface MediaUploadInfo { val file: File - data class Image(override val file: File, val info: ImageInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo - data class Video(override val file: File, val info: VideoInfo, val thumbnailInfo: ThumbnailProcessingInfo) : MediaUploadInfo + data class Image(override val file: File, val info: ImageInfo, val thumbnailFile: File) : MediaUploadInfo + data class Video(override val file: File, val info: VideoInfo, val thumbnailFile: File) : MediaUploadInfo data class Audio(override val file: File, val info: AudioInfo) : MediaUploadInfo data class AnyFile(override val file: File, val info: FileInfo) : MediaUploadInfo } - -data class ThumbnailProcessingInfo( - val file: File, - val info: ThumbnailInfo, - val blurhash: String, -) diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt index 4882000307..9482aecc13 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt @@ -17,13 +17,14 @@ package io.element.android.libraries.mediaupload import android.content.Context -import android.graphics.Bitmap +import android.graphics.BitmapFactory 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.file.getFileName +import io.element.android.libraries.androidutils.file.safeRenameTo import io.element.android.libraries.androidutils.media.runAndRelease import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.data.tryOrNull @@ -37,26 +38,22 @@ 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.MediaSource -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.MediaUploadInfo -import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo 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 java.time.Duration import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds @ContributesBinding(AppScope::class) class AndroidMediaPreProcessor @Inject constructor( @ApplicationContext private val context: Context, + private val thumbnailFactory: ThumbnailFactory, private val imageCompressor: ImageCompressor, private val videoCompressor: VideoCompressor, private val coroutineDispatchers: CoroutineDispatchers, @@ -69,23 +66,6 @@ class AndroidMediaPreProcessor @Inject constructor( * 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 @@ -95,44 +75,38 @@ class AndroidMediaPreProcessor @Inject constructor( mimeType: String, deleteOriginal: Boolean, compressIfPossible: Boolean, - ): Result = runCatching { - val shouldBeCompressed = compressIfPossible && - (mimeType.isMimeTypeImage() && mimeType != MimeTypes.Gif) || - mimeType.isMimeTypeVideo() - - val result = if (shouldBeCompressed) { - when { - mimeType.isMimeTypeImage() -> processImage(uri) - mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType) + ): Result = withContext(coroutineDispatchers.computation) { + runCatching { + val result = when { + mimeType.isMimeTypeImage() -> processImage(uri, mimeType, compressIfPossible && mimeType != MimeTypes.Gif) + mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, compressIfPossible) mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType) - else -> error("Cannot compress file of type: $mimeType") + else -> processFile(uri, mimeType) } - } else { - val file = copyToTmpFile(uri) - // Remove image metadata here too - if (mimeType.isMimeTypeImage() && mimeType != MimeTypes.Gif) { - removeSensitiveImageMetadata(file) + if (deleteOriginal) { + tryOrNull { + contentResolver.delete(uri, null, null) + } } - val info = FileInfo( - mimetype = mimeType, - size = file.length(), - thumbnailInfo = null, - thumbnailSource = null, - ) - MediaUploadInfo.AnyFile(file, info) + result.postProcess(uri) } - if (deleteOriginal) { - tryOrNull { - contentResolver.delete(uri, null, null) - } - } - result.postProcess(uri) }.mapFailure { MediaPreProcessor.Failure(it) } + private suspend fun processFile(uri: Uri, mimeType: String): MediaUploadInfo { + val file = copyToTmpFile(uri) + val info = FileInfo( + mimetype = mimeType, + size = file.length(), + thumbnailInfo = null, + thumbnailSource = null, + ) + return MediaUploadInfo.AnyFile(file, info) + } + private fun MediaUploadInfo.postProcess(uri: Uri): MediaUploadInfo { val name = context.getFileName(uri) ?: return this val renamedFile = File(context.cacheDir, name).also { - file.renameTo(it) + file.safeRenameTo(it) } return when (this) { is MediaUploadInfo.AnyFile -> copy(file = renamedFile) @@ -142,42 +116,85 @@ class AndroidMediaPreProcessor @Inject constructor( } } - 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() + private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo { + + suspend fun processImageWithCompression(): MediaUploadInfo { + val compressionResult = contentResolver.openInputStream(uri).use { input -> + imageCompressor.compressToTmpFile( + inputStream = requireNotNull(input), + resizeMode = ResizeMode.Approximate(IMAGE_SCALE_REF_SIZE, IMAGE_SCALE_REF_SIZE), + ).getOrThrow() + } + val thumbnailResult: ThumbnailResult = thumbnailFactory.createImageThumbnail(compressionResult.file) + val imageInfo = compressionResult.toImageInfo( + mimeType = mimeType, + thumbnailResult = thumbnailResult + ) + removeSensitiveImageMetadata(compressionResult.file) + return MediaUploadInfo.Image( + file = compressionResult.file, + info = imageInfo, + thumbnailFile = thumbnailResult.file + ) } - removeSensitiveImageMetadata(compressedFileResult.file) + suspend fun processImageWithoutCompression(): MediaUploadInfo { + val file = copyToTmpFile(uri) + val thumbnailResult: ThumbnailResult = thumbnailFactory.createImageThumbnail(file) + val imageInfo = contentResolver.openInputStream(uri).use { input -> + val bitmap = BitmapFactory.decodeStream(input, null, null)!! + ImageInfo( + width = bitmap.width.toLong(), + height = bitmap.height.toLong(), + mimetype = mimeType, + size = file.length(), + thumbnailInfo = thumbnailResult.info, + thumbnailSource = null, + blurhash = thumbnailResult.blurhash, + ) + } + removeSensitiveImageMetadata(file) + return MediaUploadInfo.Image( + file = file, + info = imageInfo, + thumbnailFile = thumbnailResult.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) + return if (shouldBeCompressed) { + processImageWithCompression() + } else { + processImageWithoutCompression() + } } - private suspend fun processVideo(uri: Uri, mimeType: String?): MediaUploadInfo { - val thumbnailInfo = extractVideoThumbnail(uri) - val resultFile = videoCompressor.compress(uri) - .onEach { - // TODO handle progress - } - .filterIsInstance() - .first() - .file - - val videoProcessingInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo.file.path, thumbnailInfo) - return MediaUploadInfo.Video(resultFile, videoProcessingInfo, thumbnailInfo) + private suspend fun processVideo(uri: Uri, mimeType: String?, shouldBeCompressed: Boolean): MediaUploadInfo { + val resultFile = if (shouldBeCompressed) { + videoCompressor.compress(uri) + .onEach { + // TODO handle progress + } + .filterIsInstance() + .first() + .file + } else { + copyToTmpFile(uri) + } + val thumbnailInfo = thumbnailFactory.createVideoThumbnail(resultFile) + val videoInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo) + return MediaUploadInfo.Video( + file = resultFile, + info = videoInfo, + thumbnailFile = thumbnailInfo.file + ) } 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, + duration = extractDuration(), size = file.length(), mimeType = mimeType, ) @@ -186,15 +203,6 @@ class AndroidMediaPreProcessor @Inject constructor( } } - private suspend fun generateImageThumbnail(inputStream: InputStream): ThumbnailProcessingInfo { - val thumbnailResult = imageCompressor - .compressToTmpFile( - inputStream = inputStream, - resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), - ).getOrThrow() - return thumbnailResult.toThumbnailProcessingInfo(MimeTypes.Jpeg) - } - private fun removeSensitiveImageMetadata(file: File) { // Remove GPS info, user comments and subject location tags val exifInterface = ExifInterface(file) @@ -215,60 +223,41 @@ class AndroidMediaPreProcessor @Inject constructor( } } - private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailUrl: String?, thumbnailInfo: ThumbnailProcessingInfo?): VideoInfo = + private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailResult: ThumbnailResult): VideoInfo = MediaMetadataRetriever().runAndRelease { setDataSource(context, Uri.fromFile(file)) VideoInfo( - duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L, + duration = extractDuration(), width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L, height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L, mimetype = mimeType, size = file.length(), - thumbnailInfo = thumbnailInfo?.info, - thumbnailSource = thumbnailUrl?.let { MediaSource(it) }, - blurhash = thumbnailInfo?.blurhash, + thumbnailInfo = thumbnailResult.info, + // Will be computed by the rust sdk + thumbnailSource = null, + blurhash = thumbnailResult.blurhash, ) } - private suspend fun extractVideoThumbnail(uri: Uri): ThumbnailProcessingInfo = - MediaMetadataRetriever().runAndRelease { - setDataSource(context, uri) - val bitmap = requireNotNull(getFrameAtTime(VIDEO_THUMB_FRAME)) - val inputStream = ByteArrayOutputStream().use { - bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) - ByteArrayInputStream(it.toByteArray()) - } - - val result = imageCompressor.compressToTmpFile( - inputStream = inputStream, - resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), - ) - result.getOrThrow().toThumbnailProcessingInfo(MimeTypes.Jpeg) - } - private suspend fun copyToTmpFile(uri: Uri): File { return contentResolver.openInputStream(uri)?.use { createTmpFileWithInput(it) } ?: error("Could not copy the contents of $uri to a temporary file") } } -fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailUrl: String?, thumbnailInfo: ThumbnailInfo?) = ImageInfo( +fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailResult: ThumbnailResult) = ImageInfo( width = width.toLong(), height = height.toLong(), mimetype = mimeType, size = size, - thumbnailInfo = thumbnailInfo, - thumbnailSource = thumbnailUrl?.let { MediaSource(it) }, - blurhash = blurhash, + thumbnailInfo = thumbnailResult.info, + // Will be computed by the rust sdk + thumbnailSource = null, + blurhash = thumbnailResult.blurhash, ) -fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = ThumbnailProcessingInfo( - file = file, - info = ThumbnailInfo( - width = width.toLong(), - height = height.toLong(), - mimetype = mimeType, - size = size, - ), - blurhash = blurhash, -) +private fun MediaMetadataRetriever.extractDuration(): Duration { + val durationInMs = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L + return Duration.ofMillis(durationInMs) +} + 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 96d5e2ea63..2b8669fe42 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 @@ -42,27 +42,23 @@ class ImageCompressor @Inject constructor( * @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, + inputStream: InputStream, + resizeMode: ResizeMode, + format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, + desiredQuality: Int = 80, ): Result = withContext(Dispatchers.IO) { runCatching { val compressedBitmap = compressToBitmap(inputStream, resizeMode).getOrThrow() - val blurhash = BlurHash.encode(compressedBitmap, 3, 3) - // Encode bitmap to the destination temporary file val tmpFile = context.createTmpFile(extension = "jpeg") tmpFile.outputStream().use { compressedBitmap.compress(format, desiredQuality, it) } - ImageCompressionResult( file = tmpFile, width = compressedBitmap.width, height = compressedBitmap.height, - size = tmpFile.length(), - blurhash = blurhash + size = tmpFile.length() ) } } @@ -116,7 +112,6 @@ 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/ThumbnailFactory.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ThumbnailFactory.kt new file mode 100644 index 0000000000..a9ed6319cb --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ThumbnailFactory.kt @@ -0,0 +1,122 @@ +/* + * 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.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.media.ThumbnailUtils +import android.os.Build +import android.os.CancellationSignal +import android.provider.MediaStore +import android.util.Size +import androidx.core.net.toUri +import com.vanniktech.blurhash.BlurHash +import io.element.android.libraries.androidutils.file.createTmpFile +import io.element.android.libraries.androidutils.media.runAndRelease +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.media.ThumbnailInfo +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import javax.inject.Inject +import kotlin.coroutines.resume + +/** + * 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 const val VIDEO_THUMB_FRAME = 0L + +class ThumbnailFactory @Inject constructor( + @ApplicationContext private val context: Context, +) { + + @SuppressLint("NewApi") + suspend fun createImageThumbnail(file: File): ThumbnailResult { + return createThumbnail { cancellationSignal -> + // This API works correctly with GIF + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ThumbnailUtils.createImageThumbnail( + file, + Size(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), + cancellationSignal + ) + } else { + ThumbnailUtils.createImageThumbnail( + file.path, + MediaStore.Images.Thumbnails.MINI_KIND, + ) + } + } + } + + suspend fun createVideoThumbnail(file: File): ThumbnailResult { + return createThumbnail { + MediaMetadataRetriever().runAndRelease { + setDataSource(context, file.toUri()) + getFrameAtTime(VIDEO_THUMB_FRAME) + } + } + } + + private suspend fun createThumbnail(bitmapFactory: (CancellationSignal) -> Bitmap?): ThumbnailResult = suspendCancellableCoroutine { continuation -> + val cancellationSignal = CancellationSignal() + continuation.invokeOnCancellation { + cancellationSignal.cancel() + } + val bitmapThumbnail: Bitmap? = bitmapFactory(cancellationSignal) + val thumbnailFile = context.createTmpFile(extension = "jpeg") + thumbnailFile.outputStream().use { outputStream -> + bitmapThumbnail?.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) + } + val blurhash = bitmapThumbnail?.let { + BlurHash.encode(it, 3, 3) + } + val thumbnailResult = ThumbnailResult( + file = thumbnailFile, + info = ThumbnailInfo( + height = bitmapThumbnail?.height?.toLong(), + width = bitmapThumbnail?.width?.toLong(), + mimetype = MimeTypes.Jpeg, + size = thumbnailFile.length() + ), + blurhash = blurhash + ) + bitmapThumbnail?.recycle() + continuation.resume(thumbnailResult) + + } +} + +data class ThumbnailResult( + val file: File, + val info: ThumbnailInfo, + val blurhash: String?, +) 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 490e286353..e7e294cd7c 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 @@ -21,6 +21,7 @@ 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.androidutils.file.safeDelete import io.element.android.libraries.di.ApplicationContext import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.callbackFlow @@ -46,12 +47,12 @@ class VideoCompressor @Inject constructor( } override fun onTranscodeCanceled() { - tmpFile.delete() + tmpFile.safeDelete() close() } override fun onTranscodeFailed(exception: Throwable) { - tmpFile.delete() + tmpFile.safeDelete() close(exception) } }) diff --git a/libraries/mediaupload/test/build.gradle.kts b/libraries/mediaupload/test/build.gradle.kts index 535ba51ac4..956afffbe0 100644 --- a/libraries/mediaupload/test/build.gradle.kts +++ b/libraries/mediaupload/test/build.gradle.kts @@ -24,4 +24,5 @@ android { dependencies { api(projects.libraries.mediaupload.api) + implementation(projects.tests.testutils) } 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 index c4ab8d57b1..0cc7803578 100644 --- 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 @@ -20,6 +20,7 @@ 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.MediaUploadInfo +import io.element.android.tests.testutils.simulateLongTask import java.io.File class FakeMediaPreProcessor : MediaPreProcessor { @@ -41,7 +42,9 @@ class FakeMediaPreProcessor : MediaPreProcessor { mimeType: String, deleteOriginal: Boolean, compressIfPossible: Boolean - ): Result = result + ): Result = simulateLongTask { + result + } fun givenResult(value: Result) { this.result = value diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt index d6135f28b0..613a8d2bb7 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.push.impl.notifications import android.content.Context import io.element.android.libraries.androidutils.file.EncryptedFileFactory +import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.di.ApplicationContext @@ -70,7 +71,7 @@ class NotificationEventPersistence @Inject constructor( fun persistEvents(queuedEvents: NotificationEventQueue) { Timber.tag(loggerTag.value).d("Serializing ${queuedEvents.rawEvents().size} NotifiableEvent(s)") // Always delete file before writing, or encryptedFile.openFileOutput() will throw - file.delete() + file.safeDelete() if (queuedEvents.isEmpty()) return try { encryptedFile.openFileOutput().use { fos -> diff --git a/libraries/push/impl/src/main/res/values-cs/translations.xml b/libraries/push/impl/src/main/res/values-cs/translations.xml index 88eec52772..23fa1fb379 100644 --- a/libraries/push/impl/src/main/res/values-cs/translations.xml +++ b/libraries/push/impl/src/main/res/values-cs/translations.xml @@ -7,8 +7,10 @@ "** Nepodařilo se odeslat - otevřete prosím místnost" "Vstoupit" "Odmítnout" + "Vás pozval(a) do chatu" "Nové zprávy" "Označit jako přečtené" + "Vás pozval(a) do místnosti" "Já" "Prohlížíte si oznámení! Klikněte na mě!" "%1$s: %2$s" @@ -51,4 +53,4 @@ "Služby Google" "Nebyly nalezeny žádné funkční služby Google Play. Oznámení nemusí fungovat správně." "Rychlá odpověď" - \ No newline at end of file + diff --git a/libraries/push/impl/src/main/res/values-de/translations.xml b/libraries/push/impl/src/main/res/values-de/translations.xml index be957bf68a..281e00765e 100644 --- a/libraries/push/impl/src/main/res/values-de/translations.xml +++ b/libraries/push/impl/src/main/res/values-de/translations.xml @@ -7,6 +7,7 @@ "** Senden fehlgeschlagen - bitte Raum öffnen" "Beitreten" "Ablehnen" + "Hat dich eingeladen" "Neue Nachrichten" "Als gelesen markieren" "Ich" @@ -45,4 +46,4 @@ "Google-Dienste" "Keine gültigen Google Play-Dienste gefunden. Benachrichtigungen funktionieren möglicherweise nicht richtig." "Schnellantwort" - \ No newline at end of file + diff --git a/libraries/push/impl/src/main/res/values-es/translations.xml b/libraries/push/impl/src/main/res/values-es/translations.xml index 31df508dc3..90be7669d7 100644 --- a/libraries/push/impl/src/main/res/values-es/translations.xml +++ b/libraries/push/impl/src/main/res/values-es/translations.xml @@ -1,4 +1,4 @@ "Respuesta rápida" - \ No newline at end of file + diff --git a/libraries/push/impl/src/main/res/values-fr/translations.xml b/libraries/push/impl/src/main/res/values-fr/translations.xml index 3d40a01065..3afbf200b2 100644 --- a/libraries/push/impl/src/main/res/values-fr/translations.xml +++ b/libraries/push/impl/src/main/res/values-fr/translations.xml @@ -45,4 +45,4 @@ "Services Google" "Aucun service Google Play valide n\'a été trouvé. Les notifications peuvent ne pas fonctionner correctement." "Réponse rapide" - \ No newline at end of file + diff --git a/libraries/push/impl/src/main/res/values-it/translations.xml b/libraries/push/impl/src/main/res/values-it/translations.xml index 32957fe2ce..1526b805d6 100644 --- a/libraries/push/impl/src/main/res/values-it/translations.xml +++ b/libraries/push/impl/src/main/res/values-it/translations.xml @@ -1,4 +1,4 @@ "Risposta rapida" - \ No newline at end of file + diff --git a/libraries/push/impl/src/main/res/values-ro/translations.xml b/libraries/push/impl/src/main/res/values-ro/translations.xml index d603c0b2f8..d389fd286c 100644 --- a/libraries/push/impl/src/main/res/values-ro/translations.xml +++ b/libraries/push/impl/src/main/res/values-ro/translations.xml @@ -7,6 +7,7 @@ "** Trimiterea eșuată - vă rugăm să deschideți camera" "Alăturați-vă" "Respingeți" + "v-a invitat." "Mesaje noi" "Marcați ca citit" "Eu" @@ -45,4 +46,4 @@ "Servicii Google" "Nu au fost găsite servicii Google Play valide. Este posibil ca notificările să nu funcționeze corect." "Raspuns rapid" - \ No newline at end of file + diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml index 922d35b3e1..a32e6899c0 100644 --- a/libraries/push/impl/src/main/res/values/localazy.xml +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -7,9 +7,10 @@ "** Failed to send - please open room" "Join" "Reject" - "invited you" + "Invited you to chat" "New Messages" "Mark as read" + "Invited you to join the room" "Me" "You are viewing the notification! Click me!" "%1$s: %2$s" @@ -46,4 +47,4 @@ "Google Services" "No valid Google Play Services found. Notifications may not work properly." "Quick reply" - \ No newline at end of file + diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index df12c755e3..d702c797a8 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -37,7 +37,6 @@ object TestTags { * Change server screen. */ val changeServerServer = TestTag("change_server-server") - val changeServerContinue = TestTag("change_server-continue") /** * Room list / Home screen. diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index ccf4030ce1..0256d7e529 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -302,7 +302,7 @@ private fun ReplyToModeView( style = ElementTextStyles.Regular.caption1, textAlign = TextAlign.Start, color = LocalColors.current.placeholder, - maxLines = 1, + maxLines = if (attachmentThumbnailInfo != null) 1 else 2, overflow = TextOverflow.Ellipsis, ) } diff --git a/libraries/textcomposer/src/main/res/values-cs/translations.xml b/libraries/textcomposer/src/main/res/values-cs/translations.xml index 461b52e3b4..8e0524b69a 100644 --- a/libraries/textcomposer/src/main/res/values-cs/translations.xml +++ b/libraries/textcomposer/src/main/res/values-cs/translations.xml @@ -14,4 +14,4 @@ "Přepnout číslovaný seznam" "Přepnout citaci" "Zrušit odsazení" - \ No newline at end of file + diff --git a/libraries/textcomposer/src/main/res/values-de/translations.xml b/libraries/textcomposer/src/main/res/values-de/translations.xml index a9f1b3366e..a28c784792 100644 --- a/libraries/textcomposer/src/main/res/values-de/translations.xml +++ b/libraries/textcomposer/src/main/res/values-de/translations.xml @@ -14,4 +14,4 @@ "Nummerierte Liste ein-/ausschalten" "Zitat umschalten" "Einrücken aufheben" - \ No newline at end of file + diff --git a/libraries/textcomposer/src/main/res/values-es/translations.xml b/libraries/textcomposer/src/main/res/values-es/translations.xml index e302765a58..606e3bde8e 100644 --- a/libraries/textcomposer/src/main/res/values-es/translations.xml +++ b/libraries/textcomposer/src/main/res/values-es/translations.xml @@ -14,4 +14,4 @@ "Lista numérica" "Cita" "Quitar sangría" - \ No newline at end of file + diff --git a/libraries/textcomposer/src/main/res/values-fr/translations.xml b/libraries/textcomposer/src/main/res/values-fr/translations.xml index 03143f5059..4b239c0f93 100644 --- a/libraries/textcomposer/src/main/res/values-fr/translations.xml +++ b/libraries/textcomposer/src/main/res/values-fr/translations.xml @@ -14,4 +14,4 @@ "Afficher une liste numérotée" "Afficher une citation" "Décaler vers la gauche" - \ No newline at end of file + diff --git a/libraries/textcomposer/src/main/res/values-it/translations.xml b/libraries/textcomposer/src/main/res/values-it/translations.xml index 54ca270f28..e3034e8dfe 100644 --- a/libraries/textcomposer/src/main/res/values-it/translations.xml +++ b/libraries/textcomposer/src/main/res/values-it/translations.xml @@ -14,4 +14,4 @@ "Attiva/disattiva elenco numerato" "Attiva/disattiva citazione" "Rientro a sinistra" - \ No newline at end of file + diff --git a/libraries/textcomposer/src/main/res/values-ro/translations.xml b/libraries/textcomposer/src/main/res/values-ro/translations.xml index b053e0ecaa..a7e1a7135c 100644 --- a/libraries/textcomposer/src/main/res/values-ro/translations.xml +++ b/libraries/textcomposer/src/main/res/values-ro/translations.xml @@ -14,4 +14,4 @@ "Comutați lista numerotată" "Aplicați citatul" "Dez-identare" - \ No newline at end of file + diff --git a/libraries/textcomposer/src/main/res/values/localazy.xml b/libraries/textcomposer/src/main/res/values/localazy.xml index 1eab778890..a94173c1d2 100644 --- a/libraries/textcomposer/src/main/res/values/localazy.xml +++ b/libraries/textcomposer/src/main/res/values/localazy.xml @@ -14,4 +14,4 @@ "Toggle numbered list" "Toggle quote" "Unindent" - \ No newline at end of file + diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml index daffb59602..70a3c7a3ab 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -15,6 +15,7 @@ "Pokračovat" "Kopírovat" "Kopírovat odkaz" + "Kopírovat odkaz na zprávu" "Vytvořit" "Vytvořit místnost" "Odmítnout" @@ -23,6 +24,7 @@ "Upravit" "Povolit" "Zapomněli jste heslo?" + "Vpřed" "Pozvat" "Pozvat přátele" "Pozvat přátele do %1$s" @@ -34,6 +36,7 @@ "Ne" "Teď ne" "OK" + "Otevřít v aplikaci" "Rychlá odpověď" "Citovat" "Odstranit" @@ -52,13 +55,16 @@ "Začít" "Zahájit chat" "Zahájit ověření" + "Klepnutím načtete mapu" "Vyfotit" "Zobrazit zdroj" "Ano" "O aplikaci" + "Zásady používání" "Analytika" "Zvuk" "Bubliny" + "Autorská práva" "Vytváření místnosti…" "Opustit místnost" "Chyba dešifrování" @@ -69,6 +75,8 @@ "Šifrování povoleno" "Chyba" "Soubor" + "Soubor byl uložen do složky Stažené soubory" + "Přeposlat zprávu" "GIF" "Obrázek" "Nemůžeme ověřit Matrix ID tohoto uživatele. Pozvánka nemusí být přijata." @@ -79,17 +87,19 @@ "Rozložení zprávy" "Zpráva byla odstraněna" "Moderní" + "Ztlumit" "Žádné výsledky" "Offline" "Heslo" "Lidé" "Trvalý odkaz" + "Zásady ochrany osobních údajů" "Reakce" "Odpověď na %1$s" "Nahlásit chybu" "Zpráva odeslána" "Název místnosti" - "např. Produktový sprint" + "např. název vašeho projektu" "Hledat někoho" "Výsledky hledání" "Zabezpečení" @@ -102,11 +112,14 @@ "Nálepka" "Úspěch" "Návrhy" + "Synchronizace" + "Oznámení třetích stran" "Téma" "O čem je tato místnost?" "Nelze dešifrovat" "Nepodařilo se nám úspěšně odeslat pozvánky jednomu nebo více uživatelům." "Nelze odeslat pozvánky" + "Zrušit ztlumení" "Nepodporovaná událost" "Uživatelské jméno" "Ověření zrušeno" @@ -150,12 +163,6 @@ "Nahrání média se nezdařilo, zkuste to prosím znovu." "Nahrání média se nezdařilo, zkuste to prosím znovu." "Zaškrtněte, pokud chcete skrýt všechny aktuální a budoucí zprávy od tohoto uživatele" - "Změnit poskytovatele účtu" - "Soukromý server pro zaměstnance Elementu." - "Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci." - "Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily." - "Chystáte se přihlásit do služby %1$s" - "Chystáte se vytvořit účet na %1$s" "Rageshake" "Práh detekce" "Obecné" @@ -163,7 +170,7 @@ "en" "Chyba" "Úspěch" - "Pomozte nám identifikovat problémy a vylepšit %1$s sdílením anonymních údajů o používání." + "Sdílejte anonymní údaje o používání, které nám pomohou identifikovat problémy." "Můžete si přečíst všechny naše podmínky %1$s." "zde" "Zablokovat uživatele" 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 90f27bd8dd..f8d004d624 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -23,6 +23,7 @@ "Bearbeiten" "Aktivieren" "Passwort vergessen?" + "Weiterleiten" "Einladen" "Freunde einladen" "Freunde zu %1$s einladen" @@ -34,6 +35,7 @@ "Nein" "Nicht jetzt" "OK" + "Öffne mit" "Schnellantwort" "Zitieren" "Entfernen" @@ -52,24 +54,31 @@ "Starten" "Chat starten" "Verifizierung starten" + "Tippe, um die Karte zu laden" "Foto aufnehmen" "Quelltext anzeigen" "Ja" "Über" + "Allgemeine Geschäftsbedingungen" "Analyse" "Audio" "Blasen" + "Urheberrecht" "Erstelle Raum…" "Raum verlassen" "Entschlüsselungsfehler" "Entwickleroptionen" "(bearbeitet)" "Bearbeiten" + "* %1$s %2$s" "Verschlüsselung aktiviert" "Fehler" "Datei" + "Datei gespeichert unter Downloads" + "Nachricht weiterleiten" "GIF" "Bild" + "Wir können die Matrix-ID dieses Benutzers nicht validieren. Die Einladung wurde möglicherweise nicht empfangen." "Raum verlassen" "Link in Zwischenablage kopiert" "Wird geladen…" @@ -77,16 +86,19 @@ "Nachrichtenlayout" "Nachricht wurde entfernt" "Modern" + "Stummschalten" "Keine Ergebnisse" "Offline" "Passwort" "Personen" "Permalink" + "Datenschutzerklärung" "Reaktionen" "Auf %1$s antworten" "Melde einen Fehler" "Bericht gesendet" "Raumname" + "z.B. dein Projektname" "Suche nach jemandem" "Suchergebnisse" "Sicherheit" @@ -99,8 +111,13 @@ "Sticker" "Erfolg" "Vorschläge" + "Hinweise von Drittanbietern" "Thema" + "Worum geht es in diesem Raum?" "Entschlüsselung nicht möglich" + "Wir konnten Einladungen nicht erfolgreich an einen oder mehrere Benutzer senden." + "Einladung(en) können nicht gesendet werden" + "Stummschaltung aufheben" "Nicht unterstütztes Ereignis" "Benutzername" "Verifizierung abgebrochen" @@ -140,11 +157,9 @@ "Neu" "Teile Analyse-Daten" "Medienauswahl fehlgeschlagen, bitte versuche es erneut." + "Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuchen Sie es erneut." "Medien hochladen fehlgeschlagen. Bitte versuchen Sie es erneut." "Prüfe, ob du alle aktuellen und zukünftigen Nachrichten dieses Benutzers ausblenden möchtest" - "Kontoanbieter wechseln" - "Ein privater Server für Element-Mitarbeiter." - "Matrix ist ein offenes Netzwerk für sichere, dezentrale Kommunikation" "Rageshake" "Erkennungsschwelle" "Allgemein" @@ -152,8 +167,7 @@ "de" "Fehler" "Erfolg" - "Helfen Sie uns, Probleme zu identifizieren und %1$s zu verbessern, indem Sie anonyme Nutzungsdaten weitergeben." "Sie können alle unsere Nutzerbedingungen %1$s lesen." "hier" "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 b1e73503fa..fa8a80f953 100644 --- a/libraries/ui-strings/src/main/res/values-es/translations.xml +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -132,4 +132,4 @@ "Error" "Terminado" "Bloquear usuario" - \ No newline at end of file + diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 76111046f8..7909e2d0e0 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -140,4 +140,4 @@ "Erreur" "Succès" "Bloquer l\'utilisateur" - \ 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 c8b16a7ea5..b15d570dfc 100644 --- a/libraries/ui-strings/src/main/res/values-it/translations.xml +++ b/libraries/ui-strings/src/main/res/values-it/translations.xml @@ -132,4 +132,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 57a783eac2..6b8d8e2e4f 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -22,6 +22,8 @@ "Efectuat" "Editați" "Activați" + "Ați uitat parola?" + "Redirecționați" "Invitați" "Invitați prieteni" "Invitați prieteni în %1$s" @@ -33,6 +35,7 @@ "Nu" "Nu acum" "OK" + "Deschideți cu" "Raspuns rapid" "Citat" "Ștergeți" @@ -55,9 +58,11 @@ "Vedeți sursă" "Da" "Despre" + "Politică de utilizare rezonabilă" "Analitice" "Audio" "Baloane" + "Drepturi de autor" "Se creează camera…" "Ați parăsit camera" "Eroare de decriptare" @@ -68,8 +73,10 @@ "Criptare activată" "Eroare" "Fişier" + "Fișier salvat în Descărcări" "GIF" "Imagine" + "Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost trimisă." "Se părăsește conversația" "Linkul a fost copiat în clipboard" "Se încarcă…" @@ -77,16 +84,19 @@ "Aranjamentul mesajelor" "Mesaj sters" "Modern" + "Dezactivați sunetul" "Niciun rezultat" "Deconectat" "Parola" "Persoane" "Permalink" + "Politica de confidențialitate" "Reacții" "Răspuns pentru %1$s" "Raportați o eroare" "Raport trimis" "Numele camerei" + "de exemplu, numele proiectului dvs." "Căutați pe cineva" "Rezultatele căutării" "Securitate" @@ -99,10 +109,13 @@ "Autocolant" "Succes" "Sugestii" + "Notificări despre software de la terți" "Subiect" + "Despre ce este vorba în această cameră?" "Nu s-a putut decripta" "Nu am putut trimite cu succes invitații unuia sau mai multor utilizatori." "Nu s-a putut trimite invitația (invitațiile)" + "Activați sunetul" "Eveniment neacceptat" "Utilizator" "Verificare anulată" @@ -153,8 +166,7 @@ "ro" "Eroare" "Succes" - "Ajutați-ne să identificăm problemele și să îmbunătățim %1$s prin partajarea datelor de utilizare anonime." "Puteți citi toate condițiile noastre %1$s." "aici" "Blocați utilizatorul" - \ No newline at end of file + diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 43d19f2202..ffbc7aded4 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -15,6 +15,7 @@ "Continue" "Copy" "Copy link" + "Copy link to message" "Create" "Create a room" "Decline" @@ -23,6 +24,7 @@ "Edit" "Enable" "Forgot password?" + "Forward" "Invite" "Invite friends" "Invite friends to %1$s" @@ -53,13 +55,16 @@ "Start" "Start chat" "Start verification" + "Tap to load map" "Take photo" "View Source" "Yes" "About" + "Acceptable use policy" "Analytics" "Audio" "Bubbles" + "Copyright" "Creating room…" "Left room" "Decryption error" @@ -71,6 +76,7 @@ "Error" "File" "File saved to Downloads" + "Forward message" "GIF" "Image" "We can’t validate this user’s Matrix ID. The invite might not be received." @@ -81,11 +87,13 @@ "Message layout" "Message removed" "Modern" + "Mute" "No results" "Offline" "Password" "People" "Permalink" + "Privacy policy" "Reactions" "Replying to %1$s" "Report a bug" @@ -104,11 +112,14 @@ "Sticker" "Success" "Suggestions" + "Syncing" + "Third-party notices" "Topic" "What is this room about?" "Unable to decrypt" "We were unable to successfully send invites to one or more users." "Unable to send invite(s)" + "Unmute" "Unsupported event" "Username" "Verification cancelled" @@ -146,30 +157,11 @@ "This is the beginning of %1$s." "This is the beginning of this conversation." "New" - "Change account provider" - "Continue" - "Homeserver address" - "Enter a search term or a domain address." - "Search for a company, community, or private server." - "Find an account provider" - "You’re about to sign in to %s" - "This is where you conversations will live — just like you would use an email provider to keep your emails." - "You’re about to create an account on %s" "Share analytics data" - "Matrix.org is an open network for secure, decentralized communication." - "Other" - "Use a different account provider, such as your own private server or a work account." - "Change account provider" "Failed selecting media, please try again." "Failed processing media to upload, please try again." "Failed uploading media, please try again." "Check if you want to hide all current and future messages from this user" - "Change account provider" - "A private server for Element employees." - "Matrix is an open network for secure, decentralised communication." - "This is where your conversations will live — just like you would use an email provider to keep your emails." - "You’re about to sign in to %1$s" - "You’re about to create an account on %1$s" "Rageshake" "Detection threshold" "General" @@ -178,8 +170,8 @@ "en" "Error" "Success" - "Help us identify issues and improve %1$s by sharing anonymous usage data." + "Share anonymous usage data to help us identify issues." "You can read all our terms %1$s." "here" "Block user" - \ No newline at end of file + diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/LoginScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/LoginScreen.kt index 9d9971a842..54bef4652b 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/LoginScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/LoginScreen.kt @@ -17,11 +17,13 @@ package io.element.android.samples.minimal import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow -import io.element.android.features.login.impl.root.LoginRootPresenter -import io.element.android.features.login.impl.root.LoginRootView +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordPresenter +import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordView +import io.element.android.features.login.impl.util.defaultAccountProvider import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService class LoginScreen(private val authenticationService: MatrixAuthenticationService) { @@ -29,13 +31,18 @@ class LoginScreen(private val authenticationService: MatrixAuthenticationService @Composable fun Content(modifier: Modifier = Modifier) { val presenter = remember { - LoginRootPresenter( + LoginPasswordPresenter( authenticationService = authenticationService, - DefaultOidcActionFlow() + AccountProviderDataSource() ) } + + LaunchedEffect(Unit) { + authenticationService.setHomeserver(defaultAccountProvider.title) + } + val state = presenter.present() - LoginRootView( + LoginPasswordView( state = state, modifier = modifier, onBackPressed = {}, 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 d6cc2e2e31..e4cb389d94 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 @@ -71,7 +71,7 @@ class RoomListScreen( networkMonitor = NetworkMonitorImpl(context), snackbarDispatcher = SnackbarDispatcher(), inviteStateDataSource = DefaultInviteStateDataSource(matrixClient, DefaultSeenInvitesStore(context), coroutineDispatchers), - leaveRoomPresenter = LeaveRoomPresenterImpl(matrixClient, RoomMembershipObserver() ,coroutineDispatchers) + leaveRoomPresenter = LeaveRoomPresenterImpl(matrixClient, RoomMembershipObserver(), coroutineDispatchers) ) @Composable diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt new file mode 100644 index 0000000000..154877efe9 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt @@ -0,0 +1,28 @@ +/* + * 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.tests.testutils + +import kotlinx.coroutines.delay + +/** + * Workaround for https://github.com/cashapp/molecule/issues/249. + * This functions should be removed/deprecated right after we find a proper fix. + */ +suspend inline fun simulateLongTask(lambda: () -> T): T { + delay(1) + return lambda() +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt index 4628f4910c..b8376ddf6f 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt @@ -21,32 +21,28 @@ package io.element.android.tests.testutils import io.element.android.libraries.core.coroutine.CoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher /** * Create a [CoroutineDispatchers] instance for testing. * - * @param testScheduler The [TestCoroutineScheduler] to use. If using [runTest] use the one provided by its [TestScope]. - * If null the [TestDispatcher] logic will select one or create a new one. * @param useUnconfinedTestDispatcher If true, use [UnconfinedTestDispatcher] for all dispatchers. * If false, use [StandardTestDispatcher] for all dispatchers. */ -fun testCoroutineDispatchers( - testScheduler: TestCoroutineScheduler? = null, - useUnconfinedTestDispatcher: Boolean = true, +fun TestScope.testCoroutineDispatchers( + useUnconfinedTestDispatcher: Boolean = false, ): CoroutineDispatchers = when (useUnconfinedTestDispatcher) { - false -> CoroutineDispatchers( - io = StandardTestDispatcher(testScheduler), - computation = StandardTestDispatcher(testScheduler), - main = StandardTestDispatcher(testScheduler), - diffUpdateDispatcher = StandardTestDispatcher(testScheduler), - ) - true -> CoroutineDispatchers( io = UnconfinedTestDispatcher(testScheduler), computation = UnconfinedTestDispatcher(testScheduler), main = UnconfinedTestDispatcher(testScheduler), diffUpdateDispatcher = UnconfinedTestDispatcher(testScheduler), ) + false -> CoroutineDispatchers( + io = StandardTestDispatcher(testScheduler), + computation = StandardTestDispatcher(testScheduler), + main = StandardTestDispatcher(testScheduler), + diffUpdateDispatcher = StandardTestDispatcher(testScheduler), + ) } diff --git a/tests/uitests/build.gradle.kts b/tests/uitests/build.gradle.kts index 307cbcdd5d..13f43b22d4 100644 --- a/tests/uitests/build.gradle.kts +++ b/tests/uitests/build.gradle.kts @@ -16,6 +16,7 @@ import extension.allFeaturesImpl import extension.allLibrariesImpl +import extension.allServicesImpl plugins { id("io.element.android-compose-library") @@ -30,6 +31,7 @@ android { dependencies { testImplementation(libs.test.junit) testImplementation(libs.test.parameter.injector) + testImplementation(projects.libraries.designsystem) androidTestImplementation(libs.test.junitext) ksp(libs.showkase.processor) kspTest(libs.showkase.processor) @@ -37,5 +39,6 @@ dependencies { implementation(libs.showkase) allLibrariesImpl() + allServicesImpl() allFeaturesImpl(rootDir, logger) } diff --git a/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/ScreenshotTest.kt b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/ScreenshotTest.kt index f7afb1e4a8..a929f7d182 100644 --- a/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/ScreenshotTest.kt +++ b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/ScreenshotTest.kt @@ -39,6 +39,8 @@ import com.android.ide.common.rendering.api.SessionParams import com.google.testing.junit.testparameterinjector.TestParameter import com.google.testing.junit.testparameterinjector.TestParameterInjector import io.element.android.libraries.designsystem.theme.ElementTheme +import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -96,6 +98,8 @@ class ScreenshotTest { LocalConfiguration provides Configuration().apply { setLocales(LocaleList(localeStr.toLocale())) }, + // Needed to display Snackbars and avoid crashes during screenshot tests + LocalSnackbarDispatcher provides SnackbarDispatcher(), // Needed so that UI that uses it don't crash during screenshot tests LocalOnBackPressedDispatcherOwner provides object : OnBackPressedDispatcherOwner { override val lifecycle: Lifecycle get() = lifecycleOwner.lifecycle diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewDarkPreview_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.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index b901fa8bd8..69cc122958 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewDarkPreview_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.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:514254ebfcc01b99ae6b85108c327cd2cfbe930ec793764049cf617e0d3b3580 -size 29278 +oid sha256:5c458a8c9ee5bc552c2f29b98bdb51555597d9207d9cc3283e57e007d7779852 +size 25616 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewLightPreview_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.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index 29f66e2bf4..aac2729ec4 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewLightPreview_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.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:56d3de1fe930fa9c961265c2dc1c99ebecde06f3be27ab7d3cfb7299679456bb -size 28666 +oid sha256:0cbe5dfca48bc5750a08e2150fbf98cc78db58e918bc5e5740fc7dbc647ffaf1 +size 24992 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.impl_null_DefaultGroup_AnalyticsOptInViewDarkPreview_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.analytics.impl_null_DefaultGroup_AnalyticsOptInViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index eaaa4b3a91..23bd49cb88 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.impl_null_DefaultGroup_AnalyticsOptInViewDarkPreview_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.analytics.impl_null_DefaultGroup_AnalyticsOptInViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:88d5d2054bae36664b69a20853e11e4567bd6cb1e6a8f7ea0a6747ec534807f6 -size 47160 +oid sha256:bde1e07ccd740f6f19bc39be2dc0feaff956a6fbf079639b3f0949b7f2d2dc96 +size 51661 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.impl_null_DefaultGroup_AnalyticsOptInViewLightPreview_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.analytics.impl_null_DefaultGroup_AnalyticsOptInViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index cf5be1e386..d44e6c6dce 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.impl_null_DefaultGroup_AnalyticsOptInViewLightPreview_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.analytics.impl_null_DefaultGroup_AnalyticsOptInViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d9774baeb4de35b8fc4f47d4e41eb38f856a098465ffce0cdea66c32e4cebf57 -size 46475 +oid sha256:b5c99a23745883c9b6f9c5112974e9e80f202d7a5255776f66c638af4ed28abc +size 51339 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_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.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9b57449f4c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bbb3612de857b16799f9133da4f4cd90665f4f81738127b63c57b0265e097483 +size 20870 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_1,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.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d827cba4e0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df5c0566208a88e9fcc606d12c34a4ccbb3773e30ea9d27b8527f4bbe1bf31b7 +size 9402 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_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.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3c9a443627 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2268f7d747e2926cdaae791bacd2741c659018112854fc5b525321206d441a58 +size 10390 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_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.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4b13301d6f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e533e3d36ee815d8a9f61ba7359cb4031e9948e1d7d396caf12f73f2dbf03b2 +size 8476 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_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.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5f165a39f2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:742bfd7e633fb4d743ff8036901e1d6afa43f3f041faaa075ec020301fa7ef1f +size 7373 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_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.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..13291776bd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bebd1d3386ed304b3501cee88a3b192585e65ad23322cf8a6905971ed5fc8251 +size 20244 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_1,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.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6038fa4f29 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1d0c114d197386b28ae086089a87d5b85d09c08805671b66d9fb4ff9889d2ed +size 9117 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_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.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c14b4e259f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43fcfc09cbe37e93bab1787505085d1f2913f56af04dd2ce08c6eaec24ee61ba +size 10107 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_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.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5eb18cacf0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:742e857cdf1691ee9402fdd3babd209509b18d82be6337073b7d493466cfefa7 +size 8217 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_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.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7f6de35768 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f855ee063e8bc310038db2f6dd6fd5042272096303b8ce953b77ebefc707628e +size 7088 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 55945f00cb..665c8811ac 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ac39dda453fbdf0f5cc3c6df2d9436a171c141c8be2ca2e2d2a2c7ee5d16b36f -size 39650 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_1,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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png deleted file mode 100644 index 9f65a20aaa..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:49ef94a7bd404fe5982f7319aa1c4f6b5d29d11675f53e192af85b4efb8c3517 -size 43267 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png deleted file mode 100644 index 75c156af00..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:47942f38dd2afb38d0e1174bd155bb708f645b231b250525920f5abaa7074308 -size 43217 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png deleted file mode 100644 index bfba913061..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b006f7768bbc48cf68e5d897920a14894496d0b2b84a1839a7158b45edea9a75 -size 48757 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png deleted file mode 100644 index a63cf4d7e8..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:12702b2d71193e376f15df6f1a029531868b9651b70c40d2d738646f15980704 -size 42362 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png deleted file mode 100644 index 7b72ffcde5..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a942ed49efc175776994cf58d511af3be8f878b30ed5b34ab3ae0d600f2e0db1 -size 42316 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index 8c716007cc..665c8811ac 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d692cd9030e6193691e676de62f68a9241cebf220a1f6c7986e095370e25c547 -size 37839 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_1,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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_1,NEXUS_5,1.0,en].png deleted file mode 100644 index c979df7432..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:716e20f80dd634feb58ee26247defdd8e390d268e9ec5d9767ad7b574d7ca2bc -size 41581 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_2,NEXUS_5,1.0,en].png deleted file mode 100644 index bc159e7bcb..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9658c925b80565109772111972a495d437bda9ae97a9d9327d92a44ff0a1b9d1 -size 41521 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_3,NEXUS_5,1.0,en].png deleted file mode 100644 index d0764f07b3..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_3,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f0511bf277f5194a6d38cf13416c2b2a1c834bf216c511243351d2f9bfe1b7e3 -size 47857 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_4,NEXUS_5,1.0,en].png deleted file mode 100644 index be3729d669..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_4,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e248fa1785749f3a590576ed6f3c6899ad0042f71a80a4afdc7336981c7419a3 -size 40589 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_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.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_5,NEXUS_5,1.0,en].png deleted file mode 100644 index 7a621153ce..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_5,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:903df8ff5d3816a85e16a3ec46b2a98e742ba0fc0a317df829b0fca57f33d9aa -size 40553 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_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.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_0,NEXUS_5,1.0,en].png deleted file mode 100644 index 22b1ce6d04..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:558f8f3b34e3a94a62460cca37396f94b0caa8197b7fbd49ce894382392f1899 -size 31512 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_1,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.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_1,NEXUS_5,1.0,en].png deleted file mode 100644 index 22b1ce6d04..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:558f8f3b34e3a94a62460cca37396f94b0caa8197b7fbd49ce894382392f1899 -size 31512 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_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.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_2,NEXUS_5,1.0,en].png deleted file mode 100644 index f48af1a990..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c4469faa61e68d951ed13b9cf324d11a99a1b041fb096bade2cd5d11c8185acc -size 33232 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_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.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_3,NEXUS_5,1.0,en].png deleted file mode 100644 index d65333610e..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_3,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:81125c72229ddb56f3c3e8b826de42f947f8739c3f372cad99917ee7d4db3b5c -size 33318 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_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.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_4,NEXUS_5,1.0,en].png deleted file mode 100644 index 4efbb4fca6..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_4,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cff519b372c22b623f72407e7c4113cfa24dae31fc517aea5de75bea9b4d61bf -size 29616 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_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.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_5,NEXUS_5,1.0,en].png deleted file mode 100644 index f48af1a990..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_5,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c4469faa61e68d951ed13b9cf324d11a99a1b041fb096bade2cd5d11c8185acc -size 33232 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_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.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_6,NEXUS_5,1.0,en].png deleted file mode 100644 index 1a305e0c79..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_6,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:78b278d94f46995b7d11719677b72c0804219b1c36072fd8e2ad6dfdb9de8aba -size 25313 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_7,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.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_7,NEXUS_5,1.0,en].png deleted file mode 100644 index 2adab63ade..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_7,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:25b6b15bd1646316a6b1dde8d68396e6b8f1e677b0528f07cab8f8d17a828902 -size 24564 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_8,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.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_8,NEXUS_5,1.0,en].png deleted file mode 100644 index b31cc0a315..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_8,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a59f9b56497f976a0664855e3da6245c423e8608c24b3f08b0c0cef0809ba0ee -size 20273 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_9,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.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_9,NEXUS_5,1.0,en].png deleted file mode 100644 index 873614913b..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenDarkPreview_0_null_9,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1f8841cada0e8826a43cbca6ed8af3aa703633eccdcb664be3377ab6b38ed960 -size 26073 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_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.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_0,NEXUS_5,1.0,en].png deleted file mode 100644 index a9d3b597b1..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ef34f0de8599a2c950690d1ef1d4c8f68419b137594177ed09cfe3715deb3767 -size 30490 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_1,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.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_1,NEXUS_5,1.0,en].png deleted file mode 100644 index a9d3b597b1..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ef34f0de8599a2c950690d1ef1d4c8f68419b137594177ed09cfe3715deb3767 -size 30490 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_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.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_2,NEXUS_5,1.0,en].png deleted file mode 100644 index 718f68a9f8..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:99de01c644442e4d309b1d6bc75d0920eb205df31ef0c0f2411cbb756e0a838e -size 32106 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_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.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_3,NEXUS_5,1.0,en].png deleted file mode 100644 index a11d8fe9d2..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_3,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:acdde4f931b9e8aa18280f8f6f6e003ba406801ce1603e87ab087afe44465167 -size 32124 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_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.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_4,NEXUS_5,1.0,en].png deleted file mode 100644 index 5b2872a1e2..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_4,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6f3cbb18c3c2b766b7b10b204edc300cce73193c458a2c9b7dd88bf6fa7bcd6b -size 29007 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_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.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_5,NEXUS_5,1.0,en].png deleted file mode 100644 index 718f68a9f8..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_5,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:99de01c644442e4d309b1d6bc75d0920eb205df31ef0c0f2411cbb756e0a838e -size 32106 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_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.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_6,NEXUS_5,1.0,en].png deleted file mode 100644 index b7201b0739..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_6,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3a1473e95cf2436f5e42fb1ca2d7bbd40915be830f012b96ad47801ee1fd70a2 -size 24580 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_7,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.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_7,NEXUS_5,1.0,en].png deleted file mode 100644 index b20d358a12..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_7,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e338c8c2d3f89f3b7fdeafc5afffb36399855155e2b85231349e13e381e4cf1f -size 23617 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_8,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.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_8,NEXUS_5,1.0,en].png deleted file mode 100644 index 4313439757..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_8,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dea12dfe1c1799b1d2d765214b1bd911154a19a7794bf37c6663d179cec76c81 -size 19556 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_9,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.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_9,NEXUS_5,1.0,en].png deleted file mode 100644 index ed81ad1335..0000000000 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.root_null_DefaultGroup_LoginRootScreenLightPreview_0_null_9,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1dc7d39a9b4e0f3eee8157b0290146b6bae5e6d0ea280296ddfc7051f31b95df -size 24786 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.changeaccountprovider_null_DefaultGroup_ChangeAccountProviderViewDarkPreview_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.login.impl.screens.changeaccountprovider_null_DefaultGroup_ChangeAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1108e511aa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.changeaccountprovider_null_DefaultGroup_ChangeAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83875da521afb2a14f76d9a51c30cca720589352c0cf1e4aded4c89173b44800 +size 43615 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.changeaccountprovider_null_DefaultGroup_ChangeAccountProviderViewLightPreview_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.login.impl.screens.changeaccountprovider_null_DefaultGroup_ChangeAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..622c4a378c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.changeaccountprovider_null_DefaultGroup_ChangeAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b7298092a46e91cb5aa96d6fef49eada76389c821962ea6192d9595fdcff7cb +size 43183 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewDarkPreview_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.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..406ee49b58 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51897c460fad147f358268ad6b15d6865b6378edd8533d744fe108bc96f5d718 +size 40107 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewLightPreview_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.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3346d30e90 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57ace1181dd3d95bdbc0ec11270951b6b2fce6301a68af88c6cddc428acba022 +size 39533 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_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.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4fc7c6de68 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84a19a75922a661d173d1213797112bba2e8faf5093561f336925c2f06ecd17a +size 31799 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_1,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.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9dcfbbebaa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20a03449955f95720f3ef1fcf36d9fd82fb34c5e2efea7515e1dd25b11a5257b +size 31834 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_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.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4fc7c6de68 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84a19a75922a661d173d1213797112bba2e8faf5093561f336925c2f06ecd17a +size 31799 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_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.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c9c94638f6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:367400d1ff6e9e39a5a27bcd45227c75a13fc3d2a81ca532eda75a07481c025a +size 31472 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_1,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.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0ea591d06f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d33b86a10b237ee11b7fcf757462c9685f2cc10fb30ee1c76f2808306b7bc14c +size 31456 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_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.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c9c94638f6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:367400d1ff6e9e39a5a27bcd45227c75a13fc3d2a81ca532eda75a07481c025a +size 31472 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewDarkPreview_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.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..74b6c3556e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0456d54912f117e8d07c3023c4a0c10fc4933234937f20a87c79cdc8364c3d1 +size 26891 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewDarkPreview_0_null_1,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.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..da0ea09508 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c316e712fe97ffbc22d182a44cebcd0b27c498af6d33028006907f17d12f9b8b +size 48688 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewLightPreview_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.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0facef8667 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62d743d3bfa2744891c6cd2bb4052cccb623b9ff6e99c06ce07948c71212cc54 +size 26598 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewLightPreview_0_null_1,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.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f17a59d55f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f65024164d91bbec1058a104dcac0b835f98bfb7a46ce0a0d21a3ddb11fa7e32 +size 48332 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..96d99a3ad8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b410a2cd4cdadf7fb69fc0eb307882a1eedb70710ea3a2b8fefff9fe0f4ff3a9 +size 13266 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_1,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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0230e1291e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c046931631c1d1ee9abc55e5c03d16ec7fb88d1829973342e3c358b2bd99d6c4 +size 12809 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d32361190b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec44a976cb2a7572df5d18858ccfef5d0c8fe77ef0ed3a0c1d2bd7615aa32324 +size 33230 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..88d7384886 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad6debeeb7774b50e8f578c0b8c1b91f92ce15d99ac5ccd8401eec7286e098fb +size 32766 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0269187ab1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b27c645192fd1e2909483c56d6e4b0251cd1b2cb1e39d7b3d5987eb5a4aad851 +size 33038 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0269187ab1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b27c645192fd1e2909483c56d6e4b0251cd1b2cb1e39d7b3d5987eb5a4aad851 +size 33038 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_7,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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0269187ab1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b27c645192fd1e2909483c56d6e4b0251cd1b2cb1e39d7b3d5987eb5a4aad851 +size 33038 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e4bb2e40d3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d415f55a0fc2e463c53c2182a70fc56d08e969fd01492b5ba4dd712653aede3 +size 13018 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_1,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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..dc85231b66 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83fb3a09e70802c385ccda4cb46763cc9b949eaeaf9f572c4a38cb8bb1ab6516 +size 12518 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a1532918f4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42042704bffae9e397b139effe9fa6213ec9c2167c2139db11b2611a6ebfd9cc +size 31965 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..57c9f2d3d1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5726218ec996aff3e52aa6539ece216dfe20294d0ee13939b7f0c9da7bd7555f +size 31504 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..82280a75c0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a78aa398ce214169e551470b5a45d84c19739c4f222d6bf4bd0307f1c97d0cc +size 32230 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..82280a75c0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a78aa398ce214169e551470b5a45d84c19739c4f222d6bf4bd0307f1c97d0cc +size 32230 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_7,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.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..82280a75c0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a78aa398ce214169e551470b5a45d84c19739c4f222d6bf4bd0307f1c97d0cc +size 32230 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_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.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..34557a047e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c78c731d2a100cd5632d3018a0a2f9ee3c4ecdd69feebb233f8fb60efb29808a +size 44647 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_1,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.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7756aef065 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a88c76ee497bc9544dda80517959d35862708ec25539aa910cfcf94d55dc17e +size 46580 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_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.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a37d6a00cb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c54a2b29fc3584b3de6e5d11b87200bae6ea9ce371c7382297496f0022110da5 +size 46232 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_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.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e6ba2b331c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c11907197357eaced1a3ac9dec28ada97ab871d5662ce270d0af000204ebdf11 +size 43878 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_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.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..feace46eb5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a6b210c88398341ab21982e485f08c4503198fdc550a077ffea4f123f5513e0 +size 35451 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_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.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_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.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b89bf3802a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18ec14976369b542ce426d9b559fe4d72faa8db8e7402884be20dca670106ade +size 43921 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_1,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.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d74122f832 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:539ed1ad5366e2757a99c5589ea6052bb47cc494805164c0a17f8e4970f0b102 +size 45084 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_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.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ac7d452d59 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e788d5011a1635273949be92a350a7e4068831935dfae328e5699fc361a9cc6b +size 44662 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_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.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4c84f916e9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:396fa2ca9b01b467fb5160d776130eb6b40351a93d4ed9cc4e61a027c977cac0 +size 42258 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_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.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..566e949537 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5cb0fb381934d4548d10f3b6654ecf04bc0fa29266ed58fa5653dafd322eade0 +size 34503 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_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.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index b1badf5a3f..affcb49660 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa17dd70b3c5eaaa37fbc5eed53997dd627090d9a5a3ffa518b9688647449a8e -size 99065 +oid sha256:c40273c36eb1479f284e75fa91d4a75b1ae97edd0242dda37a2d4a8f10394928 +size 138700 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_1,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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index f5ff66af17..cb4b57910b 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_1,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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4593d265265cea31c20b545250d20df54dfcc877d6e54eae28d1e84a1c693f16 -size 147443 +oid sha256:d7c47c713c74766c39d3b349d9e421ea588261e043105a5475cd8e68af782806 +size 185325 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index b1badf5a3f..affcb49660 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa17dd70b3c5eaaa37fbc5eed53997dd627090d9a5a3ffa518b9688647449a8e -size 99065 +oid sha256:c40273c36eb1479f284e75fa91d4a75b1ae97edd0242dda37a2d4a8f10394928 +size 138700 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_1,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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index f5ff66af17..cb4b57910b 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_1,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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4593d265265cea31c20b545250d20df54dfcc877d6e54eae28d1e84a1c693f16 -size 147443 +oid sha256:d7c47c713c74766c39d3b349d9e421ea588261e043105a5475cd8e68af782806 +size 185325 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 7407445780..5361cd1a24 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f4e329e21d49bd79b633913edbbc1d5c63024874d866d96d399b1a8ffa5c1f18 -size 99209 +oid sha256:06617cda0f93ce0ea26b2a77a243371cdaf4f9a2956d8159ecb7196f7f6fe082 +size 139282 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_1,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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index c803700190..0e14867b04 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_1,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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5eb1c967db078a0333ef3fe26f94692264d0158d74d78768df32cc7c641faee3 -size 147537 +oid sha256:55d2d11ba729a68b1b62e9a69c3308594839fdb3612ff3db59850f0b346c28da +size 186118 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index 7407445780..5361cd1a24 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f4e329e21d49bd79b633913edbbc1d5c63024874d866d96d399b1a8ffa5c1f18 -size 99209 +oid sha256:06617cda0f93ce0ea26b2a77a243371cdaf4f9a2956d8159ecb7196f7f6fe082 +size 139282 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_1,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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index c803700190..0e14867b04 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_1,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.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5eb1c967db078a0333ef3fe26f94692264d0158d74d78768df32cc7c641faee3 -size 147537 +oid sha256:55d2d11ba729a68b1b62e9a69c3308594839fdb3612ff3db59850f0b346c28da +size 186118 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewDark_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.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewDark_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..309f151dcc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewDark_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:307bca06295570124970ed808f4a068f50086212623fa0e69630f52cf9e8752a +size 15440 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewDark_0_null_1,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.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewDark_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..309f151dcc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewDark_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:307bca06295570124970ed808f4a068f50086212623fa0e69630f52cf9e8752a +size 15440 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewLight_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.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewLight_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..af6c929a30 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewLight_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e47e880d5bf8ffedd0662ec0c791483a2cb76e921d12958afffa959b1370c270 +size 14073 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewLight_0_null_1,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.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewLight_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..af6c929a30 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewLight_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e47e880d5bf8ffedd0662ec0c791483a2cb76e921d12958afffa959b1370c270 +size 14073 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index b244bdb73f..6434e7e2f4 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bdf7fe891aa40f4d626733deb130481d64b3315531712128d1b4073e5ccedf19 -size 5394 +oid sha256:0f56dcf7dfb9ca58618e891aae28099a82457b889da3cd8ec613aeb3757e9750 +size 5403 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_1,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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 25c4fffbab..573a379b18 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_1,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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0baf33f0ec99cd8e4b201d72b38208a02b0511da544276a38d17ff5653e2b754 -size 5906 +oid sha256:b0c0973dd677105248cd17351ac32e9cf79dc6082eb3a9e26a7ff37e2475ca2f +size 5860 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index 5aa7211ab3..f4a4fb3c17 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c8d9dc01ec07f9e43032e3ec7610eda5588145f3003cc3d329ddf4325e19f239 -size 6674 +oid sha256:2fd42d7ae900d5ece9499b1877766d82d8d174d4b8b04633c93e1de04d3aa069 +size 6715 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png index 2ed9e07432..718fa7e4ad 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e0f86449d651c50966fe594a46e3a59ae9a5505013e3e36f2458be35ba1844e -size 7174 +oid sha256:e324ac2b0578de021d27cddbba851d5835086f80066cb53c1c1829241ac63fec +size 7172 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index f640d9af42..8af55ef3ae 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:621facc4799c0069fe83b4be9a5085ecafb042de1cdfbbf4ccf0c15548373aa9 -size 5332 +oid sha256:fc5e3c5e68dcab10bd67878cd28d1cf769b19cb4daa3dfc02a310b9e44e38d59 +size 5362 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_1,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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index 8ed48c31f6..c7059cab27 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_1,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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:986f40750d69639e771c9bf1aac30c49dbf122aa60dc0280164a8868671b638a -size 5910 +oid sha256:8ec5e0cbd36f80526f2a55637d35c41eec7cd8dd07562bc9ed115b2886963b39 +size 5888 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_2,NEXUS_5,1.0,en].png index 9735300d61..159f0b046a 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6d4fb8d68603309963d434f3166821055d818836b1ebfb4fc4c637aba992277d -size 6617 +oid sha256:961c557c4d7f77a6d1deb9bbb908cdd855f5e3ebee91e211c91afe857ba3785d +size 6593 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_3,NEXUS_5,1.0,en].png index d265165330..e0496389bb 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_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.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1fa641ca8d5c796c858b54ad997052aabf817bc86e254d38d4b33038bc21eedf -size 7285 +oid sha256:833ccceceff1f0a74db95058f3714da514525d05cd3e040619fb221e32ba8d33 +size 7287 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_10,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.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_10,NEXUS_5,1.0,en].png index df3b482afd..9c924da5c9 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_10,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.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_10,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:052a95ccf790c0ef93bbfd24ff063fd5489b2c250a9c53ab6d58438653fbbea5 -size 46098 +oid sha256:259022501a602e5e6e22a800f866d221500f34f7ce475df9996b897f93e7fc49 +size 45929 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_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.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index 8ccc3512a1..2ae60ffbaf 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_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.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed788e9f85e922a81e077c2ad82b89fa822d9dec6d95442ad5aa5365535a87b9 -size 193815 +oid sha256:ead933d9b666f988a1c30997919ab364dacc4602c0cc62c03b14c63bd1b978d7 +size 226100 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_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.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png index 0d0752eeb4..2cdede3cc8 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_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.messages.impl.timeline_null_DefaultGroup_TimelineViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:169f1a6dd0d433a900c43867a3c2feacd3143fc8e58029d4f24eeb4e5a41d2f2 -size 193901 +oid sha256:2d753eeb5d8e83643f858e5190cde015321e32959111f6a45e70a60f790e94c8 +size 226976 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_10,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.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_10,NEXUS_5,1.0,en].png index 9535f67822..09f6f440f9 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_10,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.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_10,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:744f2192c85b1d6a56cc2eb7f6c873eb45827d2ad2d36fd1678c4ace4118af2c -size 45945 +oid sha256:4fe9d2349725bb5c6dbfcfd29dcf8c7c10079deefdc8a0cb367dfe23cb453b85 +size 45873 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_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.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_2,NEXUS_5,1.0,en].png index 9327685441..6351229d4c 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_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.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c8943194bce27da10d2f047e5617cbb45548170cc1f8c9606e62dae20fde44f5 -size 194104 +oid sha256:7647550e41280854b7dd68110250d8f217acc8bef70a25ca0ebdf28994802d63 +size 225809 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_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.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_3,NEXUS_5,1.0,en].png index 373800dd3b..2c51bbc761 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_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.messages.impl.timeline_null_DefaultGroup_TimelineViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc4c738cf6e3c8961d0e97b8adc24d0ba8765cd8a8d1799b9783a5b1f098fc25 -size 194152 +oid sha256:35150cf00f6e4d899417ff5f456e8fb9168424a75def962f4df40155d3819104 +size 226734 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_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.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..77ae334920 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f145f138983fce06084866f28e14e6babe6b242ea40f89a7544a8c2580020249 +size 49121 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_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.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..84d4bfb339 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb2aa0a032dcd1597fd2e7971f47a9025f41f4fb3bf29fac709dd6d33a0a2cf8 +size 48551 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_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.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_null_0,NEXUS_5,1.0,en].png index fcdaeaa1c9..4a1e55c16c 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_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.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9002796dbd6074f2cf6a2351dc5d65e772c1e7abedaf637dd81a30c11da11a3f -size 61210 +oid sha256:153126e98bb3f3a6d2a9586708af4a9f4c6110234fd9d253af164d77d8615d2d +size 59971 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_null_1,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.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_null_1,NEXUS_5,1.0,en].png index 0ea551a0b7..c325d9ecce 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_null_1,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.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a6e702e87522cd2cf8fbb72b243f0d8e4f5426b04d8060a6a4b265c3de74d8b -size 64012 +oid sha256:0346f2949c18c8d5c46d7a77e158918781a3b2e0931062dbe5af62666809cd4c +size 59960 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_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.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_0_null_0,NEXUS_5,1.0,en].png index 098af44de0..90d413ca03 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_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.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d9010c13c864e8c6cf675a8335e9653387482108c897b6ffbc62ada1711aaeb9 -size 59639 +oid sha256:ea11564e4fb01fca55ad6a7e9542351d2cdebc27a99585a935f3dca36051fc7b +size 58513 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_0_null_1,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.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_0_null_1,NEXUS_5,1.0,en].png index 21ac080032..f6e99b3379 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_0_null_1,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.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1592a00820108d4883a1ddaad265722653d7e4d5eabd99ab4b648672fd75850d -size 62606 +oid sha256:47bbbd2caafa859b445c72252a6aefd2e6804282fb31bf9be5cebf0a99987d6d +size 58650 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_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.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_0,NEXUS_5,1.0,en].png index eb3d4d754b..72cc9adfb6 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_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.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dff0e2357ef0b08f415859b25470fed31dd80c5578e8fbe483ab8650d8f9aadb -size 28803 +oid sha256:063da4485de5cdadbbaa41d64fa0f615866ca89009225421a6bd2ba0ff0fcf01 +size 24666 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_1,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.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_1,NEXUS_5,1.0,en].png index 353ef28283..191a7bee34 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_1,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.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a895c48d5fcff6bf605f4c7518468b42390130843c096a135c185d368c1b01ef -size 26246 +oid sha256:edabce3917d2d066af9903a8312f9bf372aefa7e309c2f8d543faaf6220adcd9 +size 22549 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_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.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_2,NEXUS_5,1.0,en].png index dcccb87b8d..db138119d1 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_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.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2bab9b94040850e9a0b9a15332e5e088f95b6a77ef31b6b08dc40d5f8e94ae7d -size 29679 +oid sha256:d4b782e88aebed6494276600b1792e3affa4d3a9b7619fecb0ceaf9a2137a391 +size 25670 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_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.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_3,NEXUS_5,1.0,en].png index eb3d4d754b..72cc9adfb6 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_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.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dff0e2357ef0b08f415859b25470fed31dd80c5578e8fbe483ab8650d8f9aadb -size 28803 +oid sha256:063da4485de5cdadbbaa41d64fa0f615866ca89009225421a6bd2ba0ff0fcf01 +size 24666 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_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.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_4,NEXUS_5,1.0,en].png index eb3d4d754b..72cc9adfb6 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_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.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dff0e2357ef0b08f415859b25470fed31dd80c5578e8fbe483ab8650d8f9aadb -size 28803 +oid sha256:063da4485de5cdadbbaa41d64fa0f615866ca89009225421a6bd2ba0ff0fcf01 +size 24666 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_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.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_0,NEXUS_5,1.0,en].png index 57708e6118..856d9309cd 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_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.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66d548cfe055f06ba42adabd08f31a54097d6c61decf4f2ddd8f4bd142d60f07 -size 28208 +oid sha256:1ebebc32c3b3f69ebab252cbfbe999b7e0d8d934ef1986cadd0027cd3cf02741 +size 24297 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_1,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.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_1,NEXUS_5,1.0,en].png index 8d0407b7ab..ea9e2cf526 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_1,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.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ca695e57d1ca585652544576a80cbb027e212842d727c83bf5e39adac599389e -size 25663 +oid sha256:66b7b86b54474c4c1d55022e99f5f270a148787cbe21890c68d1ea1e05e4a737 +size 22221 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_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.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_2,NEXUS_5,1.0,en].png index 13a0dacfdc..d50510272e 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_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.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:821c488d20c689230903245d64eb5740272ce6d568787f78bcd7e8875da371f7 -size 28743 +oid sha256:31da8ff7fa86ff31a63b448f82c1ddd49b03c5beba0227a77085606359656a75 +size 24626 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_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.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_3,NEXUS_5,1.0,en].png index 57708e6118..856d9309cd 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_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.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66d548cfe055f06ba42adabd08f31a54097d6c61decf4f2ddd8f4bd142d60f07 -size 28208 +oid sha256:1ebebc32c3b3f69ebab252cbfbe999b7e0d8d934ef1986cadd0027cd3cf02741 +size 24297 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_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.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_4,NEXUS_5,1.0,en].png index 57708e6118..856d9309cd 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_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.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66d548cfe055f06ba42adabd08f31a54097d6c61decf4f2ddd8f4bd142d60f07 -size 28208 +oid sha256:1ebebc32c3b3f69ebab252cbfbe999b7e0d8d934ef1986cadd0027cd3cf02741 +size 24297 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index f67e775a44..9e0556cea5 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eed20ed1221b53520bcc92c76a7b45f19134d5896220c109f89914d0d2de8c68 -size 29536 +oid sha256:64e2e959463e616c85dbf84f7e89bf4921ad8bda8b56c3ad5923c97d41ebb17a +size 29109 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_1,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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index 2980892672..8e27acb63e 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_1,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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bff3aaf96ea36cd876e0bb262155f7da0a5e110ab5308872bf43d80fa50da414 -size 26909 +oid sha256:b29de0962ec154558d45759678ae01b4cd79b5b0ca77e44023fa8d4c461375a7 +size 26436 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index de5e56864b..484bf3737d 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a1d4d57ed7cd0ab3c274d0cb46d70f6a2821fcf7fb2b4350258e675ecad8c964 -size 61153 +oid sha256:c1f43f5652bc1312b0a10b074ef7cf1b630774c9633551da36cf563218cd3812 +size 60953 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png index 35dbc4a40e..a26fee1b27 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e5db9cb1ebafb13c52b0e5f68c7dee3d6d27ce8b9bad0a1f5dd5ce76f4bda4cb -size 61738 +oid sha256:cc770343fe1da082c3f65d6f51f4b69e62751807a5b9217a0c785d720789dad2 +size 61539 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png index abbfcf77d5..d034c99ff3 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:102bc04b2e12eb68919c45346f4aab946470e8c9e9848ed59a03ad07b178e1bb -size 32639 +oid sha256:e2c28114a14e7251b70254516bb454d3cf05a183f3b5dcec9a8a8c2913d4d80e +size 32441 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png index c5f846756d..04a4cd408d 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:052f915a61028b2cd9605c07fd13000b09f003744397740cfee7054dbcabdcbd -size 27218 +oid sha256:bbf6e55866620ad7422340380a4a02620840217f6b18cc184cfa4b3c2d15a63d +size 27065 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index b6a5f9ef16..f204672d04 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a9d85fcaba465c6747e933974cf9712953222fe934eb2ef3acd513050c9ab19e -size 29087 +oid sha256:4c3b9167b8b97eed74727902987a91fc1808d91709cef3a935b0fc58dbb4650a +size 29049 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_1,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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index 7053e141af..0c9a371a45 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_1,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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ff5f22bda0c7deaaba6e8006abdd7e0c9ba1562016ae62ad41b6a70e67737fdc -size 26179 +oid sha256:1f1c11cdf296cef8514547cee32fffc53eeb2ba16ab85475e98c66eaaa315f0f +size 26294 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_2,NEXUS_5,1.0,en].png index d1e830a2ce..262a0ccbf9 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6faa634a10bf61fa3b84d396c8572586334a5554187fb9935c472d1a56e02884 -size 59497 +oid sha256:bb425ad24d840d6f85fcbc7f8629f58e2ba9b4f8173e1e728056d3185c2bdd49 +size 59691 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_3,NEXUS_5,1.0,en].png index 1da6df2b42..9ef4777a07 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:649cd1ab79165a14c67000ce378fdba8abf3e30b10db306641f6f8d158194f0c -size 59923 +oid sha256:0f5f76169e604b2f2a5ab5dc302b91bdc142fc2a0a17001f78d6d8f63774c3f0 +size 60130 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_4,NEXUS_5,1.0,en].png index 526c311ff2..a5f8157309 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a6a7c9e187e4825524e292243dcb2e3c57bab49c750dac25016e279d639c56c -size 32266 +oid sha256:8325684caab3d97fdc240748b85592a7fab64233055fbdb65e18e56786968e40 +size 32388 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_5,NEXUS_5,1.0,en].png index 78effe0524..e8535fd92c 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_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.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f9d94a1d66c85094c425f4054cc3115cab28e3109272657a96be939bbc169cf3 -size 26759 +oid sha256:78e9a0f4d9799f1a526e217398dd885d91bee6f69d4544d2f5799a170fefd02a +size 26971 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_RoundedIconAtomDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_RoundedIconAtomDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..bb4c8a386d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_RoundedIconAtomDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fddc4b662ae2284c9179ccea787dbe4fe7cf4923bad81cdbaf891e8149b7131e +size 8083 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_RoundedIconAtomLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_RoundedIconAtomLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e4993027cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_RoundedIconAtomLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e46889dfdfcd8a9999992afcc75d73ea5dd8107a4f5fc19335f9022335c6fc7d +size 6834 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeDarkPreview_0_null,NEXUS_5,1.0,en].png index 9f2b08ab8d..97f45a7e34 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeDarkPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:73d4db2c6cdd12c05672affc584c75161aa481fd0065c1b084f32ed4c3481c66 -size 10605 +oid sha256:d71452355496c1fe8214f061f72baf3c7690953138777952cdb65befdc133e57 +size 10407 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeLightPreview_0_null,NEXUS_5,1.0,en].png index 010dd2b941..70d1d4e355 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeLightPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeLightPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7aadaccb2874346a08bbdcf2cd8d51fd1490319e10c22065611c70543ce8d28d -size 9695 +oid sha256:b508532be8dd29f60e30c00923834ac21ff0664cf3521fb86cdcdd493e9bfff7 +size 9930 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetDarkPreview_0_null,NEXUS_5,1.0,en].png index fca921c50b..0a268ba362 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetDarkPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c1c1eedbab868e0c2501220293608572850e052f685f7076ec939b3f1a9abf27 -size 4464 +oid sha256:4aee0816507bc71c8649aae95e57c3b75fb5e7fbd3888531817f659e1153f5be +size 7971 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLightPreview_0_null,NEXUS_5,1.0,en].png index 665c8811ac..09a7bd0089 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLightPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLightPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b -size 4457 +oid sha256:dac298843eacb59d4666d50712e5cdbe82620a25c5c01d23b77f4dcba9df9c43 +size 7979 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedRoomDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedRoomDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..26ea6aa891 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedRoomDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbd3d24533bfa534b3379c8243c2a5af3744a6ef73ed294e1d78faea3ef855fa +size 12949 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedRoomLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedRoomLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2d3ed5547d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedRoomLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31a47c956fac22a7add0ede634c327112d42c65ffea42e19afdb75d157b55788 +size 12390 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png index c8904142b2..0ca8b318fb 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8922908539dc4978a23f72342e46a4c259322559d1f0dbe978dcf3ae8a4e79bf -size 66820 +oid sha256:ba55513ee2966dc6c02dbef9b66e3b780a8b883814d0389e5f119429a70716df +size 69445 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png index e4c0408f4e..9696945634 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyLightPreview_0_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a5b67e808ef8d171167e7fb241ba76b7e6d5ae48d193f6384bf3e2c4a0743f3 -size 66423 +oid sha256:155879c0c4035664cc622b3a27fb84a813c2115b305cb0034f17505fdb02d850 +size 68799 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.services.apperror.impl_null_DefaultGroup_AppErrorViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.services.apperror.impl_null_DefaultGroup_AppErrorViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f97e9386c7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.services.apperror.impl_null_DefaultGroup_AppErrorViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62224b9bbc7b9c1ee4eb419a1f47783d3386c9a11d1f70ccdacddb3b84793651 +size 21747 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.services.apperror.impl_null_DefaultGroup_AppErrorViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.services.apperror.impl_null_DefaultGroup_AppErrorViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fe1883275b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.services.apperror.impl_null_DefaultGroup_AppErrorViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cdb537e5fbe1f8202e4e0463e907f2a4a9b3c3f5cd4d05f97c3ae6c65d614091 +size 21556 diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt index ae346c170d..41a6c16466 100755 --- a/tools/check/forbidden_strings_in_code.txt +++ b/tools/check/forbidden_strings_in_code.txt @@ -129,3 +129,6 @@ System\.currentTimeMillis\(\)===1 ### Suspicious String template. Please check that the string template will behave as expected, i.e. the class field and not the whole object will be used. For instance `Timber.d("$event.type")` is not correct, you should write `Timber.d("${event.type}")`. In the former the whole event content will be logged, since it's a data class. If this is expected (i.e. to fix false positive), please add explicit curly braces (`{` and `}`) around the variable, for instance `"elementLogs.${i}.txt"` \$[a-zA-Z_]\w*\??\.[a-zA-Z_] + +### Use `import io.element.android.libraries.ui.strings.R as StringsR` then `StringR.string.` instead +io\.element\.android\.libraries\.ui\.strings\.R\. diff --git a/tools/detekt/detekt.yml b/tools/detekt/detekt.yml index dc797aace1..f5be110ffa 100644 --- a/tools/detekt/detekt.yml +++ b/tools/detekt/detekt.yml @@ -113,7 +113,8 @@ Compose: CompositionLocalAllowlist: active: true # You can optionally define a list of CompositionLocals that are allowed here - allowedCompositionLocals: LocalColors, LocalCompoundColors + + allowedCompositionLocals: LocalColors, LocalCompoundColors, LocalSnackbarDispatcher CompositionLocalNaming: active: true ContentEmitterReturningValues: diff --git a/tools/localazy/config.json b/tools/localazy/config.json index a23d652cd8..a7317af0db 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -73,7 +73,10 @@ "name": ":features:login:impl", "includeRegex": [ "screen_login_.*", - "screen_change_server_.*" + "screen_server_confirmation_.*", + "screen_change_server_.*", + "screen_change_account_provider_.*", + "screen_account_provider_.*" ] }, { diff --git a/tools/localazy/downloadStrings.sh b/tools/localazy/downloadStrings.sh index 0f35f9ec59..bc00488258 100755 --- a/tools/localazy/downloadStrings.sh +++ b/tools/localazy/downloadStrings.sh @@ -40,6 +40,12 @@ fi echo "Importing the strings..." localazy download --config ./tools/localazy/localazy.json +echo "Add new lines to the end of the files..." +find . -name 'localazy.xml' -print0 -exec bash -c "echo \"\" >> \"{}\"" \; >> /dev/null +if [[ $allFiles == 1 ]]; then + find . -name 'translations.xml' -print0 -exec bash -c "echo \"\" >> \"{}\"" \; >> /dev/null +fi + echo "Removing the generated config" rm ./tools/localazy/localazy.json