diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9d8be16c9..a9cc829697 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,44 +63,6 @@ jobs: path: | app/build/outputs/bundle/gplayRelease/app-gplay-release.aab - enterprise: - name: Create App Bundle Enterprise - runs-on: ubuntu-latest - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} - concurrency: - group: ${{ format('build-release-main-enterprise-{0}', github.sha) }} - cancel-in-progress: true - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Add SSH private keys for submodule repositories - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} - with: - ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }} - - name: Clone submodules - run: git submodule update --init --recursive - - name: Use JDK 21 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 - with: - distribution: 'temurin' # See 'Supported distributions' for available options - java-version: '21' - - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 - - name: Create Enterprise app bundle - env: - ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} - ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} - ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} - run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES - - name: Upload bundle as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: elementx-enterprise-app-gplay-bundle-unsigned - path: | - app/build/outputs/bundle/gplayRelease/app-gplay-release.aab - fdroid: name: Create APKs (FDroid) runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 917285ab30..a4a73ca965 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,9 +50,10 @@ PRs must meet these rules. - Title = changelog entry — descriptive, no "Fixes #…". - Leave description template for the developer. Redirect them to the [contributing etiquette](CONTRIBUTING.md#etiquette). - Screenshots/videos for visual changes. -- 500 additions max — split large changes. +- 500 additions max into production code. Test code can be larger — split large changes. - Commits need a title and description; no tiny or massive commits. - No history rewrites. +- When creating a pull request and if changes contain new or update(s) of the Composable Previews, add the label "Record-Screenshots" to the PR so that the CI will record the screenshots. --- diff --git a/CHANGES.md b/CHANGES.md index db18e59251..5f4be5d6eb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,87 @@ +Changes in Element X v26.08.4 +============================= + + + +## What's Changed +### 🧱 Build +* Add releaseV2.sh, a release script automating the GitHub release by @bmarty in https://github.com/element-hq/element-x-android/pull/7579 +* Update submodule ref. by @bmarty in https://github.com/element-hq/element-x-android/pull/7582 +### Others +* Keep the formatting of a caption that is being edited by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7572 +* Say when the server cannot be reached instead of showing nothing by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7567 +* Keep saving files to Downloads once 32 files share the same name by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7344 +* Fix removed messages hiding its thread on deletion by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7480 +* Drop the sender's name from the room list preview of two-person rooms by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7562 +* Document how the Element Call base URL developer option is used by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7546 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.3...v26.08.4 + +Changes in Element X v26.08.3 +============================= + + + +## What's Changed +### ✨ Features +* Let users enter their full Matrix user ID on the account provider screen by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7500 +* Enable user status feature and hide status text in the timeline by @bmarty in https://github.com/element-hq/element-x-android/pull/7517 +### 🐛 Bugfixes +* Fix cold start with a mobile deep link by @bmarty in https://github.com/element-hq/element-x-android/pull/7488 +* Make the account provider screen scrollable so its description is not clipped by the keyboard by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7521 +### 🧱 Build +* Update Gradle Wrapper from 9.7.0 to 9.7.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/7485 +* Fix flaky test in `DefaultVoiceMessageComposerPresenterTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7487 +* Fix warning about deprecated Gradle `keepRules.file` API by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7498 +* Update Enterprise Copyright in idea settings, so that new files get an expected header. by @bmarty in https://github.com/element-hq/element-x-android/pull/7501 +* Remove workaround for Renovate. by @bmarty in https://github.com/element-hq/element-x-android/pull/7502 +### 🚧 In development 🚧 +* Make PIP react to camera orientation changes in the other participant's device by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6951 +* Feature: global search by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7410 +### Dependency upgrades +* Update dependency org.maplibre.gl:android-sdk to v13.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7486 +* Update dependency androidx.webkit:webkit to v1.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7484 +* Update dependency io.sentry:sentry-android to v8.53.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7481 +* Update dependency io.element.android:element-call-embedded to v0.24.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7474 +* Update roborazzi to v1.72.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7489 +* Update metro to v1.4.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7493 +* Update dependency com.squareup.okhttp3:okhttp-bom to v5.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7512 +* Update dependency com.google.guava:guava to v33.7.0-android by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7516 +* Update sdk to version 26.08.25 by @bmarty in https://github.com/element-hq/element-x-android/pull/7550 +### 💼 Element Pro +* Add `ClientEnterpriseHook` and `ClientBuilderEnterpriseHook` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7495 +* Remove dependency on enterprise module. by @bmarty in https://github.com/element-hq/element-x-android/pull/7505 +### Others +* Show the right image when opening one that has not finished sending by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7359 +* Move strings to the login module by @bmarty in https://github.com/element-hq/element-x-android/pull/7494 +* Allow empty problem description if the GitHub issue number is present for rageshakes by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7497 +* Apply the system theme when switching back to it from a forced light or dark theme by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7453 +* Show dialog with reason when user clicks on a message with a failure by @bmarty in https://github.com/element-hq/element-x-android/pull/7490 +* Keep a focused timeline focused until the event has been rendered by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7463 +* Do not render in-chat key verification requests in the timeline by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7443 +* Share a file whose mime type is not one of the supported ones by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7435 +* Show reactions in the pinned messages list by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6676 +* Send a concrete mime type for media shared with a wildcard one by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7449 +* Rename module and ensure that naming convention is followed by @bmarty in https://github.com/element-hq/element-x-android/pull/7514 +* Fix lint issue, ensure that PiP methods are not called on old devices not supporting them. by @bmarty in https://github.com/element-hq/element-x-android/pull/7515 +* Rename PreviewParameterProvider subclasses to the PreviewParam suffix by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7468 +* Update Ref. by @bmarty in https://github.com/element-hq/element-x-android/pull/7518 +* Remove the reference to the decommissioned OIDC playground by @sandhose in https://github.com/element-hq/element-x-android/pull/7519 +* Hide the encryption badge when key storage is set up on the server by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7447 +* Rename remaining events sealed interface by @bmarty in https://github.com/element-hq/element-x-android/pull/7533 +* Stop waiting on a verification request the other session never accepts by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7457 +* Point the proxy debugging doc at the method that builds the clients by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7543 +* Show the build date in the screenshot gallery footer by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7544 +* Run the database migrations again when a database is upgraded by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7570 +* Update fonts on the headings by @bmarty in https://github.com/element-hq/element-x-android/pull/7520 +* Add a "Push rules" entry in the developer options by @bmarty in https://github.com/element-hq/element-x-android/pull/7577 + +## New Contributors +* @sandhose made their first contribution in https://github.com/element-hq/element-x-android/pull/7519 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.2...v26.08.3 + Changes in Element X v26.08.2 ============================= diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index f6faee0acf..f2f6e1312f 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -105,9 +105,18 @@ class LoggedInPresenter( } val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState() val isOnline by syncService.isOnline.collectAsState() - val showSyncSpinner by remember { + val hasNetwork by networkMonitor.connectivity.collectAsState() + val syncIndicatorState by remember { derivedStateOf { - isOnline && syncIndicator == RoomListService.SyncIndicator.Show + when { + isOnline -> if (syncIndicator == RoomListService.SyncIndicator.Show) { + SyncIndicatorState.Syncing + } else { + SyncIndicatorState.Hidden + } + hasNetwork == NetworkStatus.Connected -> SyncIndicatorState.ServerUnreachable + else -> SyncIndicatorState.Hidden + } } } var forceNativeSlidingSyncMigration by remember { mutableStateOf(false) } @@ -181,7 +190,7 @@ class LoggedInPresenter( } return LoggedInState( - showSyncSpinner = showSyncSpinner, + syncIndicatorState = syncIndicatorState, pusherRegistrationState = pusherRegistrationState.value, ignoreRegistrationError = ignoreRegistrationError, forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration, diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt index 33499341e7..8ac675a146 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt @@ -11,8 +11,14 @@ package io.element.android.appnav.loggedin import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog +enum class SyncIndicatorState { + Hidden, + Syncing, + ServerUnreachable, +} + data class LoggedInState( - val showSyncSpinner: Boolean, + val syncIndicatorState: SyncIndicatorState, val pusherRegistrationState: AsyncData, val ignoreRegistrationError: Boolean, val forceNativeSlidingSyncMigration: Boolean, diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStatePreviewParam.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStatePreviewParam.kt index b479e00279..705f6fbe61 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStatePreviewParam.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStatePreviewParam.kt @@ -17,7 +17,8 @@ open class LoggedInStatePreviewParam : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aLoggedInState(), - aLoggedInState(showSyncSpinner = true), + aLoggedInState(syncIndicatorState = SyncIndicatorState.Syncing), + aLoggedInState(syncIndicatorState = SyncIndicatorState.ServerUnreachable), aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())), aLoggedInState(forceNativeSlidingSyncMigration = true), aLoggedInState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale), @@ -26,13 +27,13 @@ open class LoggedInStatePreviewParam : PreviewParameterProvider { } fun aLoggedInState( - showSyncSpinner: Boolean = false, + syncIndicatorState: SyncIndicatorState = SyncIndicatorState.Hidden, pusherRegistrationState: AsyncData = AsyncData.Uninitialized, forceNativeSlidingSyncMigration: Boolean = false, appName: String = "Element X", localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None, ) = LoggedInState( - showSyncSpinner = showSyncSpinner, + syncIndicatorState = syncIndicatorState, pusherRegistrationState = pusherRegistrationState, ignoreRegistrationError = false, forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration, diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt index 060a24855a..1d3a355663 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt @@ -47,7 +47,7 @@ fun LoggedInView( ) { SyncStateView( modifier = Modifier.align(Alignment.TopCenter), - isVisible = state.showSyncSpinner, + state = state.syncIndicatorState, ) } when (state.pusherRegistrationState) { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt index 7c5f196a4c..db39658ad4 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt @@ -25,18 +25,23 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SyncStateView( - isVisible: Boolean, + state: SyncIndicatorState, modifier: Modifier = Modifier ) { AnimatedVisibility( - visible = isVisible, + visible = state != SyncIndicatorState.Hidden, modifier = modifier, enter = fadeIn(spring(stiffness = 500F)), exit = fadeOut(spring(stiffness = 500F)), ) { - AsyncIndicator.Loading( - text = stringResource(id = CommonStrings.common_syncing), - ) + when (state) { + SyncIndicatorState.ServerUnreachable -> AsyncIndicator.Failure( + text = stringResource(id = CommonStrings.common_server_unreachable), + ) + else -> AsyncIndicator.Loading( + text = stringResource(id = CommonStrings.common_syncing), + ) + } } } @@ -46,7 +51,18 @@ internal fun SyncStateViewPreview() = ElementPreview { // Add a box to see the shadow Box(modifier = Modifier.padding(24.dp)) { SyncStateView( - isVisible = true + state = SyncIndicatorState.Syncing + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SyncStateViewServerUnreachablePreview() = ElementPreview { + // Add a box to see the shadow + Box(modifier = Modifier.padding(24.dp)) { + SyncStateView( + state = SyncIndicatorState.ServerUnreachable ) } } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt index f85d35bbb5..474253c8df 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt @@ -67,7 +67,7 @@ class LoggedInPresenterTest { fun `present - initial state`() = runTest { createLoggedInPresenter().test { val initialState = awaitItem() - assertThat(initialState.showSyncSpinner).isFalse() + assertThat(initialState.syncIndicatorState).isEqualTo(SyncIndicatorState.Hidden) assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue() assertThat(initialState.ignoreRegistrationError).isFalse() } @@ -97,11 +97,33 @@ class LoggedInPresenterTest { matrixClient = FakeMatrixClient(roomListService = roomListService), ).test { val initialState = awaitItem() - assertThat(initialState.showSyncSpinner).isFalse() + assertThat(initialState.syncIndicatorState).isEqualTo(SyncIndicatorState.Hidden) roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show) - consumeItemsUntilPredicate { it.showSyncSpinner } + consumeItemsUntilPredicate { it.syncIndicatorState == SyncIndicatorState.Syncing } roomListService.postSyncIndicator(RoomListService.SyncIndicator.Hide) - consumeItemsUntilPredicate { !it.showSyncSpinner } + consumeItemsUntilPredicate { it.syncIndicatorState != SyncIndicatorState.Syncing } + } + } + + @Test + fun `present - say the server is unreachable when the device has network but the sync is offline`() = runTest { + createLoggedInPresenter( + syncState = SyncState.Offline, + networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected), + ).test { + consumeItemsUntilPredicate { it.syncIndicatorState == SyncIndicatorState.ServerUnreachable } + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `present - say nothing when the device itself has no network`() = runTest { + createLoggedInPresenter( + syncState = SyncState.Offline, + networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected), + ).test { + consumeItemsUntilPredicate { it.syncIndicatorState == SyncIndicatorState.Hidden } + cancelAndConsumeRemainingEvents() } } diff --git a/docs/debug_proxying.md b/docs/debug_proxying.md index a28163c959..f76c24eecb 100644 --- a/docs/debug_proxying.md +++ b/docs/debug_proxying.md @@ -9,13 +9,18 @@ 1) Set `127.0.0.1` as "Host name" and `8080` as "Port number". 1) Click "Apply" and verify that "Proxy status" is "Success" and close the settings window. Screenshot 2023-10-04 at 14 48 47 + 1) Alternatively, set the proxy on the device itself, which the app honours through `DefaultProxyProvider`, and which also works on a physical device: + ``` + adb shell settings put global http_proxy 10.0.2.2:8080 + ``` + Use `adb shell settings delete global http_proxy` to remove it afterwards. On a physical device, replace `10.0.2.2` with the address of the machine running mitmproxy. 1) Install the mitmproxy CA cert (this is needed to see traffic from java/kotlin code, it's not needed for traffic coming from native code e.g. the matrix-rust-sdk). 1) Open the emulator Chrome browser app 1) Go to the url `mitm.it` 1) Follow the instructions to install the CA cert on Android devices. Screenshot 2023-10-04 at 14 51 27 1) Slightly modify the Element X app source code. - 1) Go to the `RustMatrixClientFactory.create()` method. + 1) Go to the `RustMatrixClientFactory.getBaseClientBuilder()` method, which builds every client the app creates, whether it is logging in or restoring a session. 1) Add `.disableSslVerification()` in the `ClientBuilder` method chain. 1) Build and run the Element X app. 1) Enjoy, you will see all the traffic in mitmproxy's web interface. diff --git a/docs/element_call.md b/docs/element_call.md new file mode 100644 index 0000000000..e4bd46e2cb --- /dev/null +++ b/docs/element_call.md @@ -0,0 +1,53 @@ +# Element Call + + + +* [Overview](#overview) +* [Setting a custom base URL](#setting-a-custom-base-url) +* [Why the application does not add the room path itself](#why-the-application-does-not-add-the-room-path-itself) +* [Where the value is read](#where-the-value-is-read) + + + +## Overview + +Element X embeds a copy of [Element Call](https://github.com/element-hq/element-call) in the +application assets and loads it from +`https://appassets.androidplatform.net/element-call/index.html`. Calls therefore work out of the box +with no extra deployment, and the embedded copy is the one every user gets. + +Developers can point the application at another Element Call deployment instead — a local +development server, a staging deployment, or a self-hosted one — with the **Element Call base URL** +developer option. + +## Setting a custom base URL + +The option lives in *Settings* → *Developer options* → *Element Call*, under "Element Call base +URL". On a release build the *Developer options* entry is hidden until the version number at the +bottom of *Settings* is tapped seven times. Leaving the field empty restores the embedded copy. + +The value is used verbatim as the widget base URL, so it must be the URL of the page that joins a +room, not the URL of the deployment root. For the public deployments that means: + +| Deployment | Value to enter | +|---|---| +| `call.element.io` | `https://call.element.io/room` | +| `call.element.dev` | `https://call.element.dev/room` | + +Entering `https://call.element.io` without the `/room` suffix loads the Element Call lobby rather +than the room, so the call is never joined. + +## Why the application does not add the room path itself + +The path that joins a room is a property of the deployment, not of Element Call. Unpacking the +Element Call release tarball behind a reverse proxy can put it at any path — `https://call.foo.tld`, +`https://call.foo.tld/some/path/somewhere` — and a proxy that already maps a subdomain onto +`https://1.2.3.4/room` would end up with the suffix twice. The application passes the value through +unchanged so that every one of those deployments can be reached. + +## Where the value is read + +`AppDeveloperSettingsView` renders the field and sends +`AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl`, which `DefaultAppPreferencesStore` persists. +`DefaultCallWidgetProvider.getWidget` reads it back and falls back to the embedded copy when it is +unset. diff --git a/docs/install_from_github_release.md b/docs/install_from_github_release.md index 5ac27ff812..1fd963e953 100644 --- a/docs/install_from_github_release.md +++ b/docs/install_from_github_release.md @@ -64,16 +64,16 @@ brew install bundletool ### Steps 1. Open the GitHub release that you want to install from https://github.com/element-hq/element-x-android/releases -2. Download the asset `app-release-signed.aab` +2. Download the asset `app-gplay-release-signed.aab` 3. Navigate to the folder where you cloned the project and run the following command: ```bash -bundletool build-apks --bundle= --output=./tmp/elementx.apks \ +bundletool build-apks --bundle= --output=./tmp/elementx.apks \ --ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \ --overwrite ``` For instance: ```bash -bundletool build-apks --bundle=./tmp/Element/0.1.5/app-release-signed.aab --output=./tmp/elementx.apks \ +bundletool build-apks --bundle=./tmp/Element/26.08.3/app-gplay-release-signed.aab --output=./tmp/elementx.apks \ --ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \ --overwrite ``` diff --git a/enterprise b/enterprise index e633a343b1..53750903f2 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit e633a343b12088b1fe3fd6b27ec319c1f7f2931c +Subproject commit 53750903f229a410ec4dded2bb6357431a55662e diff --git a/fastlane/metadata/android/en-US/changelogs/202608030.txt b/fastlane/metadata/android/en-US/changelogs/202608030.txt new file mode 100644 index 0000000000..a4b397f1bb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202608030.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/202608040.txt b/fastlane/metadata/android/en-US/changelogs/202608040.txt new file mode 100644 index 0000000000..a4b397f1bb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202608040.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt index 63d28dca21..9f4a2403f7 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt @@ -53,8 +53,6 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection @@ -80,7 +78,7 @@ import io.element.android.libraries.designsystem.modifiers.backgroundVerticalGra import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE -import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.text.AdaptativeTitle import io.element.android.libraries.designsystem.theme.components.DropdownMenu import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem import io.element.android.libraries.designsystem.theme.components.Icon @@ -141,15 +139,21 @@ fun HomeTopBar( else -> stringResource(selectedNavigationItem.labelRes) } } - HomeNavigationBarItem.Spaces -> stringResource(selectedNavigationItem.labelRes) + HomeNavigationBarItem.Spaces -> null + } + displayTitle?.let { + val style = when (spaceFiltersState) { + // Space name + is SpaceFiltersState.Selected -> ElementTheme.typography.fontHeadingSmMedium + // "Chats" + else -> ElementTheme.typography.fontHeadingLgBold + } + AdaptativeTitle( + title = displayTitle, + style = style, + twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium, + ) } - Text( - modifier = Modifier.semantics { - heading() - }, - style = ElementTheme.typography.aliasScreenTitle, - text = displayTitle, - ) }, navigationIcon = { NavigationIcon( @@ -179,7 +183,9 @@ fun HomeTopBar( TopAppBarScrollBehaviorLayout(scrollBehavior = scrollBehavior) { RoomListFiltersView( state = filtersState, - modifier = Modifier.padding(bottom = 16.dp).padding(contentPadding) + modifier = Modifier + .padding(bottom = 16.dp) + .padding(contentPadding) ) } } @@ -348,45 +354,45 @@ private fun AccountIcon( ), contentAlignment = Alignment.Center, ) { - val avatarData by remember(matrixUser) { - derivedStateOf { - matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) - } + val avatarData by remember(matrixUser) { + derivedStateOf { + matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) } - val statusEmoji = matrixUser.displayedStatus?.toEmojiText() - val avatarModifier = if (statusEmoji != null) { - Modifier.eraseStatusEmojiBackground( - parentSize = AvatarSize.CurrentUserTopBar.dp, - layoutDirection = LocalLayoutDirection.current, - ) - } else { - Modifier - } - Avatar( - avatarData = avatarData, - avatarType = AvatarType.User, - modifier = avatarModifier, - contentDescription = if (isCurrentAccount) { - if (showAvatarIndicator) { - stringResource(CommonStrings.a11y_settings_with_required_action) - } else { - stringResource(CommonStrings.common_settings) - } - } else { - null - }, + } + val statusEmoji = matrixUser.displayedStatus?.toEmojiText() + val avatarModifier = if (statusEmoji != null) { + Modifier.eraseStatusEmojiBackground( + parentSize = AvatarSize.CurrentUserTopBar.dp, + layoutDirection = LocalLayoutDirection.current, ) - if (statusEmoji != null) { - StatusEmojiBadge( - emoji = statusEmoji, - modifier = Modifier.align(Alignment.BottomEnd), - ) - } - if (showAvatarIndicator) { - RedIndicatorAtom( - modifier = Modifier.align(Alignment.TopEnd) - ) - } + } else { + Modifier + } + Avatar( + avatarData = avatarData, + avatarType = AvatarType.User, + modifier = avatarModifier, + contentDescription = if (isCurrentAccount) { + if (showAvatarIndicator) { + stringResource(CommonStrings.a11y_settings_with_required_action) + } else { + stringResource(CommonStrings.common_settings) + } + } else { + null + }, + ) + if (statusEmoji != null) { + StatusEmojiBadge( + emoji = statusEmoji, + modifier = Modifier.align(Alignment.BottomEnd), + ) + } + if (showAvatarIndicator) { + RedIndicatorAtom( + modifier = Modifier.align(Alignment.TopEnd) + ) + } } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt index 96e9f9344b..ba3e8592b6 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt @@ -19,6 +19,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.matrix.api.room.CallIntentConsensus import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.roomlist.LatestEventValue import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.ui.model.dmUserStatus @@ -47,7 +48,7 @@ class RoomListRoomSummaryFactory( mode = DateFormatterMode.TimeOrDate, useRelative = true, ), - latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.isDm), + latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.hasOnlyTwoMembers()), avatarData = avatarData, userDefinedNotificationMode = roomInfo.userDefinedNotificationMode, hasRoomCall = roomInfo.hasRoomCall, @@ -81,6 +82,10 @@ class RoomListRoomSummaryFactory( ) } + private fun RoomInfo.hasOnlyTwoMembers(): Boolean { + return isDm || activeMembersCount <= 2 + } + private fun computeLatestEvent(latestEvent: LatestEventValue, dm: Boolean): LatestEvent { return when (latestEvent) { is LatestEventValue.None -> { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt index 914d08d7f6..f9cf38a1aa 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt @@ -143,7 +143,7 @@ private fun EmptySpaceHomeView( ) Text( text = stringResource(CommonStrings.screen_space_list_empty_state_title), - style = ElementTheme.typography.fontHeadingLgBold, + style = ElementTheme.typography.fontHeadingMdBold, color = ElementTheme.colors.textPrimary, textAlign = TextAlign.Center, ) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactoryTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactoryTest.kt index 5f307918be..bfd92094a5 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactoryTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactoryTest.kt @@ -8,10 +8,36 @@ package io.element.android.features.home.impl.datasource +import com.google.common.truth.Truth.assertThat import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomSummary +import org.junit.Test + +class RoomListRoomSummaryFactoryTest { + @Test + fun `a room with two members does not prefix its preview with the sender`() { + val formatter = FakeRoomLatestEventFormatter() + val factory = aRoomListRoomSummaryFactory(roomLatestEventFormatter = formatter) + + factory.create(aRoomSummary(info = aRoomInfo(isDm = false, activeMembersCount = 2))) + + assertThat(formatter.lastIsDmRoom).isTrue() + } + + @Test + fun `a room with more than two members prefixes its preview with the sender`() { + val formatter = FakeRoomLatestEventFormatter() + val factory = aRoomListRoomSummaryFactory(roomLatestEventFormatter = formatter) + + factory.create(aRoomSummary(info = aRoomInfo(isDm = false, activeMembersCount = 3))) + + assertThat(formatter.lastIsDmRoom).isFalse() + } +} fun aRoomListRoomSummaryFactory( dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" }, 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 index 446b969f12..3f08b269a1 100644 --- 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 @@ -115,6 +115,9 @@ fun ConfirmAccountProviderView( HeaderFooterPage( modifier = modifier, + // Let the header and content scroll so the supporting text is not clipped by the footer when the + // keyboard is visible and vertical space is reduced. + isScrollable = true, header = { IconTitleSubtitleMolecule( modifier = Modifier.padding(top = 60.dp), 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 3ba8099ecb..6a019d865b 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 @@ -51,6 +51,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.captionOrNull +import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter import io.element.android.features.roomcall.api.RoomCallState @@ -377,7 +378,7 @@ class MessagesPresenter( TimelineItemAction.Edit, TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting) TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState) - TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState) + TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState, enableTextFormatting) TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent) TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState) TimelineItemAction.ReplyInThread -> { @@ -546,10 +547,15 @@ class MessagesPresenter( private suspend fun handleActionEditCaption( targetEvent: TimelineItem.Event, composerState: MessageComposerState, + enableTextFormatting: Boolean, ) { val composerMode = MessageComposerMode.EditCaption( eventOrTransactionId = targetEvent.eventOrTransactionId, - content = targetEvent.content.captionOrNull().orEmpty(), + content = if (enableTextFormatting) { + targetEvent.content.htmlCaptionOrNull() ?: targetEvent.content.captionOrNull() + } else { + targetEvent.content.captionOrNull() + }.orEmpty(), ) composerState.eventSink( MessageComposerEvent.SetMode(composerMode) 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 040d0450ff..75966db1a3 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 @@ -108,6 +108,7 @@ class TimelineItemContentMessageFactory( fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), formattedCaption = formattedCaption, + htmlCaption = messageType.formattedCaption?.body, isEdited = content.isEdited, mediaSource = messageType.source, thumbnailSource = messageType.info?.thumbnailSource, @@ -132,6 +133,7 @@ class TimelineItemContentMessageFactory( fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), formattedCaption = formattedCaption, + htmlCaption = messageType.formattedCaption?.body, isEdited = content.isEdited, mediaSource = messageType.source, thumbnailSource = messageType.info?.thumbnailSource, @@ -174,6 +176,7 @@ class TimelineItemContentMessageFactory( fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), formattedCaption = formattedCaption, + htmlCaption = messageType.formattedCaption?.body, isEdited = content.isEdited, thumbnailSource = messageType.info?.thumbnailSource, mediaSource = messageType.source, @@ -198,6 +201,7 @@ class TimelineItemContentMessageFactory( fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), formattedCaption = formattedCaption, + htmlCaption = messageType.formattedCaption?.body, isEdited = content.isEdited, mediaSource = messageType.source, duration = messageType.info?.duration ?: Duration.ZERO, @@ -216,6 +220,7 @@ class TimelineItemContentMessageFactory( fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), formattedCaption = formattedCaption, + htmlCaption = messageType.formattedCaption?.body, isEdited = content.isEdited, mediaSource = messageType.source, duration = messageType.info?.duration ?: Duration.ZERO, @@ -235,6 +240,7 @@ class TimelineItemContentMessageFactory( fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), formattedCaption = formattedCaption, + htmlCaption = messageType.formattedCaption?.body, isEdited = content.isEdited, thumbnailSource = messageType.info?.thumbnailSource, mediaSource = messageType.source, @@ -345,6 +351,7 @@ class TimelineItemContentMessageFactory( body = messageType.body, caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() }, formattedCaption = formattedCaption, + htmlCaption = messageType.formatted?.body, isEdited = content.isEdited, items = galleryItems.toImmutableList(), ) @@ -402,6 +409,7 @@ class TimelineItemContentMessageFactory( body = messageType.body, caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() }, formattedCaption = formattedCaption, + htmlCaption = messageType.formatted?.body, isEdited = content.isEdited, attachments = attachments.toImmutableList(), ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt index 431942378a..3b16f79f79 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt @@ -91,7 +91,7 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean { // Can be grouped is FailedToParseMessageLikeContent, is MessageContent, - RedactedContent, + is RedactedContent, is StickerContent, is PollContent, is UnableToDecryptContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt index 2e01e53828..b34c66a08d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt @@ -12,6 +12,7 @@ import androidx.annotation.VisibleForTesting import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.UniqueId @@ -99,7 +100,8 @@ internal const val MIN_REDACTED_RUN_SIZE = 3 * Fold runs of [MIN_REDACTED_RUN_SIZE] or more consecutive deleted (redacted) messages into a single * [TimelineItem.GroupedEvents], so a long stretch of "Message removed" tiles shows as one expandable * "N removed messages" line, like element-web. Shorter runs, and anything that is not a redacted event - * (day dividers included), are left untouched. + * (day dividers included), are left untouched. A deleted message that still heads a thread is left + * untouched too, so that its thread summary stays reachable. * * This runs as the final step of [TimelineItemGrouper.group], after the state and membership events * have been grouped. It is deliberately kept out of the normal grouping: a run needs a higher minimum @@ -134,7 +136,7 @@ internal fun List.collapseRedactedRuns(groupIds: MutableMap, ) : TimelineItemEventContent, TimelineItemEventMutableContent { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt index f28de4f673..8c191ef140 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt @@ -17,6 +17,7 @@ data class TimelineItemAudioContent( override val fileSize: Long?, override val caption: String?, override val formattedCaption: CharSequence?, + override val htmlCaption: String? = null, override val isEdited: Boolean, val duration: Duration, override val mediaSource: MediaSource, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 01146a070b..efa73b91fc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -33,6 +33,9 @@ sealed interface TimelineItemEventContentWithAttachment : val fileSize: Long? val caption: String? val formattedCaption: CharSequence? + + /** The caption as it was written, in HTML, so that editing it can start from the formatted text. */ + val htmlCaption: String? val mediaSource: MediaSource val mimeType: String val formattedFileSize: String @@ -127,6 +130,13 @@ fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (thi else -> null } +fun TimelineItemEventContent.htmlCaptionOrNull(): String? = when (this) { + is TimelineItemEventContentWithAttachment -> htmlCaption + is TimelineItemGalleryContent -> htmlCaption + is TimelineItemAttachmentsContent -> htmlCaption + else -> null +} + fun TimelineItemEventContentWithAttachment.duration(): Duration? { return when (this) { is TimelineItemAudioContent -> duration diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt index 680797e36b..36beeeb91d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt @@ -16,6 +16,7 @@ data class TimelineItemFileContent( override val fileSize: Long?, override val caption: String?, override val formattedCaption: CharSequence?, + override val htmlCaption: String? = null, override val isEdited: Boolean, override val mediaSource: MediaSource, val thumbnailSource: MediaSource?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt index f376a2a00a..840f19488d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt @@ -16,6 +16,7 @@ data class TimelineItemGalleryContent( val body: String, val caption: String?, val formattedCaption: CharSequence?, + val htmlCaption: String? = null, override val isEdited: Boolean, val items: ImmutableList, ) : TimelineItemEventContent, TimelineItemEventMutableContent { 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 4828f22ba8..5e78adc945 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 @@ -19,6 +19,7 @@ data class TimelineItemImageContent( override val fileSize: Long?, override val caption: String?, override val formattedCaption: CharSequence?, + override val htmlCaption: String? = null, override val isEdited: Boolean, override val mediaSource: MediaSource, val thumbnailSource: MediaSource?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentPreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentPreviewParam.kt index cd93c7c006..316b2ab79b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentPreviewParam.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentPreviewParam.kt @@ -28,6 +28,7 @@ fun aTimelineItemImageContent( blurhash: String? = A_BLUR_HASH, filename: String = "A picture.jpg", caption: String? = null, + htmlCaption: String? = null, width: Int? = null, height: Int? = 300, ) = TimelineItemImageContent( @@ -35,6 +36,7 @@ fun aTimelineItemImageContent( fileSize = 4 * 1024 * 1024L, caption = caption, formattedCaption = null, + htmlCaption = htmlCaption, isEdited = false, mediaSource = MediaSource(""), thumbnailSource = null, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt index 9e2f08d5c7..65585bc028 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt @@ -15,6 +15,7 @@ data class TimelineItemStickerContent( override val fileSize: Long?, override val caption: String?, override val formattedCaption: CharSequence?, + override val htmlCaption: String? = null, override val isEdited: Boolean, override val mediaSource: MediaSource, val thumbnailSource: MediaSource?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt index 80635c6e18..a1c8e279cd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt @@ -16,6 +16,7 @@ data class TimelineItemVideoContent( override val fileSize: Long?, override val caption: String?, override val formattedCaption: CharSequence?, + override val htmlCaption: String? = null, override val isEdited: Boolean, val duration: Duration, override val mediaSource: MediaSource, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt index 29a432e6bd..806a703ea5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt @@ -19,6 +19,7 @@ data class TimelineItemVoiceContent( override val fileSize: Long?, override val caption: String?, override val formattedCaption: CharSequence?, + override val htmlCaption: String? = null, override val isEdited: Boolean, val duration: Duration, override val mediaSource: MediaSource, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 227088f78c..4c0abc0a34 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -989,6 +989,33 @@ class MessagesPresenterTest { } } + @Test + fun `present - handle action edit caption starts from the formatted caption`() = runTest { + val messageEvent = aMessageEvent( + content = aTimelineItemImageContent( + caption = "Hello world", + htmlCaption = "Hello world", + ) + ) + val composerRecorder = EventsRecorder() + val presenter = createMessagesPresenter( + messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder, showTextFormatting = true) }, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.EditCaption, messageEvent)) + awaitItem() + composerRecorder.assertSingle( + MessageComposerEvent.SetMode( + composerMode = MessageComposerMode.EditCaption( + eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(), + content = "Hello world", + ) + ) + ) + } + } + @Test fun `present - handle action add caption`() = runTest { val composerRecorder = EventsRecorder() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index e00cec3322..3b20614dac 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -51,7 +51,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender import io.element.android.libraries.matrix.api.timeline.item.event.Receipt -import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -69,6 +68,7 @@ import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.aRedactedContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent import io.element.android.libraries.matrix.ui.components.aMatrixUserList @@ -880,7 +880,7 @@ class TimelinePresenterTest { (0 until 3).map { index -> MatrixTimelineItem.Event( uniqueId = UniqueId("redacted_$index"), - event = anEventTimelineItem(eventId = EventId("\$R$index"), content = RedactedContent), + event = anEventTimelineItem(eventId = EventId("\$R$index"), content = aRedactedContent()), ) } ), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt index 3c51fc88ef..0e174ce312 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt @@ -12,15 +12,21 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo +import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.aRedactedContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -87,6 +93,42 @@ class TimelineItemsFactoryTest { ).inOrder() } + @Test + fun `redacted message keeps its thread info`() = runTest { + val factory = aTimelineItemsFactory( + config = TimelineItemsFactoryConfig( + computeReadReceipts = false, + computeReactions = false, + ) + ) + val items = listOf( + MatrixTimelineItem.Event( + uniqueId = UniqueId("event-0"), + event = anEventTimelineItem( + sender = A_USER_ID, + timestamp = 0L, + content = aRedactedContent( + threadInfo = EventThreadInfo.ThreadResponse(A_THREAD_ID), + ), + ), + ) + ) + var threadInfo: TimelineItemThreadInfo? = null + factory.timelineItems.test { + factory.replaceWith( + timelineItems = items, + roomMembers = emptyList(), + renderReadReceipts = false, + ) + threadInfo = awaitItem() + .filterIsInstance() + .firstOrNull() + ?.threadInfo + cancelAndIgnoreRemainingEvents() + } + assertThat(threadInfo).isEqualTo(TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID)) + } + @Test fun `a key verification request is not rendered`() = runTest { val factory = aTimelineItemsFactory( @@ -131,6 +173,43 @@ class TimelineItemsFactoryTest { } } + @Test + fun `a deleted message keeps the summary of the thread it heads`() = runTest { + val factory = aTimelineItemsFactory( + config = TimelineItemsFactoryConfig( + computeReadReceipts = false, + computeReactions = false, + ) + ) + val items = listOf( + MatrixTimelineItem.Event( + uniqueId = UniqueId("event-0"), + event = anEventTimelineItem( + sender = A_USER_ID, + content = aRedactedContent( + threadInfo = EventThreadInfo.ThreadRoot( + summary = ThreadSummary(latestEvent = AsyncData.Uninitialized, numberOfReplies = 3), + ), + ), + ), + ), + ) + factory.timelineItems.test { + factory.replaceWith( + timelineItems = items, + roomMembers = emptyList(), + renderReadReceipts = false, + ) + val threadInfo = awaitItem() + .filterIsInstance() + .single() + .threadInfo + assertThat(threadInfo).isInstanceOf(TimelineItemThreadInfo.ThreadRoot::class.java) + assertThat((threadInfo as TimelineItemThreadInfo.ThreadRoot).summary.numberOfReplies).isEqualTo(3) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `a sender the timeline knows nothing about is named from the room member list`() = runTest { val factory = aTimelineItemsFactory( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index fc01184764..1c9de095d3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -297,6 +297,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() { fileSize = 555L, caption = "body.mp4 caption", formattedCaption = SpannedString("formatted"), + htmlCaption = "formatted", isEdited = true, duration = 1.minutes, mediaSource = MediaSource(url = "url", json = null), @@ -539,6 +540,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() { fileSize = 888L, caption = "body.jpg caption", formattedCaption = SpannedString("formatted"), + htmlCaption = "formatted", isEdited = true, mediaSource = MediaSource(url = "url", json = null), thumbnailSource = MediaSource("url_thumbnail"), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt index 005eb5730e..f6ef4f0fc2 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt @@ -15,11 +15,14 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.model.ReadReceiptData import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts +import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.anAvatarData import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_USER_ID @@ -190,6 +193,22 @@ class TimelineItemGrouperTest { ) } + @Test + fun `a deleted message that still heads a thread is left out of the removed messages group`() { + val threadRoot = aGroupableItem.copy( + id = UniqueId("r1"), + content = TimelineItemRedactedContent, + threadInfo = TimelineItemThreadInfo.ThreadRoot( + summary = ThreadSummary(latestEvent = AsyncData.Uninitialized, numberOfReplies = 2), + latestEventText = null, + ), + ) + val r0 = aGroupableItem.copy(id = UniqueId("r0"), content = TimelineItemRedactedContent) + val r2 = aGroupableItem.copy(id = UniqueId("r2"), content = TimelineItemRedactedContent) + val result = sut.group(listOf(r0, threadRoot, r2)) + assertThat(result).isEqualTo(listOf(r0, threadRoot, r2)) + } + @Test fun `a run of fewer than three redacted events is left untouched`() { val r0 = aGroupableItem.copy(id = UniqueId("r0"), content = TimelineItemRedactedContent) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt index a1ffd1f02b..9a5eda366b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt @@ -16,11 +16,11 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails -import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.core.FakeSendHandle +import io.element.android.libraries.matrix.test.timeline.aRedactedContent import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.tests.testutils.testCoroutineDispatchers @@ -88,7 +88,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf( sender = A_USER_ID, senderProfile = ProfileDetails.Unavailable, timestamp = 9442, - content = RedactedContent, + content = aRedactedContent(), origin = null, timelineItemDebugInfoProvider = { TimelineItemDebugInfo( diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index e647ebb919..12554eb6fa 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -64,6 +64,7 @@ dependencies { implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) implementation(projects.libraries.matrixui) + implementation(projects.libraries.mediaviewer.api) implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.permissions.api) @@ -91,6 +92,7 @@ dependencies { implementation(libs.color.picker) implementation(libs.androidx.browser) implementation(libs.androidx.datastore.preferences) + implementation(libs.serialization.json) api(projects.features.preferences.api) implementation(libs.showkase) @@ -108,6 +110,7 @@ dependencies { testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.roomselect.test) + testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.libraries.troubleshoot.test) testImplementation(projects.features.deactivation.test) testImplementation(projects.features.enterprise.test) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index 15718ca0f0..c85eed5b9d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -40,10 +40,12 @@ import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.appyx.canPop import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.SessionScope 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.user.MatrixUser +import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint import kotlinx.parcelize.Parcelize @@ -56,6 +58,7 @@ class PreferencesFlowNode( private val lockScreenEntryPoint: LockScreenEntryPoint, private val notificationTroubleShootEntryPoint: NotificationTroubleShootEntryPoint, private val pushHistoryEntryPoint: PushHistoryEntryPoint, + private val fileViewerEntryPoint: FileViewerEntryPoint, private val logoutEntryPoint: LogoutEntryPoint, private val openSourceLicensesEntryPoint: OpenSourceLicensesEntryPoint, private val accountDeactivationEntryPoint: AccountDeactivationEntryPoint, @@ -95,6 +98,9 @@ class PreferencesFlowNode( @Parcelize data object PushHistory : NavTarget + @Parcelize + data class PushRules(val filename: String, val content: String) : NavTarget + @Parcelize data object LockScreenSettings : NavTarget @@ -187,6 +193,10 @@ class PreferencesFlowNode( } NavTarget.DeveloperSettings -> { val developerSettingsCallback = object : DeveloperSettingsNode.Callback { + override fun navigateToPushRules(filename: String, content: String) { + backstack.push(NavTarget.PushRules(filename = filename, content = content)) + } + override fun navigateToPushHistory() { backstack.push(NavTarget.PushHistory) } @@ -270,6 +280,26 @@ class PreferencesFlowNode( }, ) } + is NavTarget.PushRules -> { + fileViewerEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = FileViewerEntryPoint.Params( + filename = navTarget.filename, + mimeType = MimeTypes.Json, + content = navTarget.content, + ), + callback = object : FileViewerEntryPoint.Callback { + override fun onDone() { + if (backstack.canPop()) { + backstack.pop() + } else { + navigateUp() + } + } + }, + ) + } is NavTarget.EditDefaultNotificationSetting -> { val callback = object : EditDefaultNotificationSettingNode.Callback { override fun navigateToRoomNotificationSettings(roomId: RoomId) { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt index e0ad208fc1..34ccba2abe 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt @@ -17,4 +17,6 @@ sealed interface DeveloperSettingsEvent { data object VacuumStores : DeveloperSettingsEvent data class MarkAllRoomsAsRead(val needsConfirmation: Boolean) : DeveloperSettingsEvent data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent + data object OpenPushRules : DeveloperSettingsEvent + data object DismissPushRulesError : DeveloperSettingsEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNavigator.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNavigator.kt new file mode 100644 index 0000000000..8201c7a14d --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNavigator.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.developer + +/** + * Navigation performed by the developer settings screen, when data has to be loaded first. + */ +fun interface DeveloperSettingsNavigator { + fun openPushRules(filename: String, content: String) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt index a8a8c63eed..7a8c3222d1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt @@ -28,15 +28,22 @@ import io.element.android.libraries.di.SessionScope class DeveloperSettingsNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: DeveloperSettingsPresenter, + presenterFactory: DeveloperSettingsPresenter.Factory, private val extraDeveloperOptionsRenderer: ExtraDeveloperOptionsRenderer, -) : Node(buildContext, plugins = plugins) { +) : Node(buildContext, plugins = plugins), + DeveloperSettingsNavigator { interface Callback : Plugin { + fun navigateToPushRules(filename: String, content: String) fun navigateToPushHistory() fun onDone() } private val callback: Callback = callback() + private val presenter = presenterFactory.create(navigator = this) + + override fun openPushRules(filename: String, content: String) { + callback.navigateToPushRules(filename = filename, content = content) + } @Composable override fun View(modifier: Modifier) { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index c925433608..74bf595ce4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -17,7 +17,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.toArgb -import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase @@ -30,17 +32,23 @@ import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.core.data.ByteUnit +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement -@Inject +@AssistedInject class DeveloperSettingsPresenter( + @Assisted private val navigator: DeveloperSettingsNavigator, private val appDeveloperSettingsPresenter: Presenter, private val sessionId: SessionId, private val deviceId: DeviceId, @@ -52,7 +60,13 @@ class DeveloperSettingsPresenter( private val fileSizeFormatter: FileSizeFormatter, private val markAllRoomsAsRead: MarkAllRoomsAsRead, private val buildMeta: BuildMeta, + private val notificationSettingsService: NotificationSettingsService, ) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(navigator: DeveloperSettingsNavigator): DeveloperSettingsPresenter + } + @Composable override fun present(): DeveloperSettingsState { val cacheSize = remember { @@ -67,6 +81,9 @@ class DeveloperSettingsPresenter( val markAllRoomsAsReadAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + val pushRulesAction = remember { + mutableStateOf>(AsyncAction.Uninitialized) + } var showColorPicker by remember { mutableStateOf(false) } @@ -109,6 +126,10 @@ class DeveloperSettingsPresenter( DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation -> { markAllRoomsAsReadAction.value = AsyncAction.Uninitialized } + DeveloperSettingsEvent.OpenPushRules -> coroutineScope.openPushRules(pushRulesAction) + DeveloperSettingsEvent.DismissPushRulesError -> { + pushRulesAction.value = AsyncAction.Uninitialized + } } } @@ -119,6 +140,7 @@ class DeveloperSettingsPresenter( databaseSizes = databaseSizes.value, clearCacheAction = clearCacheAction.value, markAllRoomsAsReadAction = markAllRoomsAsReadAction.value, + pushRulesAction = pushRulesAction.value, isEnterpriseBuild = buildMeta.isEnterpriseBuild, showColorPicker = showColorPicker, deviceId = deviceId, @@ -164,4 +186,40 @@ class DeveloperSettingsPresenter( markAllRoomsAsRead().getOrThrow() }.runCatchingUpdatingState(state = markAllRoomsAsReadAction) } + + private fun CoroutineScope.openPushRules(pushRulesAction: MutableState>) = launch { + pushRulesAction.value = AsyncAction.Loading + notificationSettingsService.getRawPushRules() + .onSuccess { content -> + pushRulesAction.value = AsyncAction.Uninitialized + navigator.openPushRules( + filename = pushRulesFilename(), + content = content.orEmpty().prettyPrintJson(), + ) + } + .onFailure { + pushRulesAction.value = AsyncAction.Failure(it) + } + } + + /** + * The user id contains a colon, which is not a valid character for a file name on all the file systems, + * so replace it by an underscore. + */ + private fun pushRulesFilename() = "push_rules${sessionId.value.replace(':', '_')}.json" } + +@OptIn(ExperimentalSerializationApi::class) +private val prettyPrintJson = Json { + prettyPrint = true + // Keep the indentation small, for a better rendering on mobile. + prettyPrintIndent = " " +} + +/** + * Pretty print this json content, so that it is readable when rendered. + * Return the content as is if it is not valid json. + */ +private fun String.prettyPrintJson(): String = runCatchingExceptions { + prettyPrintJson.encodeToString(JsonElement.serializer(), prettyPrintJson.parseToJsonElement(this)) +}.getOrDefault(this) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt index 3a859f14bb..a4345c6452 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -20,10 +20,13 @@ data class DeveloperSettingsState( val databaseSizes: AsyncData>, val clearCacheAction: AsyncAction, val markAllRoomsAsReadAction: AsyncAction, + val pushRulesAction: AsyncAction, val isEnterpriseBuild: Boolean, val showColorPicker: Boolean, val deviceId: DeviceId, val eventSink: (DeveloperSettingsEvent) -> Unit ) { - val showLoader = clearCacheAction is AsyncAction.Loading || markAllRoomsAsReadAction is AsyncAction.Loading + val showLoader = clearCacheAction is AsyncAction.Loading || + markAllRoomsAsReadAction is AsyncAction.Loading || + pushRulesAction is AsyncAction.Loading } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStatePreviewParam.kt index cbe95d6b20..0a42251b40 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStatePreviewParam.kt @@ -32,6 +32,9 @@ open class DeveloperSettingsStatePreviewParam : PreviewParameterProvider = AsyncAction.Uninitialized, markAllRoomsAsReadAction: AsyncAction = AsyncAction.Uninitialized, + pushRulesAction: AsyncAction = AsyncAction.Uninitialized, isEnterpriseBuild: Boolean = false, showColorPicker: Boolean = false, deviceId: DeviceId = DeviceId("ILAKNDNASDLK"), @@ -49,6 +53,7 @@ fun aDeveloperSettingsState( databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")), clearCacheAction = clearCacheAction, markAllRoomsAsReadAction = markAllRoomsAsReadAction, + pushRulesAction = pushRulesAction, isEnterpriseBuild = isEnterpriseBuild, showColorPicker = showColorPicker, deviceId = deviceId, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index e143f42b0b..8eff49c471 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -23,8 +23,10 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView import io.element.android.libraries.androidutils.system.copyToClipboard +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferencePage @@ -60,6 +62,12 @@ fun DeveloperSettingsView( onDismiss = { state.eventSink(DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation) }, ) } + (state.pushRulesAction as? AsyncAction.Failure)?.let { failure -> + ErrorDialog( + content = failure.error.message ?: stringResource(CommonStrings.error_unknown), + onSubmit = { state.eventSink(DeveloperSettingsEvent.DismissPushRulesError) }, + ) + } BackHandler( enabled = !state.showLoader, onBack = onBackClick, @@ -79,7 +87,10 @@ fun DeveloperSettingsView( onOpenShowkase = onOpenShowkase, ) SessionCategory(deviceId = state.deviceId) - NotificationCategory(onPushHistoryClick) + NotificationCategory( + onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) }, + onPushHistoryClick = onPushHistoryClick, + ) MarkAllRoomsAsReadCategory(state) if (state.isEnterpriseBuild) { @@ -217,8 +228,17 @@ private fun MarkAllRoomsAsReadCategory(state: DeveloperSettingsState) { } @Composable -private fun NotificationCategory(onPushHistoryClick: () -> Unit) { +private fun NotificationCategory( + onPushRulesClick: () -> Unit, + onPushHistoryClick: () -> Unit, +) { PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) { + ListItem( + content = { + Text("Push rules") + }, + onClick = onPushRulesClick, + ) ListItem( content = { Text(stringResource(R.string.troubleshoot_notifications_entry_point_push_history_title)) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt index 963d7846b4..afc3801c88 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt @@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.mediaviewer.test.FakeFileViewerEntryPoint import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint import io.element.android.tests.testutils.lambda.lambdaError @@ -43,6 +44,7 @@ class DefaultPreferencesEntryPointTest { lockScreenEntryPoint = FakeLockScreenEntryPoint(), notificationTroubleShootEntryPoint = FakeNotificationTroubleShootEntryPoint(), pushHistoryEntryPoint = FakePushHistoryEntryPoint(), + fileViewerEntryPoint = FakeFileViewerEntryPoint(), logoutEntryPoint = FakeLogoutEntryPoint(), openSourceLicensesEntryPoint = FakeOpenSourceLicensesEntryPoint(), accountDeactivationEntryPoint = FakeAccountDeactivationEntryPoint(), diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index 753d89da95..b76939f72c 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -28,10 +28,13 @@ import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test @@ -41,6 +44,19 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +private const val A_PUSH_RULES_CONTENT = """{"global":{"override":[{"rule_id":".m.rule.master","enabled":false}]}}""" +private const val A_PUSH_RULES_CONTENT_PRETTY_PRINTED = """{ + "global": { + "override": [ + { + "rule_id": ".m.rule.master", + "enabled": false + } + ] + } +}""" +private val AN_EXCEPTION = Exception("A failure") + class DeveloperSettingsPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -170,7 +186,72 @@ class DeveloperSettingsPresenterTest { } } + @Test + fun `present - OpenPushRules event navigates to the push rules content`() = runTest { + val openPushRulesLambda = lambdaRecorder { _, _ -> } + val presenter = createDeveloperSettingsPresenter( + navigator = DeveloperSettingsNavigator(openPushRulesLambda), + notificationSettingsService = FakeNotificationSettingsService( + getRawPushRulesResult = { Result.success(A_PUSH_RULES_CONTENT) }, + ), + ) + presenter.test { + val state = awaitItem() + assertThat(state.pushRulesAction).isEqualTo(AsyncAction.Uninitialized) + state.eventSink(DeveloperSettingsEvent.OpenPushRules) + skipItems(1) + awaitItem().also { + assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Uninitialized) + } + openPushRulesLambda.assertions().isCalledOnce().with( + value("push_rules@alice_server.org.json"), + value(A_PUSH_RULES_CONTENT_PRETTY_PRINTED), + ) + } + } + + @Test + fun `present - OpenPushRules event keeps the content as is when it is not valid json`() = runTest { + val openPushRulesLambda = lambdaRecorder { _, _ -> } + val presenter = createDeveloperSettingsPresenter( + navigator = DeveloperSettingsNavigator(openPushRulesLambda), + notificationSettingsService = FakeNotificationSettingsService( + getRawPushRulesResult = { Result.success("not json") }, + ), + ) + presenter.test { + awaitItem().eventSink(DeveloperSettingsEvent.OpenPushRules) + skipItems(2) + openPushRulesLambda.assertions().isCalledOnce().with( + value("push_rules@alice_server.org.json"), + value("not json"), + ) + } + } + + @Test + fun `present - OpenPushRules event failure can be dismissed`() = runTest { + val presenter = createDeveloperSettingsPresenter( + notificationSettingsService = FakeNotificationSettingsService( + getRawPushRulesResult = { Result.failure(AN_EXCEPTION) }, + ), + ) + presenter.test { + val state = awaitItem() + state.eventSink(DeveloperSettingsEvent.OpenPushRules) + skipItems(1) + awaitItem().also { + assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + it.eventSink(DeveloperSettingsEvent.DismissPushRulesError) + } + awaitItem().also { + assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Uninitialized) + } + } + } + private fun createDeveloperSettingsPresenter( + navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() }, sessionId: SessionId = A_SESSION_ID, deviceId: DeviceId = A_DEVICE_ID, cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(), @@ -180,8 +261,10 @@ class DeveloperSettingsPresenterTest { databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) }, markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(), buildMeta: BuildMeta = aBuildMeta(), + notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(), ): DeveloperSettingsPresenter { return DeveloperSettingsPresenter( + navigator = navigator, appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() }, sessionId = sessionId, deviceId = deviceId, @@ -193,6 +276,7 @@ class DeveloperSettingsPresenterTest { fileSizeFormatter = FakeFileSizeFormatter(), markAllRoomsAsRead = markAllRoomsAsRead, buildMeta = buildMeta, + notificationSettingsService = notificationSettingsService, ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt index f1151bd3e1..fcc69cda91 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt @@ -57,6 +57,19 @@ class DeveloperSettingsViewTest : RobolectricTest() { } } + @Config(qualifiers = "h2400dp") + @Test + fun `clicking on push rules emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setDeveloperSettingsView( + state = aDeveloperSettingsState( + eventSink = eventsRecorder + ), + ) + onNodeWithText("Push rules").performScrollTo().performClick() + eventsRecorder.assertSingle(DeveloperSettingsEvent.OpenPushRules) + } + @Config(qualifiers = "h2000dp") @Test fun `clicking on open showkase invokes the expected callback`() = runAndroidComposeUiTest { diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt index c97bf2cd2e..05851437cc 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt @@ -23,9 +23,11 @@ import io.element.android.libraries.matrix.api.verification.VerificationRequest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.timeout +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import com.freeletics.flowredux.dsl.State as MachineState @@ -36,6 +38,11 @@ class OutgoingVerificationStateMachine( ) : FlowReduxStateMachine( initialState = State.Initial, ) { + companion object { + /** Lifetime the Matrix specification gives a verification request; past it the other session can no longer accept ours. */ + private val VERIFICATION_REQUEST_TIMEOUT = 10.minutes + } + init { spec { inState { @@ -44,11 +51,15 @@ class OutgoingVerificationStateMachine( } } inState { - onEnterEffect { event -> - when (event.verificationRequest) { + onEnter { state -> + val verificationRequest = state.snapshot.verificationRequest + when (verificationRequest) { is VerificationRequest.Outgoing.CurrentSession -> sessionVerificationService.requestDeviceVerification() - is VerificationRequest.Outgoing.User -> sessionVerificationService.requestUserVerification(event.verificationRequest.userId) + is VerificationRequest.Outgoing.User -> sessionVerificationService.requestUserVerification(verificationRequest.userId) } + delay(VERIFICATION_REQUEST_TIMEOUT) + sessionVerificationService.cancelVerification() + state.override { State.Canceled.andLogStateChange() } } on { _, state -> state.override { State.VerificationRequestAccepted.andLogStateChange() } diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt index e2cb533159..7dfacc40f1 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt @@ -31,10 +31,12 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import kotlin.time.Duration.Companion.minutes @ExperimentalCoroutinesApi class OutgoingVerificationPresenterTest { @@ -141,6 +143,26 @@ class OutgoingVerificationPresenterTest { } } + @Test + fun `present - A verification request the other session never accepts is canceled`() = runTest { + val cancelVerificationRecorder = lambdaRecorder {} + val service = unverifiedSessionService( + requestDeviceVerificationLambda = { }, + cancelVerificationLambda = cancelVerificationRecorder, + ) + val presenter = createOutgoingVerificationPresenter(service) + presenter.test { + awaitItem().eventSink(OutgoingVerificationViewEvent.RequestVerification) + advanceTimeBy(1.minutes) + assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse) + advanceTimeBy(9.minutes) + expectNoEvents() + advanceTimeBy(2.minutes) + assertThat(awaitItem().step).isEqualTo(Step.Canceled) + cancelVerificationRecorder.assertions().isCalledOnce() + } + } + @Test fun `present - Canceling the flow once it's verifying cancels it`() = runTest { val service = unverifiedSessionService( @@ -363,7 +385,7 @@ class OutgoingVerificationPresenterTest { var state = awaitItem() assertThat(state.step).isEqualTo(Step.Initial) state.eventSink(OutgoingVerificationViewEvent.RequestVerification) - testScope.advanceUntilIdle() + testScope.advanceTimeBy(1.minutes) // Await for other device response: fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest) state = awaitItem() diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt index 03f442f36e..add55cee36 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt @@ -16,6 +16,7 @@ import android.provider.MediaStore import androidx.annotation.RequiresApi import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.file.saveWithUniqueFileName import io.element.android.libraries.androidutils.system.toast import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions @@ -25,6 +26,7 @@ import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File import java.io.FileOutputStream +import java.io.IOException interface FileSave { suspend fun save( @@ -61,18 +63,18 @@ class DefaultFileSave( @RequiresApi(Build.VERSION_CODES.Q) private fun saveOnDiskUsingMediaStore(path: String) { val file = File(path) - val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, file.name) - put(MediaStore.MediaColumns.MIME_TYPE, MimeTypes.OctetStream) - put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) - } val resolver = context.contentResolver - val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) - if (outputUri != null) { - file.inputStream().use { input -> - resolver.openOutputStream(outputUri).use { output -> - input.copyTo(output!!, DEFAULT_BUFFER_SIZE) - } + val outputUri = saveWithUniqueFileName(file.name) { displayName -> + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, MimeTypes.OctetStream) + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + } ?: throw IOException("Unable to create the destination file") + file.inputStream().use { input -> + resolver.openOutputStream(outputUri).use { output -> + input.copyTo(output!!, DEFAULT_BUFFER_SIZE) } } } diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/DefaultFileSaveTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/DefaultFileSaveTest.kt new file mode 100644 index 0000000000..a991a11f76 --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/DefaultFileSaveTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.file + +import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.fake.registerFakeMediaStoreContentProvider +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import java.io.File + +class DefaultFileSaveTest : RobolectricTest() { + @Test + fun `save inserts the file in the Downloads collection under its own name`() = runTest { + val mediaStore = registerFakeMediaStoreContentProvider() + createFileSave().save(aFileToSave().path) + assertThat(mediaStore.insertedDisplayNames).containsExactly("a-file.txt") + assertThat(mediaStore.savedContent("a-file.txt")).isEqualTo("Some content") + } + + @Test + fun `save retries with a suffixed name when the name cannot be made unique`() = runTest { + val mediaStore = registerFakeMediaStoreContentProvider().apply { + conflictingDisplayNames = setOf("a-file.txt") + } + createFileSave().save(aFileToSave().path) + assertThat(mediaStore.insertedDisplayNames).hasSize(2) + assertThat(mediaStore.insertedDisplayNames.last()).matches("a-file_\\d+\\.txt") + assertThat(mediaStore.savedContent(mediaStore.insertedDisplayNames.last())).isEqualTo("Some content") + } + + @Test + fun `save writes nothing when the destination cannot be created`() = runTest { + val mediaStore = registerFakeMediaStoreContentProvider().apply { + returnsNoUriOnInsert = true + } + createFileSave().save(aFileToSave().path) + assertThat(mediaStore.insertedDisplayNames).containsExactly("a-file.txt") + assertThat(mediaStore.savedFileNames()).isEmpty() + } + + private fun aFileToSave(): File { + return File(RuntimeEnvironment.getApplication().cacheDir, "a-file.txt").apply { + writeText("Some content") + } + } + + private fun TestScope.createFileSave() = DefaultFileSave( + context = RuntimeEnvironment.getApplication(), + dispatchers = testCoroutineDispatchers(), + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 46890d253f..395d5bfc62 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,7 +84,7 @@ kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlin kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } # https://firebase.google.com/docs/android/setup#available-libraries -google_firebase_bom = "com.google.firebase:firebase-bom:34.17.0" +google_firebase_bom = "com.google.firebase:firebase-bom:34.18.0" firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" } ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } @@ -175,7 +175,7 @@ test_truth = "com.google.truth:truth:1.4.5" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.22" test_robolectric = "org.robolectric:robolectric:4.16.1" test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } -test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.9.2" +test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.9.3" test_detekt_api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" } @@ -185,7 +185,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.19" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.25" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } @@ -208,7 +208,7 @@ matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } -sqlcipher = "net.zetetic:sqlcipher-android:4.17.0" +sqlcipher = "net.zetetic:sqlcipher-android:4.18.0" sqlite = "androidx.sqlite:sqlite-ktx:2.7.0" unifiedpush = "org.unifiedpush.android:connector:3.3.3" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" @@ -222,7 +222,7 @@ maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" opusencoder = "io.element.android:opusencoder:1.2.0" zxing_cpp = "io.github.zxing-cpp:android:3.1.1" google_zxing = "com.google.zxing:core:3.5.4" -google_guava = "com.google.guava:guava:33.7.0-android" +google_guava = "com.google.guava:guava:33.7.1-android" haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } 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 21306ff192..7c5f0450e9 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 @@ -42,6 +42,32 @@ fun File.safeRenameTo(dest: File) { ) } +/** + * Returns this file name with [suffix] inserted before its extension, i.e. `"image.png"` with the + * suffix `"_1"` gives `"image_1.png"`. A name without extension simply gets the suffix appended. + */ +fun String.withFileNameSuffix(suffix: String): String { + val extension = substringAfterLast('.', "") + return if (extension.isEmpty()) { + "$this$suffix" + } else { + "${substringBeforeLast('.')}$suffix.$extension" + } +} + +fun saveWithUniqueFileName( + fileName: String, + uniqueSuffix: () -> String = { "_${System.currentTimeMillis()}" }, + saveAs: (String) -> T, +): T { + return try { + saveAs(fileName) + } catch (nameConflict: IllegalStateException) { + Timber.w(nameConflict, "Unable to build a unique file name, retrying with a suffixed one") + saveAs(fileName.withFileNameSuffix(uniqueSuffix())) + } +} + 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/test/kotlin/io/element/android/libraries/androidutils/file/FileTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/file/FileTest.kt new file mode 100644 index 0000000000..b2e28f6249 --- /dev/null +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/file/FileTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.file + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class FileTest { + @Test + fun `withFileNameSuffix - inserts the suffix before the extension`() { + assertThat("image.png".withFileNameSuffix("_1")).isEqualTo("image_1.png") + } + + @Test + fun `withFileNameSuffix - appends the suffix when there is no extension`() { + assertThat("README".withFileNameSuffix("_1")).isEqualTo("README_1") + } + + @Test + fun `withFileNameSuffix - only considers the last extension`() { + assertThat("archive.tar.gz".withFileNameSuffix("_1")).isEqualTo("archive.tar_1.gz") + } + + @Test + fun `withFileNameSuffix - handles a name starting with a dot`() { + assertThat(".hidden".withFileNameSuffix("_1")).isEqualTo("_1.hidden") + } + + @Test + fun `saveWithUniqueFileName - uses the original name when it works`() { + val usedNames = mutableListOf() + + val result = saveWithUniqueFileName( + fileName = "image.png", + uniqueSuffix = { "_1" }, + ) { name -> + usedNames.add(name) + "saved as $name" + } + + assertThat(result).isEqualTo("saved as image.png") + assertThat(usedNames).containsExactly("image.png") + } + + @Test + fun `saveWithUniqueFileName - retries with a suffixed name when the name cannot be made unique`() { + val usedNames = mutableListOf() + + val result = saveWithUniqueFileName( + fileName = "image.png", + uniqueSuffix = { "_1" }, + ) { name -> + usedNames.add(name) + if (name == "image.png") { + error("Failed to build unique file") + } + "saved as $name" + } + + assertThat(result).isEqualTo("saved as image_1.png") + assertThat(usedNames).containsExactly("image.png", "image_1.png").inOrder() + } + + @Test + fun `saveWithUniqueFileName - does not retry more than once`() { + val usedNames = mutableListOf() + + val error = try { + saveWithUniqueFileName( + fileName = "image.png", + uniqueSuffix = { "_1" }, + ) { name -> + usedNames.add(name) + error("Failed to build unique file") + } + null + } catch (nameConflict: IllegalStateException) { + nameConflict + } + + assertThat(error).isInstanceOf(IllegalStateException::class.java) + assertThat(usedNames).containsExactly("image.png", "image_1.png").inOrder() + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewTitleAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewTitleAtom.kt index 740605fe88..4188827a8a 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewTitleAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewTitleAtom.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.designsystem.atomic.atoms import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import io.element.android.compound.theme.ElementTheme @@ -20,11 +21,12 @@ fun RoomPreviewTitleAtom( title: String, modifier: Modifier = Modifier, fontStyle: FontStyle? = null, + style: TextStyle = ElementTheme.typography.fontHeadingLgBold ) { Text( modifier = modifier, text = title, - style = ElementTheme.typography.fontHeadingLgBold, + style = style, textAlign = TextAlign.Center, fontStyle = fontStyle, color = ElementTheme.colors.textPrimary, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AdaptativeTitle.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AdaptativeTitle.kt new file mode 100644 index 0000000000..f73347d67f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AdaptativeTitle.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.text + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * Render the title of a screen, but adapt the style if it does not fit on a single line, + * so that a wrapped title does not look oversized. + * + * @param title the title to render. + * @param style the style to use to render the [title]. + * @param twoLinesStyle the style to use instead of [style] when the [title] does not fit on a single line with [style], + * so that a wrapped title does not look oversized. + * @param modifier the [Modifier] to apply to this layout. + */ +@Composable +fun AdaptativeTitle( + title: String, + style: TextStyle, + twoLinesStyle: TextStyle, + modifier: Modifier = Modifier, +) { + // If style and twoLinesStyle are the same, we don't need to measure the text. + if (style == twoLinesStyle) { + Text( + modifier = modifier.semantics { + heading() + }, + style = style, + maxLines = 2, + text = title, + ) + } else { + val textMeasurer = rememberTextMeasurer() + BoxWithConstraints(modifier = modifier) { + // Measure the title before rendering it, so that the style is picked in a single layout pass. + val fitsOnASingleLine = textMeasurer.measure( + text = title, + style = style, + constraints = constraints, + ).lineCount == 1 + Text( + modifier = Modifier.semantics { + heading() + }, + style = if (fitsOnASingleLine) style else twoLinesStyle, + maxLines = 2, + text = title, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun AdaptativeTitlePreview() = ElementPreview { + @Composable + fun AdaptativeTitleItem( + text: String, + content: @Composable () -> Unit, + ) { + Column( + modifier = Modifier + .width(180.dp) + .background(ElementTheme.colors.bgSubtlePrimary), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = text, + style = ElementTheme.typography.fontBodyXsRegular, + color = ElementTheme.colors.textSecondary, + ) + content() + } + } + Column( + modifier = Modifier.padding(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Fits on a single line: the large style is kept. + AdaptativeTitleItem("Adaptative, 1 line:") { + AdaptativeTitle( + title = "Chats", + style = ElementTheme.typography.fontHeadingLgBold, + twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium, + ) + } + // Would be rendered on two lines with the large style: the smaller style is used instead. + AdaptativeTitleItem("Adaptative, 2 lines:") { + AdaptativeTitle( + title = "Tutte le conversazioni:", + style = ElementTheme.typography.fontHeadingLgBold, + twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium, + ) + } + // Would be rendered on two lines with the large style: the smaller style is used instead + // and it's finally rendered on a single line with the smaller style. + AdaptativeTitleItem("Adaptative, but 1 line:") { + AdaptativeTitle( + title = "Tous les chats", + style = ElementTheme.typography.fontHeadingLgBold, + twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium, + ) + } + // Short title, with small font, 1 line. + AdaptativeTitleItem("Always small, 1 line:") { + AdaptativeTitle( + title = "My space", + style = ElementTheme.typography.fontHeadingSmMedium, + twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium, + ) + } + // Space name, which can still be rendered on two lines. + AdaptativeTitleItem("Always small, 2 lines:") { + AdaptativeTitle( + title = "A very long space name", + style = ElementTheme.typography.fontHeadingSmMedium, + twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium, + ) + } + } +} diff --git a/libraries/encrypted-db/build.gradle.kts b/libraries/encrypted-db/build.gradle.kts index a2150945a5..d48d4aefd1 100644 --- a/libraries/encrypted-db/build.gradle.kts +++ b/libraries/encrypted-db/build.gradle.kts @@ -5,6 +5,8 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. */ +import extension.testCommonDependencies + plugins { id("io.element.android-library") } @@ -27,4 +29,6 @@ dependencies { implementation(libs.google.tink) implementation(projects.libraries.androidutils) + + testCommonDependencies(libs) } diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt index 84ab2d2a18..0f2e954b1f 100644 --- a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt @@ -51,12 +51,28 @@ class SqlCipherDriverFactory( is ClientSecret.Passphrase -> secret.formattedAsString().hexToByteArray() } val factory = SupportOpenHelperFactory(key) - return AndroidSqliteDriver(schema = schema, context = context, name = name, factory = factory, callback = object : AndroidSqliteDriver.Callback( - schema - ) { - override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { - onUpgradeCallback?.invoke(db, oldVersion, newVersion) - } - }) + return AndroidSqliteDriver( + schema = schema, + context = context, + name = name, + factory = factory, + callback = MigrationCallback(schema, onUpgradeCallback), + ) + } +} + +/** + * Runs [onUpgradeCallback] and then the schema migrations themselves. + * + * @param schema The SQLite DB schema, whose migrations are applied by [AndroidSqliteDriver.Callback.onUpgrade]. + * @param onUpgradeCallback Called before the migrations run, so that the database key can be replaced first. + */ +internal class MigrationCallback( + schema: SqlSchema>, + private val onUpgradeCallback: ((driver: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) -> Unit)?, +) : AndroidSqliteDriver.Callback(schema) { + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { + onUpgradeCallback?.invoke(db, oldVersion, newVersion) + super.onUpgrade(db, oldVersion, newVersion) } } diff --git a/libraries/encrypted-db/src/test/kotlin/io/element/encrypteddb/MigrationCallbackTest.kt b/libraries/encrypted-db/src/test/kotlin/io/element/encrypteddb/MigrationCallbackTest.kt new file mode 100644 index 0000000000..5e16184951 --- /dev/null +++ b/libraries/encrypted-db/src/test/kotlin/io/element/encrypteddb/MigrationCallbackTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.encrypteddb + +import app.cash.sqldelight.db.AfterVersion +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlSchema +import com.google.common.truth.Truth.assertThat +import io.mockk.mockk +import org.junit.Test + +class MigrationCallbackTest { + @Test + fun `onUpgrade migrates the schema`() { + val schema = RecordingSchema() + MigrationCallback(schema, onUpgradeCallback = null).onUpgrade(mockk(relaxed = true), 9, 11) + assertThat(schema.migrations).containsExactly(9L to 11L) + } + + @Test + fun `onUpgrade replaces the database key before migrating the schema`() { + val order = mutableListOf() + val schema = RecordingSchema(onMigrate = { order.add("migrate") }) + MigrationCallback(schema, onUpgradeCallback = { _, _, _ -> order.add("rekey") }) + .onUpgrade(mockk(relaxed = true), 9, 11) + assertThat(order).containsExactly("rekey", "migrate").inOrder() + } +} + +private class RecordingSchema( + private val onMigrate: () -> Unit = {}, +) : SqlSchema> { + val migrations = mutableListOf>() + + override val version = 11L + + override fun create(driver: SqlDriver) = QueryResult.Unit + + override fun migrate( + driver: SqlDriver, + oldVersion: Long, + newVersion: Long, + vararg callbacks: AfterVersion, + ): QueryResult.Value { + migrations.add(oldVersion to newVersion) + onMigrate() + return QueryResult.Unit + } +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt index 1311c524e4..8025591070 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt @@ -56,7 +56,7 @@ class DefaultPinnedMessagesBannerFormatter( is PollContent -> { content.question.prefixWith(CommonStrings.a11y_poll) } - RedactedContent -> { + is RedactedContent -> { sp.getString(CommonStrings.common_message_removed) } else -> { diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt index e7bbdb1e7f..bd519b60c2 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt @@ -89,7 +89,7 @@ class DefaultRoomLatestEventFormatter( ): CharSequence? { return when (content) { is MessageContent -> content.process(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) - RedactedContent -> { + is RedactedContent -> { val message = sp.getString(CommonStrings.common_message_removed) message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) } diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt index ff5cce7a59..ffb98fe2ec 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt @@ -62,7 +62,7 @@ class DefaultTimelineEventFormatter( is CallNotifyContent -> { sp.getString(CommonStrings.common_call_started) } - RedactedContent, + is RedactedContent, is LegacyCallInviteContent, is StickerContent, is PollContent, diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt index 198da2bf7d..38517dab33 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt @@ -81,7 +81,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() { fun `Redacted content`() { val expected = "Message removed" val senderName = "Someone" - val message = createRoomEvent(false, senderName, RedactedContent) + val message = createRoomEvent(false, senderName, RedactedContent(threadInfo = null)) val result = formatter.format(message) assertThat(result).isEqualTo(expected) } diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt index ecd0509269..c293b67975 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt @@ -85,7 +85,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() { val expected = "Message removed" val senderName = "Someone" sequenceOf(false, true).forEach { isDm -> - val message = createLatestEvent(false, senderName, RedactedContent) + val message = createLatestEvent(false, senderName, RedactedContent(threadInfo = null)) val result = formatter.format(message, isDm) if (isDm) { assertThat(result).isEqualTo(expected) diff --git a/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakeRoomLatestEventFormatter.kt b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakeRoomLatestEventFormatter.kt index 5e1a056a6c..de1b40e14c 100644 --- a/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakeRoomLatestEventFormatter.kt +++ b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakeRoomLatestEventFormatter.kt @@ -14,11 +14,16 @@ import io.element.android.libraries.matrix.api.roomlist.LatestEventValue class FakeRoomLatestEventFormatter : RoomLatestEventFormatter { private var result: CharSequence? = null + var lastIsDmRoom: Boolean? = null + private set + override fun format(latestEvent: LatestEventValue.Local, isDmRoom: Boolean): CharSequence? { + lastIsDmRoom = isDmRoom return result } override fun format(latestEvent: LatestEventValue.Remote, isDmRoom: Boolean): CharSequence? { + lastIsDmRoom = isDmRoom return result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt index 7bfc5dc6bb..2994683a61 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt @@ -35,6 +35,7 @@ sealed interface StateEventType { data object RoomImagePack : StateEventType data object RoomLanguage : StateEventType data object RoomPolicy : StateEventType + data object RoomRetention : StateEventType data class Custom(val type: String) : StateEventType } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index c2d388ecb0..132b3b3504 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -32,7 +32,9 @@ data class MessageContent( val type: MessageType, ) : EventContent -data object RedactedContent : EventContent +data class RedactedContent( + val threadInfo: EventThreadInfo?, +) : EventContent data class StickerContent( val filename: String, 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 91703f7fa4..8a3221677d 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 @@ -51,6 +51,7 @@ data class EventTimelineItem( is PollContent -> content.threadInfo is StickerContent -> content.threadInfo is UnableToDecryptContent -> content.threadInfo + is RedactedContent -> content.threadInfo else -> null } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index 0d593626bb..b1d93fd903 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -99,7 +99,6 @@ class RustMatrixClientFactory( isMessageSearchAvailable = isMessageSearchAvailable, ) .homeserverUrl(sessionData.homeserverUrl) - .username(sessionData.userId) .let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner } .use { it.build() } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt index 13a040525e..e64eb87570 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt @@ -39,6 +39,7 @@ fun StateEventType.map(): RustStateEventType = when (this) { StateEventType.RoomImagePack -> RustStateEventType.RoomImagePack StateEventType.RoomLanguage -> RustStateEventType.RoomLanguage StateEventType.RoomPolicy -> RustStateEventType.RoomPolicy + StateEventType.RoomRetention -> RustStateEventType.RoomRetention is StateEventType.Custom -> stateEventTypeFromString(type) } @@ -69,5 +70,6 @@ fun RustStateEventType.map(): StateEventType = when (this) { RustStateEventType.RoomImagePack -> StateEventType.RoomImagePack RustStateEventType.RoomLanguage -> StateEventType.RoomLanguage RustStateEventType.RoomPolicy -> StateEventType.RoomPolicy + RustStateEventType.RoomRetention -> StateEventType.RoomRetention is RustStateEventType.Custom -> StateEventType.Custom(this.toString()) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index 98efa505d0..c8434e207a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -81,7 +81,7 @@ class TimelineEventContentMapper( ) } is MsgLikeKind.Redacted -> { - RedactedContent + RedactedContent(threadInfo = extractThreadInfo(it.content)) } is MsgLikeKind.Poll -> { PollContent( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt index 623fe9a8cf..ba010ed728 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt @@ -42,7 +42,7 @@ class FakeFfiClientBuilder( override fun setSessionDelegate(sessionDelegate: ClientSessionDelegate) = this override fun slidingSyncVersionBuilder(versionBuilder: SlidingSyncVersionBuilder) = this override fun userAgent(userAgent: String) = this - override fun username(username: String) = this + override fun serverNameFromUserId(userId: String): ClientBuilder = this override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this override fun sqliteStore(config: SqliteStoreBuilder): ClientBuilder = this diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt index 803c9d8f42..6e57100781 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt @@ -30,6 +30,7 @@ fun aNotificationData( senderIsNameAmbiguous: Boolean = false, roomDisplayName: String? = A_ROOM_NAME, roomJoinRule: JoinRule? = null, + isNoisy: Boolean = false, ): NotificationData { return NotificationData( sessionId = A_SESSION_ID, @@ -45,7 +46,7 @@ fun aNotificationData( isDm = false, isSpace = isSpace, isEncrypted = false, - isNoisy = false, + isNoisy = isNoisy, timestamp = timestamp, content = content, hasMention = hasMention, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt index b61009a5c6..da8b872ef0 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails import io.element.android.libraries.matrix.api.timeline.item.event.Receipt +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType @@ -137,6 +138,10 @@ fun aStickerContent( threadInfo = threadInfo, ) +fun aRedactedContent( + threadInfo: EventThreadInfo? = null, +) = RedactedContent(threadInfo = threadInfo) + fun aTimelineItemDebugInfo( model: String = "Rust(Model())", originalJson: String? = null, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderRootView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderRootView.kt index 1e8fb864a7..2b5e8c6fce 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderRootView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderRootView.kt @@ -46,7 +46,7 @@ fun SpaceHeaderRootView( ) Text( text = stringResource(CommonStrings.screen_space_list_title), - style = ElementTheme.typography.fontHeadingLgBold, + style = ElementTheme.typography.fontHeadingMdBold, color = ElementTheme.colors.textPrimary, textAlign = TextAlign.Center, ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt index 7aee62902f..8a833cee44 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom @@ -68,11 +69,15 @@ fun SpaceHeaderView( }, title = { if (name != null) { - RoomPreviewTitleAtom(title = name) + RoomPreviewTitleAtom( + title = name, + style = ElementTheme.typography.fontHeadingMdBold, + ) } else { RoomPreviewTitleAtom( title = stringResource(id = CommonStrings.common_no_space_name), - fontStyle = FontStyle.Italic + fontStyle = FontStyle.Italic, + style = ElementTheme.typography.fontHeadingMdBold, ) } }, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsPreviewParam.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsPreviewParam.kt index 1b48e0eb9b..ce9fd884eb 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsPreviewParam.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsPreviewParam.kt @@ -118,7 +118,7 @@ class InReplyToDetailsDisambiguatedPreviewParam : InReplyToDetailsPreviewParam() class InReplyToDetailsInformativePreviewParam : InReplyToDetailsPreviewParam() { override val values: Sequence get() = sequenceOf( - RedactedContent, + RedactedContent(threadInfo = null), UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null), ).map { aInReplyToDetails( diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt index 3fcb2f8f18..64e647ab19 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt @@ -656,7 +656,7 @@ class InReplyToMetadataKtTest : RobolectricTest() { fun `redacted content`() = runTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( - eventContent = RedactedContent + eventContent = RedactedContent(threadInfo = null) ).metadata(hideImage = false) }.test { awaitItem().let { diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/FileViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/FileViewerEntryPoint.kt new file mode 100644 index 0000000000..8f90a5802b --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/FileViewerEntryPoint.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api + +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.architecture.NodeInputs + +/** + * Renders content which is not a Matrix media as a file, the same way the media viewer does. + * + * The content is provided by the caller and written to a temporary file, so that it can be + * displayed, shared and saved on disk. + */ +interface FileViewerEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node + + /** + * @param filename the name of the file, used as the screen title and for the shared and saved files. + * @param mimeType the mime type of [content], it drives how the content is rendered. + * @param content the whole content of the file. + */ + data class Params( + val filename: String, + val mimeType: String, + val content: String, + ) : NodeInputs + + interface Callback : Plugin { + fun onDone() + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt index 40a9a0351d..f140fc6628 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt @@ -79,7 +79,7 @@ class EventItemFactory( LegacyCallInviteContent, is PollContent, is ProfileChangeContent, - RedactedContent, + is RedactedContent, is RoomMembershipContent, is StateContent, is StickerContent, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPoint.kt new file mode 100644 index 0000000000..2692e8461d --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPoint.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint + +@ContributesBinding(AppScope::class) +class DefaultFileViewerEntryPoint : FileViewerEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: FileViewerEntryPoint.Params, + callback: FileViewerEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(params, callback), + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerEvent.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerEvent.kt new file mode 100644 index 0000000000..f36e1b87cf --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerEvent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +sealed interface FileViewerEvent { + data object Share : FileViewerEvent + data object SaveOnDisk : FileViewerEvent +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerNode.kt new file mode 100644 index 0000000000..ee1b35c990 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerNode.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint + +@ContributesNode(AppScope::class) +@AssistedInject +class FileViewerNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: FileViewerPresenter.Factory, + private val textFileViewer: TextFileViewer, +) : Node(buildContext, plugins = plugins) { + private val callback: FileViewerEntryPoint.Callback = callback() + private val inputs = inputs() + + private val presenter = presenterFactory.create(params = inputs) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + FileViewerView( + state = state, + textFileViewer = textFileViewer, + onBackClick = callback::onDone, + modifier = modifier, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenter.kt new file mode 100644 index 0000000000..61be47ebb2 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenter.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import android.content.ActivityNotFoundException +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.core.net.toUri +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import io.element.android.libraries.androidutils.R as UtilsR + +@AssistedInject +class FileViewerPresenter( + @Assisted private val params: FileViewerEntryPoint.Params, + @ApplicationContext private val context: Context, + private val coroutineDispatchers: CoroutineDispatchers, + private val fileSizeFormatter: FileSizeFormatter, + private val fileExtensionExtractor: FileExtensionExtractor, + private val localMediaActions: LocalMediaActions, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(params: FileViewerEntryPoint.Params): FileViewerPresenter + } + + // Use a local snackbarDispatcher, because this screen can be rendered from any Node. + private val snackbarDispatcher = SnackbarDispatcher() + + @Composable + override fun present(): FileViewerState { + val coroutineScope = rememberCoroutineScope() + val localMedia = remember { mutableStateOf>(AsyncData.Uninitialized) } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + + localMediaActions.Configure() + + LaunchedEffect(Unit) { + suspend { createLocalMedia() }.runCatchingUpdatingState(localMedia) + } + + fun handleEvent(event: FileViewerEvent) { + when (event) { + FileViewerEvent.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) + FileViewerEvent.Share -> coroutineScope.share(localMedia.value) + } + } + + return FileViewerState( + filename = params.filename, + localMedia = localMedia.value, + snackbarMessage = snackbarMessage, + eventSink = ::handleEvent, + ) + } + + /** + * Write the content to a temporary file, so that it can be rendered, shared and saved on disk. + * The file is always written to the same location, so it does not need to be cleaned up. + */ + private suspend fun createLocalMedia(): LocalMedia = withContext(coroutineDispatchers.io) { + val folder = File(context.cacheDir, "temp/fileviewer").apply { mkdirs() } + val file = File(folder, params.filename) + file.writeText(params.content) + LocalMedia( + uri = file.toUri(), + info = MediaInfo( + filename = params.filename, + caption = null, + mimeType = params.mimeType, + fileSize = file.length(), + formattedFileSize = fileSizeFormatter.format(file.length()), + fileExtension = fileExtensionExtractor.extractFromName(params.filename), + senderId = null, + senderName = null, + senderAvatar = null, + dateSent = null, + dateSentFull = null, + waveform = null, + duration = null, + ), + ) + } + + private fun CoroutineScope.saveOnDisk(localMedia: AsyncData) = launch { + if (localMedia is AsyncData.Success) { + localMediaActions.saveOnDisk(localMedia.data) + .onSuccess { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_file_saved_on_disk_android)) + } + .onFailure { + snackbarDispatcher.post(SnackbarMessage(mediaActionsError(it))) + } + } + } + + private fun CoroutineScope.share(localMedia: AsyncData) = launch { + if (localMedia is AsyncData.Success) { + localMediaActions.share(localMedia.data) + .onFailure { + snackbarDispatcher.post(SnackbarMessage(mediaActionsError(it))) + } + } + } + + private fun mediaActionsError(throwable: Throwable): Int { + return if (throwable is ActivityNotFoundException) { + UtilsR.string.error_no_compatible_app_found + } else { + CommonStrings.error_unknown + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerState.kt new file mode 100644 index 0000000000..0edc198205 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +data class FileViewerState( + val filename: String, + val localMedia: AsyncData, + val snackbarMessage: SnackbarMessage?, + val eventSink: (FileViewerEvent) -> Unit, +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerStatePreviewParam.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerStatePreviewParam.kt new file mode 100644 index 0000000000..3f48dbdc31 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerStatePreviewParam.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import android.net.Uri +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +private const val A_FILENAME = "push_rules@alice_server.org.json" + +open class FileViewerStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aFileViewerState(), + aFileViewerState(localMedia = AsyncData.Failure(Exception("A failure"))), + aFileViewerState( + localMedia = AsyncData.Success( + LocalMedia( + uri = Uri.EMPTY, + info = aJsonMediaInfo(), + ) + ) + ), + ) +} + +fun aFileViewerState( + filename: String = A_FILENAME, + localMedia: AsyncData = AsyncData.Uninitialized, + eventSink: (FileViewerEvent) -> Unit = {}, +) = FileViewerState( + filename = filename, + localMedia = localMedia, + snackbarMessage = null, + eventSink = eventSink, +) + +fun aJsonMediaInfo( + filename: String = A_FILENAME, +) = MediaInfo( + filename = filename, + caption = null, + mimeType = MimeTypes.Json, + fileSize = 2 * 1024, + formattedFileSize = "2kB", + fileExtension = "json", + senderId = null, + senderName = null, + senderAvatar = null, + dateSent = null, + dateSentFull = null, + waveform = null, + duration = null, +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt new file mode 100644 index 0000000000..613e3af269 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.async.AsyncFailure +import io.element.android.libraries.designsystem.components.async.AsyncLoading +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.TopAppBar +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView +import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun FileViewerView( + state: FileViewerState, + textFileViewer: TextFileViewer, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + Scaffold( + modifier = modifier, + topBar = { + FileViewerTopBar( + filename = state.filename, + actionsEnabled = state.localMedia.isSuccess(), + onBackClick = onBackClick, + onShareClick = { state.eventSink(FileViewerEvent.Share) }, + onSaveClick = { state.eventSink(FileViewerEvent.SaveOnDisk) }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + when (val localMedia = state.localMedia) { + AsyncData.Uninitialized, + is AsyncData.Loading -> AsyncLoading( + modifier = Modifier.padding(padding), + ) + is AsyncData.Failure -> AsyncFailure( + modifier = Modifier.padding(padding), + throwable = localMedia.error, + onRetry = null, + ) + is AsyncData.Success -> { + // Do not apply the padding, the content is rendered behind the translucent top bar, + // the same way the media viewer does. + LocalMediaView( + localMedia = localMedia.data, + bottomPaddingInPixels = 0, + audioFocus = null, + onClick = {}, + onOpenWith = null, + textFileViewer = textFileViewer, + forPreview = false, + modifier = Modifier.fillMaxSize(), + ) + } + } + } +} + +@Composable +private fun FileViewerTopBar( + filename: String, + actionsEnabled: Boolean, + onBackClick: () -> Unit, + onShareClick: () -> Unit, + onSaveClick: () -> Unit, +) { + TopAppBar( + titleStr = filename, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = bgCanvasWithTransparency, + ), + navigationIcon = { BackButton(onClick = onBackClick) }, + actions = { + IconButton( + onClick = onShareClick, + enabled = actionsEnabled, + ) { + Icon( + imageVector = CompoundIcons.ShareAndroid(), + contentDescription = stringResource(id = CommonStrings.action_share), + ) + } + IconButton( + onClick = onSaveClick, + enabled = actionsEnabled, + ) { + Icon( + imageVector = CompoundIcons.Download(), + contentDescription = stringResource(id = CommonStrings.action_download), + ) + } + }, + ) +} + +@PreviewsDayNight +@Composable +internal fun FileViewerViewPreview( + @PreviewParameter(FileViewerStatePreviewParam::class) state: FileViewerState, +) = ElementPreview { + FileViewerView( + state = state, + textFileViewer = { _, _ -> }, + onBackClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt index 071e0dba64..4364020052 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt @@ -32,6 +32,7 @@ import androidx.core.content.PermissionChecker import androidx.core.net.toFile import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.file.saveWithUniqueFileName import io.element.android.libraries.androidutils.system.startInstallFromSourceIntent import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions @@ -44,6 +45,7 @@ import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File import java.io.FileOutputStream +import java.io.IOException import java.io.InputStream @ContributesBinding(AppScope::class) @@ -172,18 +174,18 @@ class AndroidLocalMediaActions( @RequiresApi(Build.VERSION_CODES.Q) private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) { - val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.info.filename) - put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType) - put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) - } val resolver = context.contentResolver - val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) - if (outputUri != null) { - localMedia.openStream()?.use { input -> - resolver.openOutputStream(outputUri).use { output -> - input.copyTo(output!!, DEFAULT_BUFFER_SIZE) - } + val outputUri = saveWithUniqueFileName(localMedia.info.filename) { displayName -> + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + } ?: throw IOException("Unable to create the destination file") + localMedia.openStream()?.use { input -> + resolver.openOutputStream(outputUri).use { output -> + input.copyTo(output!!, DEFAULT_BUFFER_SIZE) } } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt index 01b44f1246..e370361696 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt @@ -59,7 +59,7 @@ fun LocalMediaView( forPreview = forPreview, modifier = modifier, ) - mimeType == MimeTypes.PlainText -> TextFileView( + mimeType == MimeTypes.PlainText || mimeType == MimeTypes.Json -> TextFileView( localMedia = localMedia, textFileViewer = textFileViewer, modifier = modifier, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt index 4ed89e8923..4f02637a62 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactoryTest.kt @@ -35,7 +35,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import io.element.android.libraries.matrix.api.timeline.item.event.OtherState -import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent import io.element.android.libraries.matrix.api.timeline.item.event.StateContent import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType @@ -49,6 +48,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.aPollContent import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent +import io.element.android.libraries.matrix.test.timeline.aRedactedContent import io.element.android.libraries.matrix.test.timeline.aStickerContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent @@ -80,7 +80,7 @@ class EventItemFactoryTest { LegacyCallInviteContent, aPollContent(), aProfileChangeMessageContent(), - RedactedContent, + aRedactedContent(), aRoomMembershipContent( userId = A_USER_ID, ), diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPointTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPointTest.kt new file mode 100644 index 0000000000..2a84ad00b8 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPointTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import io.element.android.tests.testutils.testCoroutineDispatchers +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultFileViewerEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultFileViewerEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + FileViewerNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { nodeParams -> + FileViewerPresenter( + params = nodeParams, + context = mockk(relaxed = true), + coroutineDispatchers = testCoroutineDispatchers(), + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + localMediaActions = FakeLocalMediaActions(), + ) + }, + textFileViewer = { _, _ -> lambdaError() }, + ) + } + val params = FileViewerEntryPoint.Params( + filename = "a file.json", + mimeType = MimeTypes.Json, + content = "{}", + ) + val callback = object : FileViewerEntryPoint.Callback { + override fun onDone() = lambdaError() + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(FileViewerNode::class.java) + assertThat(result.plugins).contains(params) + assertThat(result.plugins).contains(callback) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenterTest.kt new file mode 100644 index 0000000000..7a8a85d69e --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenterTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import android.content.ActivityNotFoundException +import androidx.core.net.toFile +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import io.element.android.libraries.androidutils.R as UtilsR + +private const val A_FILENAME = "push_rules@alice_server.org.json" +private const val A_CONTENT = """{"global":{"override":[]}}""" + +class FileViewerPresenterTest : RobolectricTest() { + @Test + fun `present - writes the content to a file and exposes it as a local media`() = runTest { + val presenter = createFileViewerPresenter() + presenter.test { + assertThat(awaitItem().localMedia).isEqualTo(AsyncData.Uninitialized) + consumeItemsUntilPredicate { it.localMedia.isSuccess() }.last().also { state -> + assertThat(state.filename).isEqualTo(A_FILENAME) + val localMedia = state.localMedia.dataOrNull()!! + assertThat(localMedia.info.filename).isEqualTo(A_FILENAME) + assertThat(localMedia.info.mimeType).isEqualTo(MimeTypes.Json) + assertThat(localMedia.info.fileExtension).isEqualTo("json") + val file = localMedia.uri.toFile() + assertThat(file.name).isEqualTo(A_FILENAME) + assertThat(file.readText()).isEqualTo(A_CONTENT) + } + } + } + + @Test + fun `present - SaveOnDisk event saves the file and posts a snackbar message`() = runTest { + val saveOnDiskResult = lambdaRecorder> { Result.success(Unit) } + val presenter = createFileViewerPresenter( + localMediaActions = FakeLocalMediaActions(saveOnDiskResult = saveOnDiskResult), + ) + presenter.test { + consumeItemsUntilPredicate { it.localMedia.isSuccess() }.last().eventSink(FileViewerEvent.SaveOnDisk) + consumeItemsUntilPredicate { it.snackbarMessage != null }.last().also { state -> + assertThat(state.snackbarMessage?.messageResId).isEqualTo(CommonStrings.common_file_saved_on_disk_android) + } + saveOnDiskResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - Share event failure posts a snackbar message`() = runTest { + val shareResult = lambdaRecorder> { Result.failure(ActivityNotFoundException()) } + val presenter = createFileViewerPresenter( + localMediaActions = FakeLocalMediaActions(shareResult = shareResult), + ) + presenter.test { + consumeItemsUntilPredicate { it.localMedia.isSuccess() }.last().eventSink(FileViewerEvent.Share) + consumeItemsUntilPredicate { it.snackbarMessage != null }.last().also { state -> + assertThat(state.snackbarMessage?.messageResId).isEqualTo(UtilsR.string.error_no_compatible_app_found) + } + shareResult.assertions().isCalledOnce() + } + } + + private fun TestScope.createFileViewerPresenter( + params: FileViewerEntryPoint.Params = FileViewerEntryPoint.Params( + filename = A_FILENAME, + mimeType = MimeTypes.Json, + content = A_CONTENT, + ), + localMediaActions: LocalMediaActions = FakeLocalMediaActions(), + ) = FileViewerPresenter( + params = params, + context = RuntimeEnvironment.getApplication(), + coroutineDispatchers = testCoroutineDispatchers(), + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + localMediaActions = localMediaActions, + ) +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerViewTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerViewTest.kt new file mode 100644 index 0000000000..96f5de7007 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerViewTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.annotation.StringRes +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class FileViewerViewTest : RobolectricTest() { + @Test + fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setFileViewerView( + state = aFileViewerState(eventSink = eventsRecorder), + onBackClick = it, + ) + pressBack() + } + } + + @Test + fun `clicking on share emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setFileViewerView( + state = aFileViewerState( + localMedia = AsyncData.Success(LocalMedia(Uri.EMPTY, aJsonMediaInfo())), + eventSink = eventsRecorder, + ), + ) + clickOnContentDescription(CommonStrings.action_share) + eventsRecorder.assertSingle(FileViewerEvent.Share) + } + + @Test + fun `clicking on download emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setFileViewerView( + state = aFileViewerState( + localMedia = AsyncData.Success(LocalMedia(Uri.EMPTY, aJsonMediaInfo())), + eventSink = eventsRecorder, + ), + ) + clickOnContentDescription(CommonStrings.action_download) + eventsRecorder.assertSingle(FileViewerEvent.SaveOnDisk) + } +} + +private fun AndroidComposeUiTest.clickOnContentDescription(@StringRes res: Int) { + val text = activity!!.getString(res) + onNode(hasContentDescription(text) and hasClickAction()).performClick() +} + +private fun AndroidComposeUiTest.setFileViewerView( + state: FileViewerState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + FileViewerView( + state = state, + textFileViewer = { _, _ -> }, + onBackClick = onBackClick, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActionsTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActionsTest.kt index cdc5f7220d..ab1b193e26 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActionsTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActionsTest.kt @@ -12,18 +12,22 @@ import android.net.Uri import androidx.activity.compose.LocalActivityResultRegistryOwner import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia +import io.element.android.tests.testutils.fake.registerFakeMediaStoreContentProvider import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test import org.robolectric.RuntimeEnvironment +import java.io.File +import java.io.IOException class AndroidLocalMediaActionsTest : RobolectricTest() { @Test @@ -62,6 +66,43 @@ class AndroidLocalMediaActionsTest : RobolectricTest() { assertThat(result.exceptionOrNull()).isNotNull() } + @Test + fun `test AndroidLocalMediaAction save on disk inserts the media under its own name`() = runTest { + val mediaStore = registerFakeMediaStoreContentProvider() + val sut = createAndroidLocalMediaActions() + val result = sut.saveOnDisk(aLocalMedia(aMediaFile().toUri())) + assertThat(result.exceptionOrNull()).isNull() + assertThat(mediaStore.insertedDisplayNames).containsExactly("an image file.jpg") + } + + @Test + fun `test AndroidLocalMediaAction save on disk retries with a suffixed name when the name cannot be made unique`() = runTest { + val mediaStore = registerFakeMediaStoreContentProvider().apply { + conflictingDisplayNames = setOf("an image file.jpg") + } + val sut = createAndroidLocalMediaActions() + val result = sut.saveOnDisk(aLocalMedia(aMediaFile().toUri())) + assertThat(result.exceptionOrNull()).isNull() + assertThat(mediaStore.insertedDisplayNames).hasSize(2) + assertThat(mediaStore.insertedDisplayNames.last()).matches("an image file_\\d+\\.jpg") + } + + @Test + fun `test AndroidLocalMediaAction save on disk fails when the destination cannot be created`() = runTest { + registerFakeMediaStoreContentProvider().apply { + returnsNoUriOnInsert = true + } + val sut = createAndroidLocalMediaActions() + val result = sut.saveOnDisk(aLocalMedia(aMediaFile().toUri())) + assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java) + } + + private fun aMediaFile(): File { + return File(RuntimeEnvironment.getApplication().cacheDir, "an image file.jpg").apply { + writeText("Some content") + } + } + private fun TestScope.createAndroidLocalMediaActions() = AndroidLocalMediaActions( RuntimeEnvironment.getApplication(), testCoroutineDispatchers(), diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeFileViewerEntryPoint.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeFileViewerEntryPoint.kt new file mode 100644 index 0000000000..0379bff47d --- /dev/null +++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeFileViewerEntryPoint.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeFileViewerEntryPoint : FileViewerEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: FileViewerEntryPoint.Params, + callback: FileViewerEntryPoint.Callback, + ): Node = lambdaError() +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index fdffad3049..e22ae1f7d1 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -298,6 +298,7 @@ class DefaultNotifiableEventResolver( roomId = roomId, eventId = eventId, cause = "Unable to decrypt event content", + noisy = isNoisy, ) ResolvedPushEvent.Event(fallbackNotifiableEvent) } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index b6d15dac4e..6834f404ed 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -274,6 +274,12 @@ private fun NotifiableEvent.toFallbackNotifiableEvent(): FallbackNotifiableEvent is FallbackNotifiableEvent -> timestamp is NotifiableRingingCallEvent -> return null } + val noisy = when (this) { + is NotifiableMessageEvent -> noisy + is InviteNotifiableEvent -> noisy + is SimpleNotifiableEvent -> noisy + is FallbackNotifiableEvent -> noisy + } return FallbackNotifiableEvent( sessionId = sessionId, roomId = roomId, @@ -283,6 +289,7 @@ private fun NotifiableEvent.toFallbackNotifiableEvent(): FallbackNotifiableEvent canBeReplaced = false, isRedacted = false, isUpdated = false, + noisy = noisy, timestamp = timestamp, cause = null, ) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactory.kt index 970f19fd45..d2083f7333 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactory.kt @@ -24,6 +24,7 @@ class FallbackNotificationFactory( roomId: RoomId, eventId: EventId, cause: String?, + noisy: Boolean, ): FallbackNotifiableEvent = FallbackNotifiableEvent( sessionId = sessionId, roomId = roomId, @@ -32,6 +33,7 @@ class FallbackNotificationFactory( canBeReplaced = true, isRedacted = false, isUpdated = false, + noisy = noisy, timestamp = clock.epochMillis(), description = "", cause = cause, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt index a97937f5d0..88ba6c5ff9 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt @@ -170,6 +170,7 @@ class DefaultNotificationResultProcessor( roomId = RoomId(request.roomId), eventId = EventId(request.eventId), cause = exception.message, + noisy = false, ) ) } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt index afbbe4af4f..26412abebc 100755 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt @@ -317,7 +317,7 @@ class DefaultNotificationCreator( val fallbackNotifiableEvent = fallbackNotifiableEvents.first() val channelId = notificationChannels.getChannelIdForMessage( sessionId = fallbackNotifiableEvent.sessionId, - noisy = false, + noisy = fallbackNotifiableEvents.any { it.noisy }, ) val existingCounter = existingNotification ?.extras diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/FallbackNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/FallbackNotifiableEvent.kt index 97f66face0..3b3210a580 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/FallbackNotifiableEvent.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/FallbackNotifiableEvent.kt @@ -25,6 +25,7 @@ data class FallbackNotifiableEvent( override val canBeReplaced: Boolean, override val isRedacted: Boolean, override val isUpdated: Boolean, + val noisy: Boolean, val timestamp: Long, val cause: String?, ) : NotifiableEvent diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index 24e88e72d2..5918329c40 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -712,6 +712,41 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() { canBeReplaced = true, isRedacted = false, isUpdated = false, + noisy = false, + timestamp = A_FAKE_TIMESTAMP, + cause = "Unable to decrypt event content", + ) + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve RoomEncrypted noisy`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success( + aNotificationData( + content = NotificationContent.MessageLike.RoomEncrypted, + isNoisy = true, + ) + ) + ) + ) + ) + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + FallbackNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + description = "", + canBeReplaced = true, + isRedacted = false, + isUpdated = false, + noisy = true, timestamp = A_FAKE_TIMESTAMP, cause = "Unable to decrypt event content", ) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index 4e2685e63a..cdc6a01fad 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -15,28 +15,35 @@ import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.test.FakeLockScreenService +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_THREAD_ID_2 +import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationIdProvider +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.observer.SessionObserver @@ -48,6 +55,8 @@ import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.aNavigationState import io.element.android.services.appnavstate.test.anAppNavigationState +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -472,6 +481,137 @@ class DefaultNotificationDrawerManagerTest { extraInvocationsForNotificationSummary = 1, ) + @Test + fun `when the app is locked, a noisy message event produces a noisy fallback notification`() = testAppLockedNotifiableEventReceived( + notifiableEvent = aNotifiableMessageEvent(noisy = true), + expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = true, timestamp = A_TIMESTAMP), + ) + + @Test + fun `when the app is locked, a silent message event produces a silent fallback notification`() = testAppLockedNotifiableEventReceived( + notifiableEvent = aNotifiableMessageEvent(noisy = false), + expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = false, timestamp = A_TIMESTAMP), + ) + + @Test + fun `when the app is locked, a noisy invite event produces a noisy fallback notification`() = testAppLockedNotifiableEventReceived( + notifiableEvent = anInviteNotifiableEvent(noisy = true), + expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = true, timestamp = 0), + ) + + @Test + fun `when the app is locked, a silent invite event produces a silent fallback notification`() = testAppLockedNotifiableEventReceived( + notifiableEvent = anInviteNotifiableEvent(noisy = false), + expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = false, timestamp = 0), + ) + + @Test + fun `when the app is locked, a noisy simple event produces a noisy fallback notification`() = testAppLockedNotifiableEventReceived( + notifiableEvent = aSimpleNotifiableEvent(noisy = true), + expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = true, timestamp = 0), + ) + + @Test + fun `when the app is locked, a silent simple event produces a silent fallback notification`() = testAppLockedNotifiableEventReceived( + notifiableEvent = aSimpleNotifiableEvent(noisy = false), + expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = false, timestamp = 0), + ) + + @Test + fun `when the app is locked, a noisy fallback event stays noisy`() = testAppLockedNotifiableEventReceived( + notifiableEvent = aFallbackNotifiableEvent(noisy = true), + expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = true, timestamp = A_FAKE_TIMESTAMP), + ) + + @Test + fun `when the app is locked, a silent fallback event stays silent`() = testAppLockedNotifiableEventReceived( + notifiableEvent = aFallbackNotifiableEvent(noisy = false), + expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = false, timestamp = A_FAKE_TIMESTAMP), + ) + + @Test + fun `when the app is locked, all the events are grouped into a single fallback notification`() = runTest { + val createFallbackNotificationResult = aCreateFallbackNotificationRecorder() + val sut = createAppLockedNotificationDrawerManager(createFallbackNotificationResult) + sut.onNotifiableEventsReceived( + listOf( + aNotifiableMessageEvent(noisy = false), + aNotifiableMessageEvent(eventId = AN_EVENT_ID_2, noisy = true), + ) + ) + createFallbackNotificationResult.assertions() + .isCalledOnce() + .with( + value(null), + any(), + value( + listOf( + anExpectedFallbackNotifiableEvent(noisy = false, timestamp = A_TIMESTAMP), + anExpectedFallbackNotifiableEvent(eventId = AN_EVENT_ID_2, noisy = true, timestamp = A_TIMESTAMP), + ) + ), + ) + } + + @Test + fun `when the app is not locked, no fallback notification is created`() = runTest { + val createFallbackNotificationResult = aCreateFallbackNotificationRecorder() + val sut = createDefaultNotificationDrawerManager( + appNavigationStateService = FakeAppNavigationStateService( + initialAppNavigationState = anAppNavigationState(isInForeground = false), + ), + notificationCreator = FakeNotificationCreator( + createFallbackNotificationResult = createFallbackNotificationResult, + ), + lockScreenService = FakeLockScreenService().apply { setIsPinSetup(false) }, + ) + sut.onNotifiableEventsReceived(listOf(aNotifiableMessageEvent(noisy = true))) + createFallbackNotificationResult.assertions().isNeverCalled() + } + + /** + * The content of the original event must be stripped, only the noisy flag, the ids and the timestamp are kept. + */ + private fun anExpectedFallbackNotifiableEvent( + eventId: EventId = AN_EVENT_ID, + noisy: Boolean, + timestamp: Long, + ) = aFallbackNotifiableEvent( + eventId = eventId, + description = null, + canBeReplaced = false, + noisy = noisy, + timestamp = timestamp, + cause = null, + ) + + private fun aCreateFallbackNotificationRecorder() = + lambdaRecorder, Notification> { _, _, _ -> A_NOTIFICATION } + + private fun TestScope.createAppLockedNotificationDrawerManager( + createFallbackNotificationResult: LambdaThreeParamsRecorder, Notification>, + ) = createDefaultNotificationDrawerManager( + appNavigationStateService = FakeAppNavigationStateService( + initialAppNavigationState = anAppNavigationState(isInForeground = false), + ), + notificationCreator = FakeNotificationCreator( + createFallbackNotificationResult = createFallbackNotificationResult, + ), + lockScreenService = FakeLockScreenService().apply { setIsPinSetup(true) }, + ) + + private fun testAppLockedNotifiableEventReceived( + notifiableEvent: NotifiableEvent, + expectedFallbackEvent: FallbackNotifiableEvent, + ) = runTest { + val createFallbackNotificationResult = aCreateFallbackNotificationRecorder() + val sut = createAppLockedNotificationDrawerManager(createFallbackNotificationResult) + sut.onNotifiableEventsReceived(listOf(notifiableEvent)) + createFallbackNotificationResult.assertions() + .isCalledOnce() + .with(value(null), any(), value(listOf(expectedFallbackEvent))) + } + private fun testOnNotifiableEventReceived( appNavigationState: AppNavigationState, notifiableEvents: List, @@ -503,6 +643,7 @@ class DefaultNotificationDrawerManagerTest { fun TestScope.createDefaultNotificationDrawerManager( notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(), notificationRenderer: NotificationRenderer? = null, + notificationCreator: NotificationCreator = FakeNotificationCreator(), appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(), summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(), @@ -519,7 +660,7 @@ fun TestScope.createDefaultNotificationDrawerManager( notificationRenderer = notificationRenderer ?: NotificationRenderer( notificationDisplayer = notificationDisplayer, notificationDataFactory = DefaultNotificationDataFactory( - notificationCreator = FakeNotificationCreator(), + notificationCreator = notificationCreator, roomGroupMessageCreator = roomGroupMessageCreator, summaryGroupMessageCreator = summaryGroupMessageCreator, activeNotificationsProvider = activeNotificationsProvider, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt index 91f29dd28e..21a2c6cb91 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt @@ -267,6 +267,42 @@ class DefaultNotificationResultProcessorTest { assertThat(receivedFallbackEvent).isTrue() } + @Test + fun `when an event cannot be resolved, the generated fallback event is silent`() = runTest { + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val processor = createDefaultNotificationResultProcessor( + mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore( + showBatteryOptimizationBannerResult = lambdaRecorder {}, + ), + pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ), + onNotifiableEventsReceived = onNotifiableEventsReceived, + ) + + runningProcessor(processor) { + emit(mapOf(aPushRequest() to Result.failure(NotificationResolverException.UnknownError("Unable to resolve event")))) + } + + advanceTimeBy(300.milliseconds) + + onNotifiableEventsReceived.assertions() + .isCalledOnce() + .with( + value( + listOf( + aFallbackNotifiableEvent( + description = "", + canBeReplaced = true, + noisy = false, + cause = "Unable to resolve event", + ) + ) + ) + ) + } + private suspend fun TestScope.runningProcessor(processor: NotificationResultProcessor, block: suspend NotificationResultProcessor.() -> Unit) { processor.start() diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactoryTest.kt new file mode 100644 index 0000000000..58825f07b9 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactoryTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import org.junit.Test + +class FallbackNotificationFactoryTest { + @Test + fun `create silent event`() { + val sut = FallbackNotificationFactory(FakeSystemClock()) + val result = sut.create( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + cause = "A cause", + noisy = false, + ) + assertThat(result).isEqualTo( + aFallbackNotifiableEvent( + description = "", + canBeReplaced = true, + noisy = false, + cause = "A cause", + ) + ) + } + + @Test + fun `create noisy event`() { + val sut = FallbackNotificationFactory(FakeSystemClock()) + val result = sut.create( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + cause = "A cause", + noisy = true, + ) + assertThat(result).isEqualTo( + aFallbackNotifiableEvent( + description = "", + canBeReplaced = true, + noisy = true, + cause = "A cause", + ) + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt index 299c5ad213..ead16c02d7 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt @@ -18,6 +18,7 @@ import io.element.android.appconfig.NotificationConfig import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.A_COLOR_INT @@ -33,6 +34,7 @@ import io.element.android.libraries.push.impl.notifications.DefaultNotificationB import io.element.android.libraries.push.impl.notifications.NotificationActionIds import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo import io.element.android.libraries.push.impl.notifications.channels.DefaultNotificationChannels +import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory @@ -47,6 +49,8 @@ import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvid import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.test.runTest import org.junit.Test @@ -82,7 +86,10 @@ class DefaultNotificationCreatorTest : RobolectricTest() { @Test fun `test createFallbackNotification`() { - val sut = createNotificationCreator() + val channelIdForMessage = lambdaRecorder { _, _ -> A_CHANNEL_ID } + val sut = createNotificationCreator( + notificationChannels = FakeNotificationChannels(channelIdForMessage = channelIdForMessage), + ) val result = sut.createFallbackNotification( existingNotification = null, notificationAccountParams = aNotificationAccountParams(), @@ -93,6 +100,50 @@ class DefaultNotificationCreatorTest : RobolectricTest() { result.commonAssertions( expectedCategory = null, ) + assertThat(result.channelId).isEqualTo(A_CHANNEL_ID) + // The notification must be posted on the silent channel. + channelIdForMessage.assertions().isCalledOnce().with(value(A_SESSION_ID), value(false)) + } + + @Test + fun `test createFallbackNotification noisy`() { + val channelIdForMessage = lambdaRecorder { _, _ -> A_CHANNEL_ID } + val sut = createNotificationCreator( + notificationChannels = FakeNotificationChannels(channelIdForMessage = channelIdForMessage), + ) + val result = sut.createFallbackNotification( + existingNotification = null, + notificationAccountParams = aNotificationAccountParams(), + fallbackNotifiableEvents = listOf( + aFallbackNotifiableEvent(noisy = true), + ) + ) + result.commonAssertions( + expectedCategory = null, + ) + assertThat(result.channelId).isEqualTo(A_CHANNEL_ID) + // The notification must be posted on the noisy channel. + channelIdForMessage.assertions().isCalledOnce().with(value(A_SESSION_ID), value(true)) + } + + @Test + fun `test createFallbackNotification is noisy when at least one event is noisy`() { + val channelIdForMessage = lambdaRecorder { _, _ -> A_CHANNEL_ID } + val sut = createNotificationCreator( + notificationChannels = FakeNotificationChannels(channelIdForMessage = channelIdForMessage), + ) + val result = sut.createFallbackNotification( + existingNotification = null, + notificationAccountParams = aNotificationAccountParams(), + fallbackNotifiableEvents = listOf( + aFallbackNotifiableEvent(noisy = false), + aFallbackNotifiableEvent(eventId = AN_EVENT_ID_2, noisy = true), + ) + ) + result.commonAssertions( + expectedCategory = null, + ) + channelIdForMessage.assertions().isCalledOnce().with(value(A_SESSION_ID), value(true)) } @Test @@ -389,6 +440,8 @@ class DefaultNotificationCreatorTest : RobolectricTest() { } } +private const val A_CHANNEL_ID = "aChannelId" + const val MARK_AS_READ_ACTION_TITLE = "MarkAsReadAction" const val QUICK_REPLY_ACTION_TITLE = "QuickReplyAction" const val ACCEPT_INVITATION_ACTION_TITLE = "AcceptInvitationAction" diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt index 4242632fe5..cb4bffcfb3 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt @@ -39,13 +39,14 @@ fun aSimpleNotifiableEvent( type: String? = null, isRedacted: Boolean = false, canBeReplaced: Boolean = false, - editedEventId: EventId? = null + editedEventId: EventId? = null, + noisy: Boolean = false, ) = SimpleNotifiableEvent( sessionId = sessionId, roomId = roomId, eventId = eventId, editedEventId = editedEventId, - noisy = false, + noisy = noisy, title = "title", description = "description", type = type, @@ -59,14 +60,15 @@ fun anInviteNotifiableEvent( sessionId: SessionId = A_SESSION_ID, roomId: RoomId = A_ROOM_ID, eventId: EventId = AN_EVENT_ID, - isRedacted: Boolean = false + isRedacted: Boolean = false, + noisy: Boolean = false, ) = InviteNotifiableEvent( sessionId = sessionId, eventId = eventId, roomId = roomId, roomName = A_ROOM_NAME, editedEventId = null, - noisy = false, + noisy = noisy, title = "title", description = "description", type = null, @@ -89,11 +91,12 @@ fun aNotifiableMessageEvent( senderId: UserId = A_USER_ID_2, senderDisambiguatedDisplayName: String = A_USER_NAME_2, roomName: String? = A_ROOM_NAME, + noisy: Boolean = false, ) = NotifiableMessageEvent( sessionId = sessionId, eventId = eventId, editedEventId = null, - noisy = false, + noisy = noisy, timestamp = timestamp, senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, senderId = senderId, @@ -149,15 +152,23 @@ fun aNotifiableCallEvent( fun aFallbackNotifiableEvent( sessionId: SessionId = A_SESSION_ID, + roomId: RoomId = A_ROOM_ID, + eventId: EventId = AN_EVENT_ID, + description: String? = "A fallback notification", + canBeReplaced: Boolean = false, + noisy: Boolean = false, + timestamp: Long = A_FAKE_TIMESTAMP, + cause: String? = "Unable to decrypt event", ) = FallbackNotifiableEvent( sessionId = sessionId, - roomId = A_ROOM_ID, - eventId = AN_EVENT_ID, + roomId = roomId, + eventId = eventId, editedEventId = null, - description = "A fallback notification", - canBeReplaced = false, + description = description, + canBeReplaced = canBeReplaced, isRedacted = false, isUpdated = false, - timestamp = A_FAKE_TIMESTAMP, - cause = "Unable to decrypt event", + noisy = noisy, + timestamp = timestamp, + cause = cause, ) diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 89ad4a63fc..0c2885aaa1 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -45,7 +45,7 @@ private const val versionMonth = 8 * Release number in the month. Value must be in [0,99]. * Do not update this value. it is updated by the release script. */ -private const val versionReleaseNumber = 2 +private const val versionReleaseNumber = 4 object Versions { /** diff --git a/screenshots/html/data.js b/screenshots/html/data.js index 62cea77dcf..8cf60a31ef 100644 --- a/screenshots/html/data.js +++ b/screenshots/html/data.js @@ -1,4 +1,5 @@ // Generated file, do not edit +export const buildDate = 1787667600; export const screenshots = [ ["en","en-dark","de",], ["features.messages.impl.timeline.components.event_ATimelineItemEventRow_Day_0_en","features.messages.impl.timeline.components.event_ATimelineItemEventRow_Night_0_en",20675,], diff --git a/screenshots/html/screenshots.css b/screenshots/html/screenshots.css index f946c82e81..90842b7ac9 100644 --- a/screenshots/html/screenshots.css +++ b/screenshots/html/screenshots.css @@ -150,6 +150,11 @@ input { height: 40px; } +.build-date { + margin-right: 20px; + font-size: 13px; +} + a:link { color: white; font-size: 14px; diff --git a/screenshots/html/script.js b/screenshots/html/script.js index 4a9586d591..44b2522a5d 100644 --- a/screenshots/html/script.js +++ b/screenshots/html/script.js @@ -5,7 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. */ -import { screenshots } from './data.js'; +import { buildDate, screenshots } from './data.js'; const URL_PARAM_LANGUAGES = "l"; const URL_PARAM_IMAGE_WIDTH = "w"; @@ -360,5 +360,13 @@ function addTable() { document.getElementById('lines').textContent = `${linesCounter} lines`; } +function addBuildDate() { + const element = document.createElement('div'); + element.className = 'build-date'; + element.textContent = `Built on ${new Date(buildDate * 1000).toLocaleDateString()}`; + document.getElementById('footer').appendChild(element); +} + addForm(); +addBuildDate(); addTable(); diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt index 53cdbbf225..14a3f24f3c 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt @@ -103,6 +103,7 @@ class KonsistClassNameTest { "FakeImageLoader", "FakeKeyStore", "FakeListenableFuture", + "FakeMediaStoreContentProvider", "FakeParentNode", ) .assertTrue { diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index e066a6b7ec..ed901a99b4 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -133,6 +133,7 @@ class KonsistPreviewTest { "SecureBackupSetupViewChangePreview", "SelectedUserCannotRemovePreview", "SpaceMembersViewNoHeroesPreview", + "SyncStateViewServerUnreachablePreview", "TextComposerAddCaptionPreview", "TextComposerCaptionPreview", "TextComposerEditCaptionPreview", diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/fake/FakeMediaStoreContentProvider.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/fake/FakeMediaStoreContentProvider.kt new file mode 100644 index 0000000000..13077db51f --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/fake/FakeMediaStoreContentProvider.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.tests.testutils.fake + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.pm.ProviderInfo +import android.database.Cursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.MediaStore +import androidx.test.core.app.ApplicationProvider +import org.robolectric.shadows.ShadowContentResolver +import java.io.File + +/** + * Stands in for the MediaStore provider: it records the display names an insert is attempted with, + * writes what is copied into it to a temporary directory, and can make any display name collide the + * way MediaStore does once a directory already holds 32 files with the same name. + */ +class FakeMediaStoreContentProvider : ContentProvider() { + private val directory: File = File.createTempFile("fake-media-store", null).apply { + delete() + mkdir() + } + + val insertedDisplayNames = mutableListOf() + + var conflictingDisplayNames: Set = emptySet() + + /** When true, an insert reports that it created nothing, the way a rejected insert does. */ + var returnsNoUriOnInsert: Boolean = false + + fun savedFileNames(): List = directory.list().orEmpty().toList() + + fun savedContent(displayName: String): String = File(directory, displayName).readText() + + override fun onCreate() = true + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + val displayName = values?.getAsString(MediaStore.MediaColumns.DISPLAY_NAME).orEmpty() + insertedDisplayNames += displayName + check(displayName !in conflictingDisplayNames) { "Failed to build unique file" } + return if (returnsNoUriOnInsert) null else uri.buildUpon().appendPath(displayName).build() + } + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { + val file = File(directory, uri.lastPathSegment.orEmpty()) + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_CREATE or ParcelFileDescriptor.MODE_READ_WRITE) + } + + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null + + override fun getType(uri: Uri): String? = null + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0 + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0 +} + +fun registerFakeMediaStoreContentProvider(): FakeMediaStoreContentProvider { + return FakeMediaStoreContentProvider().apply { + attachInfo( + ApplicationProvider.getApplicationContext(), + ProviderInfo().apply { authority = MediaStore.AUTHORITY } + ) + ShadowContentResolver.registerProviderInternal(MediaStore.AUTHORITY, this) + } +} diff --git a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_2_en.png index 6082c07349..ae687af6c7 100644 --- a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:062413bedff67d6264f7c498db69e38babdfad4cc5b5c0a581f9031dcd03ae10 -size 36968 +oid sha256:fdce2c0a9c41b04870e79cc5e0413bf09ddea7907650d798cc4206ece881933f +size 9782 diff --git a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_3_en.png index ad2925d791..6082c07349 100644 --- a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8ed6c634f81fca7a0d8cf242f294d37fe44b4dd5f0832c1bb1e29df245f06546 -size 24161 +oid sha256:062413bedff67d6264f7c498db69e38babdfad4cc5b5c0a581f9031dcd03ae10 +size 36968 diff --git a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_4_en.png index 5adfe35003..ad2925d791 100644 --- a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:965cd4692e88a413e5e0a83e16a190386e8f199716fdd7288f4e05b8c623a8b2 -size 33756 +oid sha256:8ed6c634f81fca7a0d8cf242f294d37fe44b4dd5f0832c1bb1e29df245f06546 +size 24161 diff --git a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_5_en.png index bf9aed6830..5adfe35003 100644 --- a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:771c01d4563687aff208b0f279c9fb98192b0cd2b2ba56d43627f6c785e92b90 -size 32956 +oid sha256:965cd4692e88a413e5e0a83e16a190386e8f199716fdd7288f4e05b8c623a8b2 +size 33756 diff --git a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_6_en.png new file mode 100644 index 0000000000..bf9aed6830 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:771c01d4563687aff208b0f279c9fb98192b0cd2b2ba56d43627f6c785e92b90 +size 32956 diff --git a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_2_en.png index 72eca81304..5439563841 100644 --- a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e612d46ec9d5234d26768bf68f7f58877f9344858184bd54c6d6be893e972fa -size 34955 +oid sha256:5b11f9ba2fda961d1571b46c1bcbd7a7ccde6efb4a320ead276779c391421223 +size 8543 diff --git a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_3_en.png index 8e38bbe539..72eca81304 100644 --- a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bb9ef99adff85610ec2808bc468a5f8161db436283e51bde77fc2d6f5b61e214 -size 22502 +oid sha256:8e612d46ec9d5234d26768bf68f7f58877f9344858184bd54c6d6be893e972fa +size 34955 diff --git a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_4_en.png index 75fdd8172d..8e38bbe539 100644 --- a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8f2d01f11bcca2ba3465b6308f7368797c6fd9712bf90f4e9493b30dbece8277 -size 31907 +oid sha256:bb9ef99adff85610ec2808bc468a5f8161db436283e51bde77fc2d6f5b61e214 +size 22502 diff --git a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_5_en.png index 28fd0cfc21..75fdd8172d 100644 --- a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:484173d52fcb0acff42f62d28f88cd544e205bde49370ccc8230c30b193e1959 -size 31173 +oid sha256:8f2d01f11bcca2ba3465b6308f7368797c6fd9712bf90f4e9493b30dbece8277 +size 31907 diff --git a/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_6_en.png new file mode 100644 index 0000000000..28fd0cfc21 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:484173d52fcb0acff42f62d28f88cd544e205bde49370ccc8230c30b193e1959 +size 31173 diff --git a/tests/uitests/src/test/snapshots/images/appnav.loggedin_SyncStateViewServerUnreachable_Day_0_en.png b/tests/uitests/src/test/snapshots/images/appnav.loggedin_SyncStateViewServerUnreachable_Day_0_en.png new file mode 100644 index 0000000000..d1e672507d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/appnav.loggedin_SyncStateViewServerUnreachable_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:433fd969f604ec432cc0b324109c5aa3f60abb44c692c6965731314c66377924 +size 10200 diff --git a/tests/uitests/src/test/snapshots/images/appnav.loggedin_SyncStateViewServerUnreachable_Night_0_en.png b/tests/uitests/src/test/snapshots/images/appnav.loggedin_SyncStateViewServerUnreachable_Night_0_en.png new file mode 100644 index 0000000000..95c1b90315 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/appnav.loggedin_SyncStateViewServerUnreachable_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab1c87fe0b7d5c7e5f1ccad62fba68c2a12607bad342046e58ae11abe981a383 +size 8834 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarMultiAccount_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarMultiAccount_Day_0_en.png index d68c73c3dd..5f17df2b70 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarMultiAccount_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarMultiAccount_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:32ad73a97cbf59783c6f5ba2ba216c180af3b8ff30280b712b9b936293a2ecf1 -size 22034 +oid sha256:16eff51daa024855e4843c78ac5d8f381e0ca66d7fd4102319ae74bc5298d4bd +size 22758 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarMultiAccount_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarMultiAccount_Night_0_en.png index 3df4db4b5f..a381f7a081 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarMultiAccount_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarMultiAccount_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:13fac4328987e056ff03284d8d8814d762e8134ea8e3c33ccd95036d1c662b16 -size 20306 +oid sha256:41a251131b2d236b7e5d24730def468e9bf1861409dd1a7ef8eaf9c3b5ab2226 +size 20909 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaces_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaces_Day_0_en.png index 690fad4569..0f3475b8ce 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaces_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaces_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:499d48e33c188e42e066e30644d2ce4d1a2516b6b26d15c385d613234777d738 -size 12105 +oid sha256:a6b9a6700cc66e74dced47f658cc8a6bff533e29e026dfada6c733180b72a19b +size 9126 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaces_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaces_Night_0_en.png index 82f1da2494..c5e641590e 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaces_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaces_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f9d7c7900c41fbc10b27a68bf079e4750c28cfdcc4d96db35a79f3606cd5796 -size 11205 +oid sha256:e4db5dc9d61a6c3e7f19d79efe8b247e91f77d04885b1b63a86682137d009023 +size 8232 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithIndicator_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithIndicator_Day_0_en.png index 2a515ca368..1fd8dfd36f 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithIndicator_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithIndicator_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd697e25338f15e3dfeb82370f1726cc337fa74a5e65240cad22c404ec949976 -size 22485 +oid sha256:d5accca8cea12a266d137db293d48261e7bb92ae84e38167dc287ed39a34df5c +size 23207 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithIndicator_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithIndicator_Night_0_en.png index 7dd2daea6e..91b11e062a 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithIndicator_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithIndicator_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0567d8419972054d7dce9a080b856324a1bc97529ff2d15776aba6c023eb90e2 -size 20730 +oid sha256:3bc4c15cdee0758dfb100efb9c2571152940f0a7724b8e3838ef223c0bd122c8 +size 21325 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithStatus_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithStatus_Day_0_en.png index 4fa1c78188..54b10704ee 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithStatus_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithStatus_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bd896bfc1111dcb04d94584c19a395e69612dd9ceda8beea6432c14aa4f18023 -size 23126 +oid sha256:6a38ac7b1ae8a91f08a8010d684204474e815fb967bbabe3a1080d152d802d44 +size 23742 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithStatus_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithStatus_Night_0_en.png index 9111455bec..a41049657f 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithStatus_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarWithStatus_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:262622cd1124902ab45ccd645d33193dd8d0e4a99ba7e4d2cff786393b350118 -size 21299 +oid sha256:520288193ac12637e44d96ce99ca2f44d447699ae840674c146ec05fe41493d1 +size 21902 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBar_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBar_Day_0_en.png index 539216dd6b..495b8b894b 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBar_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBar_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d1f98cbd7fb3856f01738bca57088ee72c859aef8f09ae8f597b7769ef372ed7 -size 22144 +oid sha256:1981c2ba67db5027a3dad0a477dd33ee2947044574c2b89a55cf7972ab64b3bf +size 22863 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBar_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBar_Night_0_en.png index 353ee965dc..2b154c8425 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBar_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBar_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:038445401184aa84312acebbcc52327202ca0613a88d2667591db00b0121b287 -size 20378 +oid sha256:b31e258cd95a44e39943224285751a1b0ca51b9d490dc70479bf11bb63385dae +size 20974 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_0_en.png index c69cba2125..fb9ed3f101 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09c0613d19abd7673223bc5eac12b4e87c73e66e7ff9d437c5f97ae8bcaf9303 -size 85239 +oid sha256:43b2800a5fb8014557801f35210a98752529c3c71ec285e95be1d405d2910327 +size 84602 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_1_en.png index bd9fa30de2..82f5491b50 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df0dc04b3946a58dda5c3499d965ab23cd3c172eedcb8a23461201c2aca95934 -size 38713 +oid sha256:9640707e06cf5ac5b4f60305c2166b3562f072bcfa13b56b1ed8e6bdd446d24e +size 37854 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_2_en.png index 03d7d97186..be9847ac64 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ecd79d3f8c4efbb54efa6a5b8dee79274ae9957b01146025dab42a45fc328fc0 -size 24740 +oid sha256:e3f09104202b0d1ba857ef376c32733bb598bebe1dbb47fd2765f7bbf5f756eb +size 22218 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_0_en.png index 8ec9b1585e..f1a441a0b3 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e28f120a919db7c7a0ced5ca0d56239197785a0ecfbd37af0c5dacb5f8316c1f -size 83583 +oid sha256:330103af450379bc1ea3bd9815779324384d6025a68ba8e3bb9a76b2565fa2dc +size 82909 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_1_en.png index 72e173c493..29f23a6b80 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cc37ee6efade5947d0fdc25f6925cf45ba55442eb3fb715f942178513a00e8fc -size 37416 +oid sha256:8d6216bd19d1fae4b5cb70aefe2f3959a18d08f0e1f0ae82e174c10427df400f +size 36596 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_2_en.png index 049aa93a8a..3a37cae365 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.spaces_HomeSpacesView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fd37517b8913e2bac3275f717aa15bed18055a7525e0404b25b43cb8bd8422d -size 23840 +oid sha256:037171836a783617d4c6907e73e4738c38bdef6c328302a14f8dcb5535105e41 +size 21553 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png index aca66273b4..fbb9365c88 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeViewA11y_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9cf305c0e52881fb40fee2ba7bdcdc3dfa5c899577b3e252af19e37ee2efacf7 -size 143213 +oid sha256:610593d1acf3fa39f937f787a5e6531c3dd0a2262bc1abf63b8489f227917ba5 +size 144141 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png index b5f80f2faf..091b77a218 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:265d1fd77f393724a26a410e343b1dde4ce8e986db16ba00bb07def24aa52139 +size 68363 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_10_en.png index f106244405..e2b3622d86 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c393b13532fba4d4ef25dda45b6ca3e1f6da6d49c5b2de412beded261a4f9311 -size 36164 +oid sha256:3c3e85ad3d267db2fa0964b4d28ef9fd50ce4989259646d5d07bdea7e24c7a4c +size 36872 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_11_en.png index f2a69ea475..48b80d3c0c 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b493bc5151f15d6ba91bfb0c27b41f3abcf13ee7d511aa59db171cecdbd95f3 -size 31958 +oid sha256:3c3580427877cca8f59538bccd47ee19f57b4bdce28d60135da7de185e17f8f1 +size 32636 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_13_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_13_en.png index f4dff45422..80e9263c7e 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a33681fe064d5e12f53273bfd9262af8c7b902e237cb8d0641573b0645e291c -size 94687 +oid sha256:7758cf4023fa542f29fd8ade54bab1f2707666c640ad62ed8a6805c8ec2256a3 +size 95336 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_14_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_14_en.png index bdad242551..2d5574b88a 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:022954bdca89572b74bc70f6d91820e99eef8826409c0a06e6145a5c3ec8eaf4 -size 87268 +oid sha256:60eec8085688c228b28cef3f1922b984f2cdc54a9207ea04c27c44900fb4d7ee +size 87932 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_15_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_15_en.png index edf07a66f3..0f310c78a8 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af36d0e3383935d256b3730e6f955e858ae42a82f54485b879e7a6a71fc25f6c -size 55237 +oid sha256:0f2280adb5d99a20fbe98b8d097e97c2de0c05a083382a905075bfb5795a2290 +size 55890 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_16_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_16_en.png index aa313049f5..17ff86bf54 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e55407ac7116b3127a979f630777d0d528a349c38d37829d8bc0c37a316c7c5b -size 42526 +oid sha256:2e2d1b5923dd2941bd7e60a9019a7770ed3383529ad9a8f3572c9e3d450310e0 +size 37461 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png index b5f80f2faf..091b77a218 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:265d1fd77f393724a26a410e343b1dde4ce8e986db16ba00bb07def24aa52139 +size 68363 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png index b5f80f2faf..091b77a218 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:265d1fd77f393724a26a410e343b1dde4ce8e986db16ba00bb07def24aa52139 +size 68363 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png index e01335907a..f4db0cfcf2 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:25e5ac35ddf79fdc78c21dbd031f506da04caac7bdd53f279c88fd4574f72a82 -size 64644 +oid sha256:564360123154706aceed6daf43f33c96c7e3c49b787222ecc236aabb1349e3a1 +size 65323 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_4_en.png index fe41a54fcc..9eb0a6a43d 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8c1245da0a4cf56aba8e102e7f297996ed58fc8c50165288dea11ca86fbc0dcc -size 57235 +oid sha256:0227ce3fa0664ff4a99649532683837835733d1bb26dcd05b7f6277964b5115c +size 54342 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png index b5f80f2faf..091b77a218 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8247dedc9811f2b325568973a2b714cd4b46322bcd0bf89af0e662518fa77747 -size 67694 +oid sha256:265d1fd77f393724a26a410e343b1dde4ce8e986db16ba00bb07def24aa52139 +size 68363 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_6_en.png index 0894037dc4..c301dfd550 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e389bce27c241cc13f60c247fea578fa4cd1ecc7448c9bf3ac3af1179e693216 -size 54701 +oid sha256:de11af4e521b9ea166dd422b94e1a6405753a8c00a9b637c76dd3b0e14774294 +size 55355 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_7_en.png index c4630c3e45..05b71c486e 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5d56802b3e6dd21b20c4e92d47d9b880085f499feac855a46af601a5eb0bfc2f -size 54510 +oid sha256:6596062bc9bcbd81f51e1c7118426ec389c3bcae65df0793754c1a319399998f +size 55159 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_8_en.png index ce7b5ea699..85ef4f3937 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef1246daff44b29ee4c2b50538a3a948c2593749c70e15e053b8258a377c5756 -size 52745 +oid sha256:af598c1be693235acf29785c487fe69f0bdfc32408bddc948be78bc5fd8ce558 +size 53394 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_9_en.png index b6779aca93..f3460a5dd4 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0769e243dc60de52c16df23a86eb62167bb60cb756d113f88b9b476c025e402b -size 87117 +oid sha256:d2151f61b19e6d1710c9b9074abbcf2a927fca510cc6d3ef08746dcc4cf8e14a +size 87791 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png index fe171e26b7..226f054223 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:a4c896b9025d301961b134c22ec1af858cec8a7b0eb1d823936f5af3cad38271 +size 63094 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_10_en.png index 420cb7c631..fc6b74a2f6 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d56c1af9fc73f4aaeed33dd8b6113ec64ddbfdf63506aa31ae6996732870baa2 -size 31127 +oid sha256:588859e742ca76818510abb9326969da5c227d0946ea6aac9de216e1a62a7d6a +size 31657 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_11_en.png index f36b5c2f83..6f2a17627d 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c3fb8ae530989e5a6d6e4193fbac4d4356af34076b684b782a0f8fec31226f1 -size 26657 +oid sha256:827036f5c685629037b5bb7ae3265b3261a52ab3d8b912344cd06137839512be +size 27266 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_13_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_13_en.png index 48b07fc9bd..d905171a03 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5d302be6dbaef08b501f4f33d3e01338e068767702a7018bd5bb24ade6eee2cb -size 88558 +oid sha256:3691f9a0e8e90001011735c7496cc977995347eee2333d0f9bb37bd57b488fb8 +size 89113 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_14_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_14_en.png index 5cb7d766cd..a4ffe24031 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6be5cd35eec917bf1c96d03079df8235d0bc7b52159f54c8a177a64f648e58c2 -size 81212 +oid sha256:649be03a09cc9f34c0d59f035cdf26096b54b04d9dcb06f53882aae7a6c098cb +size 81789 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_15_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_15_en.png index 2691d1c1df..2e8b7314f6 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66d241a4270bef0a1f80c62c302cb5b40b4370f73e8175a746d32c90d1f98208 -size 49882 +oid sha256:422c10006716ea99ae5d3222334f4fe74a002e42fa2ab8b7890dff31a60ca8f7 +size 50444 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_16_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_16_en.png index 451ca0c3ec..a26f43f289 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1838a9839badf62d4d427a66a15a7d220996bdb0b60f0373249d62e4afae2910 -size 37390 +oid sha256:858ed6993ba8d322e9acc40ca3e759372e19cd3aae9854709f374c0c8d1c4429 +size 32549 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png index fe171e26b7..226f054223 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:a4c896b9025d301961b134c22ec1af858cec8a7b0eb1d823936f5af3cad38271 +size 63094 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png index fe171e26b7..226f054223 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:a4c896b9025d301961b134c22ec1af858cec8a7b0eb1d823936f5af3cad38271 +size 63094 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png index 89c4934dd9..b8280a1e69 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8172ca867a8c2e827ead36a22cefe967a5b57c69cae6d5aca1c941e4dd2ba16f -size 60160 +oid sha256:6329073993661030954c535849498a33dc3a95700ef3b1fae9f80cf0b5e6ffaa +size 60728 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_4_en.png index 390c9f6818..95f3da33c4 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:13740b4fc4cbd24075606e45bf070a8fbb762d298857bc071399def91dda7175 -size 52189 +oid sha256:a04f8865034c36f32f0f7dd098fd6bf3e80f96869036f965c755a7a48286a071 +size 49232 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png index fe171e26b7..226f054223 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a7b2a6ccb43dc572140e2ce658967cd273d1122bf0216269a648920099a68d0 -size 62572 +oid sha256:a4c896b9025d301961b134c22ec1af858cec8a7b0eb1d823936f5af3cad38271 +size 63094 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_6_en.png index 0950a6881e..75d8b98f01 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:842d5e0a5cc1102a2e320b43ea5ffda6cf63e94acdbf42adc39edaa1fb355812 -size 51742 +oid sha256:18d684eb1d4bbb7c3eb5de88a5123a60ff66ae625809761ae5f3624e443961c4 +size 52297 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_7_en.png index b29ccd3a11..0c77cb1a94 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82b75ce20a535cf1062c4b3ad86d08dc9044cf58d8035a5fdc68c48119157aae -size 51477 +oid sha256:6d4567aa39bf44dbf66c8c81f9195f0520364b1478b0bf9ef499923e0c267b9f +size 52015 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_8_en.png index 1b1e2edb98..f9b5ae643f 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9bde8e9c5618ffc2b3019ce10db6fca44e4f23f3b987c222b7e838beb0dcb488 -size 49749 +oid sha256:729160a038512d10dbce06e6bdf8db15a2aca6b22624a096ade6687ca9c8b7de +size 50335 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_9_en.png index 1092dcd1b9..3ae5a4a153 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_9_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Night_9_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bce0bc8590037705369735e83e576f0818f4528298b4b2face999c8802f855b9 -size 81081 +oid sha256:76035b0f36179e93f4e35b9c59d51acee15e7db9d02249ee102656da88e04341 +size 81635 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_3_en.png new file mode 100644 index 0000000000..da05331f32 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a5a8e393b6c77066e69b25e41a82850f86d0b8b283cecc7f7c8416d612974d7 +size 43097 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_3_en.png new file mode 100644 index 0000000000..0fe722a849 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81cffb805bfeda8d0ce58f6c54fb298cc2b35d0ce210f514fd7ccb2fa23ab82f +size 40978 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png index 2c15fd6a37..82f344f714 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7260abe16fc2ab4f6ada97ea0f756dc5c9908ff3b61d6ec1723ffde302bb3dc5 -size 11955 +oid sha256:8d96eefa01a5de803b0cc9d9109c20df86eaff01b73ff24a91945b43d53d5d01 +size 11960 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png index 7c422591ad..fdd8fcd5a5 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2e16fc1daa0f284c442f66591e1d1f59161272a67998d26740b0b403bfe82495 -size 11866 +oid sha256:22678a2a8505e1d9d5bfcb44459b6fac69c11a4bf72fae09c48ffa2e54f5a110 +size 12128 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png index acb8afa74a..f6e27475da 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bed7565420e11c43acc06896ef298683d2f2dd1dc16c430eab7018e9ead9937a -size 11652 +oid sha256:9ee89826c8aa2ac0371ed31959243f62bf9968806a7b27117cd7b4d849423a33 +size 11667 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png index e0a4cb1c97..b1bb69efb8 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.userstatus_UserStatusView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:87811df502c760816e74beb006341627857c39380ce715f1a747ac9fce77b226 -size 11516 +oid sha256:e27cb9e501ccbac54926f0252b4098867643a2d2683c5b267f86ad99edf6f5d7 +size 11817 diff --git a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_5_en.png index 5c597300e4..73b8b8e50c 100644 --- a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:21aee17ee9a292bb2f0d3d2ec2db565ebb8d5df753454a5a002f033ed5ed39c5 -size 3853 +oid sha256:e8a3ad99763929d2783147814e44fa9ec7e7efb7b99949e3580f23c38fb5d971 +size 9398 diff --git a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_7_en.png index f8a1f24b34..5c597300e4 100644 --- a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1342b6427f5b2e3075ae3ccee60bf1d3b11afaccf3e152643d6ef2d65c21b54e -size 9108 +oid sha256:21aee17ee9a292bb2f0d3d2ec2db565ebb8d5df753454a5a002f033ed5ed39c5 +size 3853 diff --git a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_5_en.png index 4ae7279fe7..d47bf90664 100644 --- a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:410a8596d4c70b9a3856d959d6d71ade8e831e8581ff410c9900957a93b3eb50 -size 8114 +oid sha256:520cae2544153ba45f8fe4b021286c2d22da06aa1b60be7f1b13879723ff994c +size 3664 diff --git a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_7_en.png index d47bf90664..a87d89c701 100644 --- a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:520cae2544153ba45f8fe4b021286c2d22da06aa1b60be7f1b13879723ff994c -size 3664 +oid sha256:0f3a0cb5d9bae6725a1c527f3d9575aabf85d3edf63a28d9faa23f4f0b65e2d0 +size 7624 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_0_en.png index cf3ad87af2..1ba6334833 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5bf5bde86a7f7e873a6871d359f04f2139099724f9977601f5077b2590e97965 -size 46711 +oid sha256:a4e87a86ec5448c005cc236354c08668380023f3f734413edd38130af7ac9846 +size 46183 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_1_en.png index a31ad79f82..e165278f84 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:078079bf6b37471c7a16363c9d49f7deb5bd9d044ed0591ab86ac8a20800ba03 -size 46795 +oid sha256:9d7ecf7e48fc351e1936d9dcb7485873c93941248a5c63af8334fa4967bcca5b +size 46285 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_2_en.png index 5f77c0fa62..5b3e741c3f 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d36b97324b3794ff1d01fc13e9e59afb010130d6e022dcc2d7cc9387a13a787e -size 48387 +oid sha256:9db9d270801acdc43a87955bc055e5a25b9b1617de7e2aad93e4d390a36ac653 +size 47797 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_3_en.png index 5f67807828..8b83d0555c 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:986cfe49b91c522cf3a85d0425c0677a6929c44dca1531923bff2c1766ce46ae -size 59583 +oid sha256:373cfbe1fbd3d9e06bc2079b7ceadccce999efd0cbb1d43f7e791450f3882cad +size 59876 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_4_en.png index bc27628ac1..6febc55310 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8f8ef2430abd11b31b3e90976f85bd4578e667bd0acc0851544ee4d16358479d -size 60237 +oid sha256:5192794371b4b3d10d5e8035f5ccf5dad66b04f16abb686cb5820d4647f678ef +size 60550 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_5_en.png index 490c357afb..b73534e92c 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7228db8e07c808d0a8780ed06f04f22ff8f1d620327f9243b1dedd476cecb902 -size 56896 +oid sha256:59c20c6ef29f76824f67bf26062586455a792bcbee79b7a0c648a1d69db08a65 +size 56400 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_0_en.png index 039776804a..463df3804a 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d28be1c00397b6ac456ef93dab7b1da469a7665380ab2c326124efeef3a1c150 -size 45143 +oid sha256:13cf913c2a26e30ad4c9700b110802a00213a514d3c9fcfbce81a82f7316f9fe +size 44515 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_1_en.png index 6847911e3c..3423750384 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:81523eeba6cf9e4ea1e92cc819845bc95bbd76f9e977b462f2357fe1d2e3d335 -size 45230 +oid sha256:5a20e35cfde7c96c6f4ceb5fe51af915f7a5ebdd16dc4dc4a0b4187b0163c42a +size 44611 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_2_en.png index 763fae759e..cb5e4713ba 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cdff40a4ce4b77a98818798c2b6ce3ad18ee5117f40c47c075b30d7006626f10 -size 46703 +oid sha256:28698a3c2fbbee2f76b6a3fb6312596102e7fc8743dc95db62816606b4109be0 +size 46054 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_3_en.png index 760d8a7533..8d9d32385a 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d331297c0912ffc278828cd00f886c926a71e59aadbee9fef994258ea56c822 -size 57757 +oid sha256:1ee744055473f7b652d874ff024ece68434e88ef085571dbab6466bcd3557c23 +size 58226 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_4_en.png index 058f822302..2c24a43118 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8265b9acc3932df6f0e1eee5e85c2b16d13060c4164c3fbb5b1b0b598745770c -size 58326 +oid sha256:e3c991f23daeb792f580afc18f3f528649cc92f27555ed05cc1abedad8accc27 +size 58781 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_5_en.png index 5f5b2315e6..abeece9d09 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:936cf07258d4a001b80b08df4b804c990b9e399bf8a5a544443beebf93caa298 -size 54825 +oid sha256:a95e67e4fd7d8886691d5df2571325254f49db24f8ab16335505b0c201cafb14 +size 54363 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.text_AdaptativeTitle_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.text_AdaptativeTitle_Day_0_en.png new file mode 100644 index 0000000000..87822df7c7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.text_AdaptativeTitle_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5bbefd78bcd53367f72ca11d4f57ea87903a677ff24ba4bc80cc50ddd33108fa +size 34859 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.text_AdaptativeTitle_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.text_AdaptativeTitle_Night_0_en.png new file mode 100644 index 0000000000..57b121b8fa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.text_AdaptativeTitle_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:17941844b79274e31880c09b5ee8052693c4425eb7f09c3c2c8687bf2ef64863 +size 34113 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png index ee5c582240..58546b8522 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:75de341f705bb22955f9399180519b65ad59c2e1917317d56e50ea4db78f35bc -size 12102 +oid sha256:30357bf18792db5ab20085010251170a724ffe8c00313facd04ee704e806af64 +size 10982 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Night_0_en.png index 52fd9b9693..fedead0fa8 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbarNoFab_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:58b9491aaa5554b89d86c62a5c9c44975367538c6baa5562f8dc5768f76d8264 -size 8705 +oid sha256:4811667dec0f32accc5e2c8a5dd5c0b34017fcff8d1e0376ee7a4708426bca0f +size 9838 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png index 38ad0e4b56..83302d6ba4 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:326d59cf768b2dbd96eb7964d54c8eda509a5cd57c6e62087625e3314da0bb4d -size 13658 +oid sha256:16dee1dfe746cd40dac9fe8f0af3ecb42cd14bab5cc490917b589beed065ed1b +size 14686 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Night_0_en.png index 687e0d436a..4da2a98aca 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components_HorizontalFloatingToolbar_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d57df7110371c9224e232b707d6a1b10ce14afcb56c803a78220792f2a0d4cf2 -size 11354 +oid sha256:ef0dc4d53daeb986f378b6e4d85928b4b6729336c89cf21e642c88c53688a3ab +size 10292 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderRootView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderRootView_Day_0_en.png index 3caed99b5a..7230e781b2 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderRootView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderRootView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:53ec492b0a46d3add165e01c7ce540f304574791d10232a825e28cd15ae22248 -size 16903 +oid sha256:e582696ce8f1354353347853e8ad5e774b0c8fd2d34de5b1bc22460d372b2cf0 +size 16207 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderRootView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderRootView_Night_0_en.png index c653ffd3fe..6f079aa16c 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderRootView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderRootView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:938b51a3e4e4466056da464fabd34066b716fbf55622b179f799181ff900819f -size 15978 +oid sha256:e34e3ba2f0c3bb8cbb0a21dea399dfa4ee34607c7ad4b2f86153d60f325696c3 +size 15232 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderView_Day_0_en.png index f1bd04835c..341974f70a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7853b79eda8a4b82cc49d553f78f545af62bc44b708a20a5bb5e4582ed07bc22 -size 63262 +oid sha256:72ea344a67f0c3cdb0037d63265414868bdc886ee0ef548ace738a7c95aa8ef6 +size 62034 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderView_Night_0_en.png index 9eba778594..e813b4086b 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_SpaceHeaderView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7daff199310c41900d0ca650f38ad2561c8db8542a4d48161a5a88f7fe976318 -size 61899 +oid sha256:0af67c43f3a8b3a5b322a9ab46a46468aa45df57e181e661b9a8c4489d737aff +size 61205 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Day_0_en.png new file mode 100644 index 0000000000..3cec94cb44 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1bfd26e506a4e1693d0d0720add60ab9aa2b01558e0d14c8294bedd5b42ca2fb +size 15005 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Day_1_en.png new file mode 100644 index 0000000000..5d370f30d9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5103803369870f9cd661f1b5cbbb70023cef586276fca80f010432596b8ecbc3 +size 15026 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Day_2_en.png new file mode 100644 index 0000000000..a62a416966 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec16ff6ed3abbd4a04cf8d4b0664424e109736013868e790496afe5c7b106646 +size 14861 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Night_0_en.png new file mode 100644 index 0000000000..8fe23cb9be --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7351236d535315198d7d6d8d63d36deb26360af1ff3ed6f148e7c811ee69056 +size 14852 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Night_1_en.png new file mode 100644 index 0000000000..d679329fb8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5bff0dd25d4ea06c23bc2fa8968892e8b07da65dfba57a61788d2955187b7c9c +size 14845 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Night_2_en.png new file mode 100644 index 0000000000..ac90f5b949 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ffc62fd0e9b765cfe43193cf0dd443fcf7199330f4a3f9691f9627e423c3f64 +size 14774 diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt index 7eae1a5a78..ff8cec3036 100644 --- a/tools/manifest/gplay/release/aaptDump.txt +++ b/tools/manifest/gplay/release/aaptDump.txt @@ -1,4 +1,4 @@ -package: name='io.element.android.x' versionCode='202608020' versionName='26.08.2' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' +package: name='io.element.android.x' versionCode='202608040' versionName='26.08.4' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' sdkVersion:'24' targetSdkVersion:'37' uses-permission: name='android.permission.POST_NOTIFICATIONS' diff --git a/tools/release/releaseV2.sh b/tools/release/releaseV2.sh new file mode 100755 index 0000000000..cef4b7e6b0 --- /dev/null +++ b/tools/release/releaseV2.sh @@ -0,0 +1,544 @@ +#!/usr/bin/env bash + +# Copyright (c) 2026 Element Creations Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +# Please see LICENSE files in the repository root for full details. + +# do not exit when any command fails (issue with git flow) +set +e + +printf "\n================================================================================\n" +printf "| Welcome to the release script (V2)! |\n" +printf "================================================================================\n" + +printf "Checking environment...\n" +envError=0 + +# Check that bundletool is installed +if ! command -v bundletool &> /dev/null +then + printf "Fatal: bundletool is not installed. You can install it running \`brew install bundletool\`\n" + envError=1 +fi + +# Check that the GitHub CLI is installed. It is expected to be already authenticated, see `gh auth login`. +if ! command -v gh &> /dev/null +then + printf "Fatal: gh (the GitHub CLI) is not installed. You can install it running \`brew install gh\`\n" + envError=1 +fi + +# Path of the key store (it's a file) +keyStorePath="${ELEMENT_X_KEYSTORE_PATH}" +if [[ -z "${keyStorePath}" ]]; then + printf "Fatal: ELEMENT_X_KEYSTORE_PATH is not defined in the environment.\n" + envError=1 +fi +# Keystore password +keyStorePassword="${ELEMENT_X_KEYSTORE_PASSWORD}" +if [[ -z "${keyStorePassword}" ]]; then + printf "Fatal: ELEMENT_X_KEYSTORE_PASSWORD is not defined in the environment.\n" + envError=1 +fi +# Key password +keyPassword="${ELEMENT_X_KEY_PASSWORD}" +if [[ -z "${keyPassword}" ]]; then + printf "Fatal: ELEMENT_X_KEY_PASSWORD is not defined in the environment.\n" + envError=1 +fi +# Android home +androidHome="${ANDROID_HOME}" +if [[ -z "${androidHome}" ]]; then + printf "Fatal: ANDROID_HOME is not defined in the environment.\n" + envError=1 +fi +# @elementbot:matrix.org matrix token / Not mandatory +elementBotToken="${ELEMENT_BOT_MATRIX_TOKEN}" +if [[ -z "${elementBotToken}" ]]; then + printf "Warning: ELEMENT_BOT_MATRIX_TOKEN is not defined in the environment.\n" +fi + +if [ ${envError} == 1 ]; then + exit 1 +fi + +# Handle the result of a version check. ${checkError} must be set to 1 when a check has failed. +checkVersionResult() { + if [[ ${checkError} -ne 0 ]]; then + printf "\nThe check above has failed, this is not expected.\n" + read -r -p "Do you want to continue anyway (yes/no) default to no? " doContinue + doContinue=${doContinue:-no} + if [ "${doContinue}" != "yes" ]; then + exit 1 + fi + else + printf "The versions are correct.\n" + fi +} + +# Read minSdkVersion from file plugins/src/main/kotlin/Versions.kt +minSdkVersion=$(grep "MIN_SDK_FOSS =" ./plugins/src/main/kotlin/Versions.kt |cut -d '=' -f 2 |xargs) +# Read buildToolsVersion from file plugins/src/main/kotlin/Versions.kt +buildToolsVersion=$(grep "BUILD_TOOLS_VERSION =" ./plugins/src/main/kotlin/Versions.kt |cut -d '=' -f 2 |xargs) +buildToolsPath="${androidHome}/build-tools/${buildToolsVersion}" + +if [[ ! -d ${buildToolsPath} ]]; then + printf "Fatal: %s folder not found, ensure that you have installed the SDK version %s.\n" "${buildToolsPath}" "${buildToolsVersion}" + exit 1 +fi + +# Check if git flow is enabled +gitFlowDevelop=$(git config gitflow.branch.develop) +if [[ ${gitFlowDevelop} != "" ]] +then + printf "Git flow is initialized\n" +else + printf "Git flow is not initialized. Initializing...\n" + ./tools/gitflow/gitflow-init.sh +fi + +printf "OK\n" + +printf "\n================================================================================\n" +printf "Ensuring main and develop branches are up to date...\n" + +git checkout main +git pull +git checkout develop +git pull + +printf "\n================================================================================\n" +# Guessing version to propose a default version +versionsFile="./plugins/src/main/kotlin/Versions.kt" +# The version of the release must match the date of next monday, where the release is supposed to go live +# The command below gets the date of next monday +nextMondayDateCommand="date -v +1w -v -monday" +# Get release year on 2 digits +versionYearCandidate=$(${nextMondayDateCommand} +%y) +currentVersionMonth=$(grep "val versionMonth" ${versionsFile} | cut -d " " -f6) +# Get release month on 2 digits +versionMonthCandidate=$(${nextMondayDateCommand} +%m) +versionMonthCandidateNoLeadingZero=${versionMonthCandidate/#0/} +currentVersionReleaseNumber=$(grep "val versionReleaseNumber" ${versionsFile} | cut -d " " -f6) +# if the release month is the same as the current version, we increment the release number, else we reset it to 0 +if [[ ${currentVersionMonth} -eq ${versionMonthCandidateNoLeadingZero} ]]; then + versionReleaseNumberCandidate=$((currentVersionReleaseNumber + 1)) +else + versionReleaseNumberCandidate=0 +fi +versionCandidate="${versionYearCandidate}.${versionMonthCandidate}.${versionReleaseNumberCandidate}" + +read -r -p "Please enter the release version (example: ${versionCandidate}). Format must be 'YY.MM.x' or 'YY.MM.xy', with year and month matching next Monday. Just press enter if ${versionCandidate} is correct. " version +version=${version:-${versionCandidate}} + +# extract year, month and release number for future use +versionYear=$(echo "${version}" | cut -d "." -f1) +versionMonth=$(echo "${version}" | cut -d "." -f2) +versionMonthNoLeadingZero=${versionMonth/#0/} +versionReleaseNumber=$(echo "${version}" | cut -d "." -f3) + +printf "\n================================================================================\n" +printf "Starting the release %s\n" "${version}" +git flow release start "${version}" + +# Note: in case the release is already started and the script is started again, checkout the release branch again. +ret=$? +if [[ $ret -ne 0 ]]; then + printf "Mmh, it seems that the release is already started. I'm displaying the changes now:\n" + git diff --stat "release/${version}" origin/main + printf "Do you want to continue the release using its contents?\n\n" + read -r -p "Continue (yes/no) default to yes? " doContinue + doContinue=${doContinue:-yes} + if [ "${doContinue}" == "no" ]; then + printf "OK, exiting, you can start the release again with the command 'git flow release start %s'\n" "${version}" + exit 1 + fi + git checkout "release/${version}" +fi + +# Ensure version is OK +versionsFileBak="${versionsFile}.bak" +cp ${versionsFile} ${versionsFileBak} +sed "s/private const val versionYear = .*/private const val versionYear = ${versionYear}/" ${versionsFileBak} > ${versionsFile} +sed "s/private const val versionMonth = .*/private const val versionMonth = ${versionMonthNoLeadingZero}/" ${versionsFile} > ${versionsFileBak} +sed "s/private const val versionReleaseNumber = .*/private const val versionReleaseNumber = ${versionReleaseNumber}/" ${versionsFileBak} > ${versionsFile} +rm ${versionsFileBak} + +printf -v versionReleaseNumber2Digits "%02d" "${versionReleaseNumber}" +versionCode="20${versionYear}${versionMonth}${versionReleaseNumber2Digits}0" + +# Update the file aaptDump.txt with the new version +aaptDumpFile="./tools/manifest/gplay/release/aaptDump.txt" +sed "s/versionCode='[0-9]*'/versionCode='${versionCode}'/" ${aaptDumpFile} > ${aaptDumpFile}.bak +sed "s/versionName='[0-9]*\.[0-9]*\.[0-9]*'/versionName='${version}'/" ${aaptDumpFile}.bak > ${aaptDumpFile} +rm ${aaptDumpFile}.bak + +git commit -a -m "Setting version for the release ${version}" + +printf "\n================================================================================\n" +printf "Creating fastlane file...\n" +fastlaneFile="${versionCode}.txt" +fastlanePathFile="./fastlane/metadata/android/en-US/changelogs/${fastlaneFile}" +printf "Main changes in this version: bug fixes and improvements.\nFull changelog: https://github.com/element-hq/element-x-android/releases" > "${fastlanePathFile}" + +read -r -p "I have created the file ${fastlanePathFile}, please edit it and press enter to continue. " +git add "${fastlanePathFile}" +git commit -a -m "Adding fastlane file for version ${version}" + +printf "\n================================================================================\n" +printf "OK, finishing the release...\n" +# GIT_MERGE_AUTOEDIT avoids opening the editor for the 2 merge commits, whose default message is +# always used, and -m provides the message of the annotated tag. git flow appends the tag name to +# it, so the tag message ends up being "Release v${version}". +GIT_MERGE_AUTOEDIT=no git flow release finish -m "Release" "${version}" + +printf "\n================================================================================\n" +read -r -p "Done, push the branch 'main' and the new tag (yes/no) default to yes? " doPush +doPush=${doPush:-yes} + +if [ "${doPush}" == "yes" ]; then + printf "Pushing branch 'main' and tag 'v%s'...\n" "${version}" + git push origin main + git push origin "v${version}" +else + printf "Not pushing, do not forget to push manually!\n" +fi + +printf "\n================================================================================\n" +printf "Checking out develop...\n" +git checkout develop + +printf "\n================================================================================\n" +printf "Downloading the artifacts...\n" + +targetPath="./tmp/Element/${version}" +fdroidTargetPath="${targetPath}/fdroid" +gplayTargetPath="${targetPath}/gplay" + +releaseCommit=$(git rev-parse --verify --quiet "v${version}^{commit}") +if [[ -z "${releaseCommit}" ]]; then + # Without a commit, `gh run list` would return the latest run of the workflow, which may be another one. + printf "Fatal: the tag v%s cannot be resolved.\n" "${version}" + exit 1 +fi + +printf "Looking for the run of the workflow release.yml for the commit %s...\n" "${releaseCommit}" + +runId="" + +# The run can take a few seconds to appear after the push, so retry for a couple of minutes. +for _ in $(seq 1 12); do + runId=$(gh run list --repo element-hq/element-x-android --workflow release.yml --commit "${releaseCommit}" --limit 1 --json databaseId -q '.[0].databaseId' 2> /dev/null) + if [[ -n "${runId}" ]]; then + break + fi + printf "No run found yet, waiting...\n" + sleep 10 +done + +if [[ -z "${runId}" ]]; then + printf "Fatal: no run of the workflow release.yml found for the commit %s.\n" "${releaseCommit}" + exit 1 +fi + +printf "Found the run https://github.com/element-hq/element-x-android/actions/runs/%s\n" "${runId}" +printf "Waiting for the run to complete...\n" +gh run watch "${runId}" --repo element-hq/element-x-android --compact --exit-status + +ret=1 + +while [[ $ret -ne 0 ]]; do + gh run download "${runId}" --repo element-hq/element-x-android \ + --dir "${gplayTargetPath}" \ + --name elementx-app-gplay-bundle-unsigned + + ret=$? + if [[ $ret -eq 0 ]]; then + gh run download "${runId}" --repo element-hq/element-x-android \ + --dir "${fdroidTargetPath}" \ + --name elementx-app-fdroid-apks-unsigned + + ret=$? + fi + if [[ $ret -ne 0 ]]; then + read -r -p "Error while downloading the artifacts. You may want to fix the issue and retry. Retry (yes/no) default to yes? " doRetry + doRetry=${doRetry:-yes} + if [ "${doRetry}" == "no" ]; then + exit 1 + fi + fi +done + +printf "\n================================================================================\n" +printf "Signing the FDroid APKs...\n" + +cp "${fdroidTargetPath}"/app-fdroid-arm64-v8a-release.apk \ + "${fdroidTargetPath}"/app-fdroid-arm64-v8a-release-signed.apk +"${buildToolsPath}"/apksigner sign \ + -v \ + --alignment-preserved true \ + --ks "${keyStorePath}" \ + --ks-pass pass:"${keyStorePassword}" \ + --ks-key-alias elementx \ + --key-pass pass:"${keyPassword}" \ + --min-sdk-version "${minSdkVersion}" \ + "${fdroidTargetPath}"/app-fdroid-arm64-v8a-release-signed.apk + +cp "${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release.apk \ + "${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release-signed.apk +"${buildToolsPath}"/apksigner sign \ + -v \ + --alignment-preserved true \ + --ks "${keyStorePath}" \ + --ks-pass pass:"${keyStorePassword}" \ + --ks-key-alias elementx \ + --key-pass pass:"${keyPassword}" \ + --min-sdk-version "${minSdkVersion}" \ + "${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release-signed.apk + +cp "${fdroidTargetPath}"/app-fdroid-x86-release.apk \ + "${fdroidTargetPath}"/app-fdroid-x86-release-signed.apk +"${buildToolsPath}"/apksigner sign \ + -v \ + --alignment-preserved true \ + --ks "${keyStorePath}" \ + --ks-pass pass:"${keyStorePassword}" \ + --ks-key-alias elementx \ + --key-pass pass:"${keyPassword}" \ + --min-sdk-version "${minSdkVersion}" \ + "${fdroidTargetPath}"/app-fdroid-x86-release-signed.apk + +cp "${fdroidTargetPath}"/app-fdroid-x86_64-release.apk \ + "${fdroidTargetPath}"/app-fdroid-x86_64-release-signed.apk +"${buildToolsPath}"/apksigner sign \ + -v \ + --alignment-preserved true \ + --ks "${keyStorePath}" \ + --ks-pass pass:"${keyStorePassword}" \ + --ks-key-alias elementx \ + --key-pass pass:"${keyPassword}" \ + --min-sdk-version "${minSdkVersion}" \ + "${fdroidTargetPath}"/app-fdroid-x86_64-release-signed.apk + +printf "\n================================================================================\n" +printf "Checking the signed APKs...\n" + +checkError=0 + +# Each APK gets the version code of the app bundle, plus the code of its ABI. +# Must be kept in sync with the abiVersionCodes map in app/build.gradle.kts. +fdroidAbis="armeabi-v7a:1 arm64-v8a:2 x86:3 x86_64:4" + +for abiEntry in ${fdroidAbis}; do + abi="${abiEntry%%:*}" + abiCode="${abiEntry##*:}" + expectedApkVersionCode=$((versionCode + abiCode)) + apkFile="${fdroidTargetPath}/app-fdroid-${abi}-release-signed.apk" + apkBadging=$("${buildToolsPath}"/aapt dump badging "${apkFile}" | grep -m 1 "^package") + apkVersionCode=$(printf "%s" "${apkBadging}" | sed -n "s/.*versionCode='\([0-9]*\)'.*/\1/p") + apkVersionName=$(printf "%s" "${apkBadging}" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p") + printf "File app-fdroid-%s-release-signed.apk: version code %s, version name %s\n" "${abi}" "${apkVersionCode}" "${apkVersionName}" + if [[ "${apkVersionCode}" != "${expectedApkVersionCode}" ]]; then + printf "Warning: was expecting the version code %s, but got %s.\n" "${expectedApkVersionCode}" "${apkVersionCode}" + checkError=1 + fi + if [[ "${apkVersionName}" != "${version}" ]]; then + printf "Warning: was expecting the version name %s, but got %s.\n" "${version}" "${apkVersionName}" + checkError=1 + fi +done + +checkVersionResult + +printf "\n================================================================================\n" +printf "The APKs in %s have been signed!\n" "${fdroidTargetPath}" + +unsignedBundlePath="${gplayTargetPath}/app-gplay-release.aab" +signedBundlePath="${gplayTargetPath}/app-gplay-release-signed.aab" +# The universal APK is downloaded manually from the GooglePlay console, and is named after the version code. +universalApkPath="${gplayTargetPath}/${versionCode}.apk" + +printf "\n================================================================================\n" +printf "Signing file %s with build-tools version %s for min SDK version %s...\n" "${unsignedBundlePath}" "${buildToolsVersion}" "${minSdkVersion}" + +cp "${unsignedBundlePath}" "${signedBundlePath}" + +"${buildToolsPath}"/apksigner sign \ + -v \ + --ks "${keyStorePath}" \ + --ks-pass pass:"${keyStorePassword}" \ + --ks-key-alias elementx \ + --key-pass pass:"${keyPassword}" \ + --min-sdk-version "${minSdkVersion}" \ + "${signedBundlePath}" + +printf "\n================================================================================\n" +printf "Checking the signed app bundle...\n" + +checkError=0 + +bundleVersionCode=$(bundletool dump manifest --bundle="${signedBundlePath}" --xpath=/manifest/@android:versionCode) +bundleVersionName=$(bundletool dump manifest --bundle="${signedBundlePath}" --xpath=/manifest/@android:versionName) +printf "File %s: version code %s, version name %s\n" "$(basename "${signedBundlePath}")" "${bundleVersionCode}" "${bundleVersionName}" +if [[ "${bundleVersionCode}" != "${versionCode}" ]]; then + printf "Warning: was expecting the version code %s, but got %s.\n" "${versionCode}" "${bundleVersionCode}" + checkError=1 +fi +if [[ "${bundleVersionName}" != "${version}" ]]; then + printf "Warning: was expecting the version name %s, but got %s.\n" "${version}" "${bundleVersionName}" + checkError=1 +fi + +checkVersionResult + +printf "\n================================================================================\n" +printf "The file %s has been signed and can be uploaded to the PlayStore!\n" "${signedBundlePath}" + +printf "\n================================================================================\n" +read -r -p "Do you want to build the APKs from the app bundle? You need to do this step if you want to install the application to your device. (yes/no) default to no " doBuildApks +doBuildApks=${doBuildApks:-no} + +if [ "${doBuildApks}" == "yes" ]; then + printf "Building apks...\n" + bundletool build-apks --bundle="${signedBundlePath}" --output="${gplayTargetPath}"/elementx.apks \ + --ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \ + --overwrite + + read -r -p "Do you want to install the application to your device? Make sure there is one (and only one!) connected device first. (yes/no) default to yes " doDeploy + doDeploy=${doDeploy:-yes} + if [ "${doDeploy}" == "yes" ]; then + printf "Installing apk for your device...\n" + bundletool install-apks --apks="${gplayTargetPath}"/elementx.apks + read -r -p "Please run the application on your phone to check that the upgrade went well. Press enter to continue. " + else + printf "APK will not be deployed!\n" + fi +else + printf "APKs will not be generated!\n" +fi + +printf "\n================================================================================\n" +printf "Create the open testing release on GooglePlay.\n" + +printf "On GooglePlay console, go the the open testing section and click on \"Create new release\" button, then:\n" +printf " - upload the file %s.\n" "${signedBundlePath}" +printf " - copy the release note from the fastlane file.\n" +printf " - download the universal APK, to be able to provide it to the GitHub release: click on the right arrow next to the \"App bundle\", then click on the \"Download\" tab, and download the \"Signed, universal APK\". Save it, without renaming it, to the folder %s. The expected final path is %s\n" "${gplayTargetPath}" "${universalApkPath}" +printf " - submit the release.\n" +read -r -p "Press enter to continue. " + +printf "You can then go to \"Publishing overview\" and send the new release for a review by Google.\n" +read -r -p "Press enter to continue. " + +printf "\n================================================================================\n" +printf "Creating the release on GitHub.\n" + +releaseAssets=( + "${signedBundlePath}" + "${universalApkPath}" + "${fdroidTargetPath}/app-fdroid-arm64-v8a-release-signed.apk" + "${fdroidTargetPath}/app-fdroid-armeabi-v7a-release-signed.apk" + "${fdroidTargetPath}/app-fdroid-x86-release-signed.apk" + "${fdroidTargetPath}/app-fdroid-x86_64-release-signed.apk" +) + +missingAsset=0 + +for releaseAsset in "${releaseAssets[@]}"; do + if [[ ! -f "${releaseAsset}" ]]; then + printf "Error: the file %s does not exist.\n" "${releaseAsset}" + missingAsset=1 + fi +done + +if [[ ${missingAsset} -ne 0 ]]; then + printf "Fatal: some files are missing, cannot create the GitHub release.\n" + exit 1 +fi + +printf "Creating the pre-release v%s and uploading the %d files, this can take a while...\n" "${version}" "${#releaseAssets[@]}" +gh release create "v${version}" \ + --repo element-hq/element-x-android \ + --title "Element X Android v${version}" \ + --generate-notes \ + --prerelease \ + --verify-tag \ + "${releaseAssets[@]}" + +if [[ $? -ne 0 ]]; then + printf "Fatal: error while creating the GitHub release.\n" + exit 1 +fi + +printf "The pre-release has been created: https://github.com/element-hq/element-x-android/releases/tag/v%s\n" "${version}" +printf "Please check the generated release notes, and optionally reorder items and fix typos.\n" +read -r -p "Press enter to continue. " + +printf "\n================================================================================\n" +printf "Update the project release notes:\n\n" + +printf "Getting the release notes from GitHub...\n" +releaseNotes=$(gh release view "v${version}" --repo element-hq/element-x-android --json body -q .body) + +if [[ $? -ne 0 || -z "${releaseNotes}" ]]; then + printf "Fatal: error while getting the release notes from GitHub.\n" + exit 1 +fi + +# GitHub returns the release notes with CRLF line endings, remove the CR. +releaseNotes="${releaseNotes//$'\r'/}" +changesTitle="Changes in Element X v${version}" +# Underline the title with as many '=' as there are characters in the title. +changesUnderline="${changesTitle//?/=}" +changesFile="./CHANGES.md" +changesFileBak="${changesFile}.bak" +mv "${changesFile}" "${changesFileBak}" +{ + printf "%s\n%s\n\n" "${changesTitle}" "${changesUnderline}" + printf "%s\n\n" "${releaseNotes}" + cat "${changesFileBak}" +} > "${changesFile}" +rm "${changesFileBak}" +printf "The file CHANGES.md has been updated.\n" +read -r -p "Please check the change and press enter to commit it. " + +printf "\n================================================================================\n" +printf "Committing...\n" +git commit -a -m "Changelog for version ${version}" + +printf "\n================================================================================\n" +read -r -p "Done, push the branch 'develop' (yes/no) default to yes? (A rebase may be necessary in case develop got new commits) " doPush +doPush=${doPush:-yes} + +if [ "${doPush}" == "yes" ]; then + printf "Pushing branch 'develop'...\n" + git push origin develop +else + printf "Not pushing, do not forget to push manually!\n" +fi + +printf "\n================================================================================\n" +printf "Message for the Android internal room:\n\n" +message="@room Element X Android ${version} is ready to be tested. You can get it from https://github.com/element-hq/element-x-android/releases/tag/v${version}. You can install the universal APK. If you want to install the application from the app bundle, you can follow instructions [here](https://github.com/element-hq/element-x-android/blob/develop/docs/install_from_github_release.md). Please report any feedback. Thanks!" +printf "%s\n\n" "${message}" + +if [[ -z "${elementBotToken}" ]]; then + read -r -p "ELEMENT_BOT_MATRIX_TOKEN is not defined in the environment. Cannot send the message for you. Please send it manually, and press enter to continue. " +else + read -r -p "Send this message to the room (yes/no) default to yes? " doSend + doSend=${doSend:-yes} + if [ "${doSend}" == "yes" ]; then + printf "Sending message...\n" + transactionId=$(openssl rand -hex 16) + # Element Android internal + matrixRoomId="!LiSLXinTDCsepePiYW:matrix.org" + curl -X PUT --data "{\"msgtype\":\"m.text\",\"body\":\"${message}\"}" -H "Authorization: Bearer ${elementBotToken}" https://matrix-client.matrix.org/_matrix/client/r0/rooms/${matrixRoomId}/send/m.room.message/\$local."${transactionId}" + else + printf "Message not sent, please send it manually!\n" + fi +fi + +printf "\n================================================================================\n" +printf "Congratulation! Kudos for using this script! Have a nice day!\n" +printf "================================================================================\n" diff --git a/tools/test/generateAllScreenshots.py b/tools/test/generateAllScreenshots.py index e10a7abb74..a4e3468d80 100755 --- a/tools/test/generateAllScreenshots.py +++ b/tools/test/generateAllScreenshots.py @@ -167,6 +167,7 @@ def generateJavascriptFile(): with open("screenshots/html/data.js", "w") as f: f.write("// Generated file, do not edit\n") + f.write("export const buildDate = %d;\n" % int(time.time())) f.write("export const screenshots = [\n") for line in data: f.write("[")