From 17365a50d17389c593f58624a9da6e20aba9172a Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:59:17 +0300 Subject: [PATCH 01/32] Keep saving files to Downloads once 32 files share the same name MediaStore de-duplicates the display name of a new file itself, but its uniquifier gives up after 32 collisions and throws IllegalStateException. Saving then failed with "Sorry, an error occurred" and wrote nothing, and because image.png is the name most screenshots arrive with, a user who saves media regularly could reach a state where that name never saved again without deleting files from Downloads by hand. A new saveWithUniqueFileName helper in androidutils retries the insert once with a suffixed name when the platform reports it cannot build a unique one; a second failure propagates so genuine errors still surface. withFileNameSuffix builds that name by inserting the suffix before the extension, and handles names without one. Both call sites are fixed: AndroidLocalMediaActions and DefaultFileSave carried byte-identical logic and therefore the identical defect. While in AndroidLocalMediaActions, a null output uri from ContentResolver.insert now throws instead of reporting success without writing anything. The pre-Android-Q path is untouched: it overwrites rather than uniquifying, so it never reaches this limit. Relates to https://github.com/element-hq/element-x-android/issues/6371 --- .../features/viewfolder/impl/file/FileSave.kt | 24 ++--- .../libraries/androidutils/file/File.kt | 35 ++++++++ .../libraries/androidutils/file/FileTest.kt | 89 +++++++++++++++++++ .../impl/local/AndroidLocalMediaActions.kt | 24 ++--- 4 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/file/FileTest.kt 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/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..dd8dc4eeb0 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,41 @@ 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" + } +} + +/** + * Runs [saveAs] with [fileName], and runs it once more with a suffixed name if the platform failed to + * build a unique name for it. + * + * `MediaStore` de-duplicates the display name of a new file itself, but gives up after 32 conflicts + * and throws `IllegalStateException("Failed to build unique file")`, which makes saving a file with a + * common name such as `image.png` fail for good. + * See https://github.com/element-hq/element-x-android/issues/6371 + */ +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/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) } } } From fbdb99019e8735832cd097f8be02325495beb547 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:40:20 +0300 Subject: [PATCH 02/32] Drop the MediaStore explanation from the save helper Review feedback: the retry itself is short enough to read, and the platform limit it works around belongs in the commit, not the source. The contract of the name helper keeps its one-line KDoc. --- .../element/android/libraries/androidutils/file/File.kt | 9 --------- 1 file changed, 9 deletions(-) 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 dd8dc4eeb0..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 @@ -55,15 +55,6 @@ fun String.withFileNameSuffix(suffix: String): String { } } -/** - * Runs [saveAs] with [fileName], and runs it once more with a suffixed name if the platform failed to - * build a unique name for it. - * - * `MediaStore` de-duplicates the display name of a new file itself, but gives up after 32 conflicts - * and throws `IllegalStateException("Failed to build unique file")`, which makes saving a file with a - * common name such as `image.png` fail for good. - * See https://github.com/element-hq/element-x-android/issues/6371 - */ fun saveWithUniqueFileName( fileName: String, uniqueSuffix: () -> String = { "_${System.currentTimeMillis()}" }, From 4558590492a263b554d2a03a68195b5ce9171e34 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:49:15 +0300 Subject: [PATCH 03/32] Cover both MediaStore save paths with a fake provider DefaultFileSave had no test at all and AndroidLocalMediaActions only exercised the failing path, so neither the uniquifying retry nor the new IOException on a null insert was ever executed. FakeMediaStoreContentProvider stands in for the media authority: it records the display names an insert is attempted with, keeps whatever is copied into it, and can either collide a name the way MediaStore does past 32 files or refuse to create anything. Both call sites now go through it for the plain save, the retry and the rejected insert. Its name cannot follow the Fake convention because it extends the framework ContentProvider, so it joins the existing exceptions in KonsistClassNameTest next to FakeFileSystem and FakeKeyStore. --- .../impl/file/DefaultFileSaveTest.kt | 60 ++++++++++++++++ .../local/AndroidLocalMediaActionsTest.kt | 41 +++++++++++ .../tests/konsist/KonsistClassNameTest.kt | 1 + .../fake/FakeMediaStoreContentProvider.kt | 72 +++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/DefaultFileSaveTest.kt create mode 100644 tests/testutils/src/main/kotlin/io/element/android/tests/testutils/fake/FakeMediaStoreContentProvider.kt 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/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/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 93b3200acc..ddb5ba95b3 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 @@ -104,6 +104,7 @@ class KonsistClassNameTest { "FakeImageLoader", "FakeKeyStore", "FakeListenableFuture", + "FakeMediaStoreContentProvider", ) .assertTrue { val interfaceName = it.name 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..4a1ffcda29 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/fake/FakeMediaStoreContentProvider.kt @@ -0,0 +1,72 @@ +/* + * 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 +import java.nio.file.Files + +/** + * 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 = Files.createTempDirectory("fake-media-store").toFile() + + 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) + } +} From 59b26173a750f450d5f85ec4888de57e0abcde97 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:36:51 +0300 Subject: [PATCH 04/32] Create the fake MediaStore directory without java.nio.file Files.createTempDirectory needs API 26 and the fake lives in the main source set of tests:testutils, so lint failed it against the minimum of 24. File.createTempFile has been available since API 1 and still gives each provider instance its own directory. --- .../tests/testutils/fake/FakeMediaStoreContentProvider.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 4a1ffcda29..13077db51f 100644 --- 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 @@ -17,7 +17,6 @@ import android.provider.MediaStore import androidx.test.core.app.ApplicationProvider import org.robolectric.shadows.ShadowContentResolver import java.io.File -import java.nio.file.Files /** * Stands in for the MediaStore provider: it records the display names an insert is attempted with, @@ -25,7 +24,10 @@ import java.nio.file.Files * way MediaStore does once a directory already holds 32 files with the same name. */ class FakeMediaStoreContentProvider : ContentProvider() { - private val directory: File = Files.createTempDirectory("fake-media-store").toFile() + private val directory: File = File.createTempFile("fake-media-store", null).apply { + delete() + mkdir() + } val insertedDisplayNames = mutableListOf() From 2ff6a56e7df3e4642f7ffc81416b95df2a24cf77 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:12:15 +0000 Subject: [PATCH 05/32] Use the new SDK thread info for redacted messages --- .../impl/timeline/groups/Groupability.kt | 2 +- .../impl/timeline/TimelinePresenterTest.kt | 4 +- .../factories/TimelineItemsFactoryTest.kt | 40 +++++++++++++++++++ .../RedactedVoiceMessageManagerTest.kt | 4 +- .../DefaultPinnedMessagesBannerFormatter.kt | 2 +- .../impl/DefaultRoomLatestEventFormatter.kt | 2 +- .../impl/DefaultTimelineEventFormatter.kt | 2 +- ...efaultPinnedMessagesBannerFormatterTest.kt | 2 +- .../DefaultRoomLatestEventFormatterTest.kt | 2 +- .../api/timeline/item/event/EventContent.kt | 4 +- .../timeline/item/event/EventTimelineItem.kt | 1 + .../item/event/TimelineEventContentMapper.kt | 2 +- .../matrix/test/timeline/TimelineFixture.kt | 5 +++ .../reply/InReplyToDetailsProvider.kt | 2 +- .../messages/reply/InReplyToMetadataKtTest.kt | 2 +- .../impl/datasource/EventItemFactory.kt | 2 +- .../impl/datasource/EventItemFactoryTest.kt | 4 +- 17 files changed, 65 insertions(+), 17 deletions(-) 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/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 3b0794305f..0db690f928 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 @@ -47,7 +47,6 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction 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 @@ -65,6 +64,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 @@ -876,7 +876,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 53c39ee530..094fa5764e 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,10 +12,14 @@ 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.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.test.A_THREAD_ID 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.aRedactedContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -82,6 +86,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)) + } + private suspend fun TestScope.groupPositionsOf(timestamps: List): List { val factory = aTimelineItemsFactory( config = TimelineItemsFactoryConfig( 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/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/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/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/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/messages/reply/InReplyToDetailsProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt index b335ef9609..0b7db84ae1 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt @@ -117,7 +117,7 @@ class InReplyToDetailsDisambiguatedProvider : InReplyToDetailsProvider() { class InReplyToDetailsInformativeProvider : InReplyToDetailsProvider() { 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/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/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, ), From 9586478039fec2d39697c5b759364d83cebe522b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 24 Aug 2026 18:58:54 +0200 Subject: [PATCH 06/32] Updates to text sizes in Chats header and Space hero Closes #7476 --- .../home/impl/components/HomeTopBar.kt | 103 ++++++++++-------- .../atomic/atoms/RoomPreviewTitleAtom.kt | 4 +- .../ui/components/SpaceHeaderRootView.kt | 2 +- .../matrix/ui/components/SpaceHeaderView.kt | 9 +- 4 files changed, 67 insertions(+), 51 deletions(-) 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..1a351be9b6 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 @@ -80,7 +80,6 @@ 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.theme.components.DropdownMenu import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem import io.element.android.libraries.designsystem.theme.components.Icon @@ -141,15 +140,23 @@ 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 + } + Text( + modifier = Modifier.semantics { + heading() + }, + style = style, + text = displayTitle, + ) } - Text( - modifier = Modifier.semantics { - heading() - }, - style = ElementTheme.typography.aliasScreenTitle, - text = displayTitle, - ) }, navigationIcon = { NavigationIcon( @@ -179,7 +186,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 +357,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/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/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, ) } }, From 95866f960eccb722414800f1cb57e891e74b2d0a Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 24 Aug 2026 17:16:01 +0000 Subject: [PATCH 07/32] Update screenshots --- ...s.home.impl.components_HomeTopBarMultiAccount_Day_0_en.png | 4 ++-- ...home.impl.components_HomeTopBarMultiAccount_Night_0_en.png | 4 ++-- ...eatures.home.impl.components_HomeTopBarSpaces_Day_0_en.png | 4 ++-- ...tures.home.impl.components_HomeTopBarSpaces_Night_0_en.png | 4 ++-- ....home.impl.components_HomeTopBarWithIndicator_Day_0_en.png | 4 ++-- ...ome.impl.components_HomeTopBarWithIndicator_Night_0_en.png | 4 ++-- ...res.home.impl.components_HomeTopBarWithStatus_Day_0_en.png | 4 ++-- ...s.home.impl.components_HomeTopBarWithStatus_Night_0_en.png | 4 ++-- .../features.home.impl.components_HomeTopBar_Day_0_en.png | 4 ++-- .../features.home.impl.components_HomeTopBar_Night_0_en.png | 4 ++-- .../features.home.impl.spaces_HomeSpacesView_Day_0_en.png | 4 ++-- .../features.home.impl.spaces_HomeSpacesView_Day_1_en.png | 4 ++-- .../features.home.impl.spaces_HomeSpacesView_Night_0_en.png | 4 ++-- .../features.home.impl.spaces_HomeSpacesView_Night_1_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeViewA11y_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_0_en.png | 4 ++-- .../images/features.home.impl_HomeView_Day_10_en.png | 4 ++-- .../images/features.home.impl_HomeView_Day_11_en.png | 4 ++-- .../images/features.home.impl_HomeView_Day_13_en.png | 4 ++-- .../images/features.home.impl_HomeView_Day_14_en.png | 4 ++-- .../images/features.home.impl_HomeView_Day_15_en.png | 4 ++-- .../images/features.home.impl_HomeView_Day_16_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_1_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_2_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_3_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_4_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_5_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_6_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_7_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_8_en.png | 4 ++-- .../snapshots/images/features.home.impl_HomeView_Day_9_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_0_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_10_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_11_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_13_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_14_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_15_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_16_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_1_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_2_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_3_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_4_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_5_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_6_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_7_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_8_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_9_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Day_0_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Day_1_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Day_2_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Day_3_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Day_4_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Day_5_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Night_0_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Night_1_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Night_2_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Night_3_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Night_4_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Night_5_en.png | 4 ++-- ...ries.matrix.ui.components_SpaceHeaderRootView_Day_0_en.png | 4 ++-- ...es.matrix.ui.components_SpaceHeaderRootView_Night_0_en.png | 4 ++-- ...ibraries.matrix.ui.components_SpaceHeaderView_Day_0_en.png | 4 ++-- ...raries.matrix.ui.components_SpaceHeaderView_Night_0_en.png | 4 ++-- 63 files changed, 126 insertions(+), 126 deletions(-) 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_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_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..559a928a97 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:7a694440a4ac9db68a24ef77f0502b3936370974b1f6932c49accb6736eb5566 +size 39875 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..cbe5cf415b 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:c748d1ae791d77ddfed18024a4ea04188adba1ac00aa43b5faa7660153cc9209 +size 34734 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.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.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 From 47f4e136ddbe0835316fded54447843590f04e87 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 25 Aug 2026 10:22:31 +0200 Subject: [PATCH 08/32] Use fontHeadingMdBold for empty space title. --- .../element/android/features/home/impl/spaces/HomeSpacesView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, ) From c62ff733fbad8a54ea530980c34ad9ab7f1d64cc Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 25 Aug 2026 08:37:53 +0000 Subject: [PATCH 09/32] Update screenshots --- .../features.home.impl.spaces_HomeSpacesView_Day_2_en.png | 4 ++-- .../features.home.impl.spaces_HomeSpacesView_Night_2_en.png | 4 ++-- .../images/features.home.impl_HomeView_Day_16_en.png | 4 ++-- .../images/features.home.impl_HomeView_Night_16_en.png | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) 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_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_HomeView_Day_16_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl_HomeView_Day_16_en.png index 559a928a97..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:7a694440a4ac9db68a24ef77f0502b3936370974b1f6932c49accb6736eb5566 -size 39875 +oid sha256:2e2d1b5923dd2941bd7e60a9019a7770ed3383529ad9a8f3572c9e3d450310e0 +size 37461 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 cbe5cf415b..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:c748d1ae791d77ddfed18024a4ea04188adba1ac00aa43b5faa7660153cc9209 -size 34734 +oid sha256:858ed6993ba8d322e9acc40ca3e759372e19cd3aae9854709f374c0c8d1c4429 +size 32549 From 0634cadc9ccb971994527a19e17e74f6c054c4ce Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 25 Aug 2026 11:36:23 +0200 Subject: [PATCH 10/32] Ensure translation of "Chats" does not render in big font when displayed on 2 lines. They can add the end be displayed on one line using the small font. --- .../home/impl/components/HomeTopBar.kt | 11 +- .../designsystem/text/AdaptativeTitle.kt | 144 ++++++++++++++++++ 2 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AdaptativeTitle.kt 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 1a351be9b6..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,6 +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.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 @@ -149,12 +148,10 @@ fun HomeTopBar( // "Chats" else -> ElementTheme.typography.fontHeadingLgBold } - Text( - modifier = Modifier.semantics { - heading() - }, + AdaptativeTitle( + title = displayTitle, style = style, - text = displayTitle, + twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium, ) } }, 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, + ) + } + } +} From 806f7c500c942f0c2f714da81f3187812c7865c5 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 25 Aug 2026 10:09:30 +0000 Subject: [PATCH 11/32] Update screenshots --- .../libraries.designsystem.text_AdaptativeTitle_Day_0_en.png | 3 +++ ...libraries.designsystem.text_AdaptativeTitle_Night_0_en.png | 3 +++ ...eme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png | 4 ++-- ...e.components_HorizontalFloatingToolbarNoFab_Night_0_en.png | 4 ++-- ...em.theme.components_HorizontalFloatingToolbar_Day_0_en.png | 4 ++-- ....theme.components_HorizontalFloatingToolbar_Night_0_en.png | 4 ++-- 6 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.text_AdaptativeTitle_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.text_AdaptativeTitle_Night_0_en.png 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 From c893657aa01897b635f14510a9ee80f9f84ed103 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:30:49 +0300 Subject: [PATCH 12/32] Document how the Element Call base URL developer option is used The developer option passes its value through to the widget URL verbatim, so it has to be the URL of the page that joins a room rather than the deployment root. That is not obvious from the field, and hughns asked for it to be written down in docs/ rather than expanded inline in the app, because the path depends on how the deployment was hosted. Part of #4475. --- docs/element_call.md | 53 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/element_call.md 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. From 30c18b8a008208d9d252bf79a59326a9447e8a37 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:51:27 +0300 Subject: [PATCH 13/32] Say when the server cannot be reached instead of showing nothing While the homeserver is unreachable but the device still has a working network, the app showed no indication at all: the syncing chip is hidden as soon as the SDK reports the session offline, and the offline banner only appears when the device itself has no connectivity. A blocked or down server was therefore indistinguishable from a quiet, healthy one. The chip now has three states rather than a boolean, and the third says "Server unreachable" for exactly that case. When the device has no network the chip stays hidden, because the offline banner already says so and two overlapping messages would be worse than one. Part of #4099 --- .../appnav/loggedin/LoggedInPresenter.kt | 15 ++++++++-- .../android/appnav/loggedin/LoggedInState.kt | 8 ++++- .../loggedin/LoggedInStatePreviewParam.kt | 7 +++-- .../android/appnav/loggedin/LoggedInView.kt | 2 +- .../android/appnav/loggedin/SyncStateView.kt | 28 +++++++++++++---- .../appnav/loggedin/LoggedInPresenterTest.kt | 30 ++++++++++++++++--- 6 files changed, 72 insertions(+), 18 deletions(-) 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() } } From 65c2cd8a79855ce9be7f0c1a6b845fc10f1f7b93 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:55:38 +0300 Subject: [PATCH 14/32] Drop the sender's name from the room list preview of two-person rooms The room list preview prefixes the sender's name unless the room is a direct message. A room that was created as a group and later converted to a direct message is not marked as one by the server, so its preview kept naming the only other person in it on every message, while iOS did not. The prefix now depends on how many people are actually in the room rather than on the direct message flag, which is the behaviour asked for in the issue: no name while only two people can be talking, the name back as soon as a third joins. Part of #1548 --- .../datasource/RoomListRoomSummaryFactory.kt | 7 ++++- .../RoomListRoomSummaryFactoryTest.kt | 26 +++++++++++++++++++ .../test/FakeRoomLatestEventFormatter.kt | 5 ++++ 3 files changed, 37 insertions(+), 1 deletion(-) 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/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/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 } From 69ba9cf21e96b75de05e0bb4ace4d386771b0981 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 25 Aug 2026 18:10:19 +0000 Subject: [PATCH 15/32] Update screenshots --- .../images/appnav.loggedin_LoggedInView_Day_2_en.png | 4 ++-- .../images/appnav.loggedin_LoggedInView_Day_3_en.png | 4 ++-- .../images/appnav.loggedin_LoggedInView_Day_4_en.png | 4 ++-- .../images/appnav.loggedin_LoggedInView_Day_5_en.png | 4 ++-- .../images/appnav.loggedin_LoggedInView_Day_6_en.png | 3 +++ .../images/appnav.loggedin_LoggedInView_Night_2_en.png | 4 ++-- .../images/appnav.loggedin_LoggedInView_Night_3_en.png | 4 ++-- .../images/appnav.loggedin_LoggedInView_Night_4_en.png | 4 ++-- .../images/appnav.loggedin_LoggedInView_Night_5_en.png | 4 ++-- .../images/appnav.loggedin_LoggedInView_Night_6_en.png | 3 +++ ...ppnav.loggedin_SyncStateViewServerUnreachable_Day_0_en.png | 3 +++ ...nav.loggedin_SyncStateViewServerUnreachable_Night_0_en.png | 3 +++ 12 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Day_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/appnav.loggedin_LoggedInView_Night_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/appnav.loggedin_SyncStateViewServerUnreachable_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/appnav.loggedin_SyncStateViewServerUnreachable_Night_0_en.png 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 From 362acd6cab8c6b8a1f28ff9c8dd4f6d14860adb4 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:41:17 +0300 Subject: [PATCH 16/32] Run the database migrations again when a database is upgraded Upgrading over an older install crashed before the login screen with `SQLiteException: no such column: SessionData.position`. The session, cache and push databases were opened at their new schema version but their tables were never migrated. `SqlCipherDriverFactory` overrides `AndroidSqliteDriver.Callback.onUpgrade` so the database key can be replaced, and never called through to the base implementation - which is the only thing that runs `schema.migrate`. Every SQLDelight migration in the app was skipped as a result. The override now calls `super.onUpgrade` after the re-key, so a database that crosses the re-key version is still re-encrypted before its tables are migrated. It moved out of an anonymous object into a named `MigrationCallback` so the behaviour can be tested. Part of #7509 --- libraries/encrypted-db/build.gradle.kts | 4 ++ .../encrypteddb/SqlCipherDriverFactory.kt | 30 +++++++--- .../encrypteddb/MigrationCallbackTest.kt | 55 +++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 libraries/encrypted-db/src/test/kotlin/io/element/encrypteddb/MigrationCallbackTest.kt 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 + } +} From 594a5612ad70aa730e7c458033b38da7ae82098a Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:45:59 +0300 Subject: [PATCH 17/32] Register the second sync state preview with konsist --- .../io/element/android/tests/konsist/KonsistPreviewTest.kt | 1 + 1 file changed, 1 insertion(+) 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", From 4290a9c3b3b2b6251229c8f89bb997a055bf849e Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:03:14 +0300 Subject: [PATCH 18/32] Keep the formatting of a caption that is being edited Editing the caption of a media message dropped all of its formatting: bold, links and the rest came back as plain text, and saving wrote that plain text over the formatted caption. The composer was being opened on the plain caption. Editing a message body already starts from its HTML when rich text editing is on, and the caption path was the only one that did not - it read `captionOrNull()` and never had the caption's HTML to read, because the timeline content only kept the already-parsed `formattedCaption`. Media contents now carry the caption's HTML alongside the parsed one, the same way text contents carry `htmlBody`, and editing a caption starts from it when rich text editing is on. The save path already sent the composer's HTML, so nothing changes there. Part of #6730 --- .../messages/impl/MessagesPresenter.kt | 10 +++++-- .../TimelineItemContentMessageFactory.kt | 8 ++++++ .../event/TimelineItemAttachmentsContent.kt | 1 + .../model/event/TimelineItemAudioContent.kt | 1 + .../model/event/TimelineItemEventContent.kt | 10 +++++++ .../model/event/TimelineItemFileContent.kt | 1 + .../model/event/TimelineItemGalleryContent.kt | 1 + .../model/event/TimelineItemImageContent.kt | 1 + .../TimelineItemImageContentPreviewParam.kt | 2 ++ .../model/event/TimelineItemStickerContent.kt | 1 + .../model/event/TimelineItemVideoContent.kt | 1 + .../model/event/TimelineItemVoiceContent.kt | 1 + .../messages/impl/MessagesPresenterTest.kt | 27 +++++++++++++++++++ .../TimelineItemContentMessageFactoryTest.kt | 2 ++ 14 files changed, 65 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 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/model/event/TimelineItemAttachmentsContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt index 66d28598a1..dd3578ea61 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt @@ -14,6 +14,7 @@ data class TimelineItemAttachmentsContent( val body: String, val caption: String?, val formattedCaption: CharSequence?, + val htmlCaption: String? = null, override val isEdited: Boolean, val attachments: ImmutableList, ) : 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/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"), From 0801a415b59d89504a1815601eca8730c9de0dfc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 26 Aug 2026 12:11:04 +0200 Subject: [PATCH 19/32] Add a FileViewerEntryPoint to render in-memory content as a file Introduce io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint, a reusable screen which renders content that is not a Matrix media the same way the media viewer renders a file, with Share and Download actions. The caller provides a filename, a mime type and the whole content as a String. The node writes it to a temporary file in the cache directory, then reuses LocalMediaView (so text content goes through TextFileViewer) and LocalMediaActions for sharing and saving on disk. The node is contributed to AppScope so that the screen can be used from any graph, since MediaViewerNode itself is bound to RoomScope and depends on the timeline data sources. Also route MimeTypes.Json to TextFileView in LocalMediaView, so that a json file is rendered as text instead of falling back to the generic file card. Co-Authored-By: Claude Opus 5 (1M context) --- .../mediaviewer/api/FileViewerEntryPoint.kt | 44 ++++++ .../fileviewer/DefaultFileViewerEntryPoint.kt | 30 ++++ .../impl/fileviewer/FileViewerEvent.kt | 13 ++ .../impl/fileviewer/FileViewerNode.kt | 47 ++++++ .../impl/fileviewer/FileViewerPresenter.kt | 143 ++++++++++++++++++ .../impl/fileviewer/FileViewerState.kt | 19 +++ .../fileviewer/FileViewerStatePreviewParam.kt | 62 ++++++++ .../impl/fileviewer/FileViewerView.kt | 134 ++++++++++++++++ .../mediaviewer/impl/local/LocalMediaView.kt | 2 +- .../DefaultFileViewerEntryPointTest.kt | 73 +++++++++ .../fileviewer/FileViewerPresenterTest.kt | 100 ++++++++++++ .../impl/fileviewer/FileViewerViewTest.kt | 87 +++++++++++ .../test/FakeFileViewerEntryPoint.kt | 22 +++ 13 files changed, 775 insertions(+), 1 deletion(-) create mode 100644 libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/FileViewerEntryPoint.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPoint.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerEvent.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerNode.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenter.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerState.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerStatePreviewParam.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPointTest.kt create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenterTest.kt create mode 100644 libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerViewTest.kt create mode 100644 libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeFileViewerEntryPoint.kt 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/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/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/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/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() +} From 373d2f1eb5c13fb8599f121315bbbd65e375eadf Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 26 Aug 2026 12:11:16 +0200 Subject: [PATCH 20/32] Add a Push rules entry to the developer options Add a "Push rules" item in the Notifications section of the developer options, above "Push history". It displays the raw push rules of the account, which were only available as a rageshake attachment so far. The content is read using NotificationSettingsService.getRawPushRules(), then rendered by the new FileViewerEntryPoint, so that the screen looks like the media viewer rendering a file, with Share and Download actions on a push_rules.json file. The user id contains a colon, which is not a valid file name character on all the file systems, so it is replaced by an underscore. Co-Authored-By: Claude Opus 5 (1M context) --- features/preferences/impl/build.gradle.kts | 2 + .../preferences/impl/PreferencesFlowNode.kt | 30 ++++++++++ .../impl/developer/DeveloperSettingsEvent.kt | 2 + .../developer/DeveloperSettingsNavigator.kt | 15 +++++ .../impl/developer/DeveloperSettingsNode.kt | 11 +++- .../developer/DeveloperSettingsPresenter.kt | 43 ++++++++++++++- .../impl/developer/DeveloperSettingsState.kt | 5 +- .../DeveloperSettingsStatePreviewParam.kt | 5 ++ .../impl/developer/DeveloperSettingsView.kt | 24 +++++++- .../impl/DefaultPreferencesEntryPointTest.kt | 2 + .../DeveloperSettingsPresenterTest.kt | 55 +++++++++++++++++++ .../developer/DeveloperSettingsViewTest.kt | 13 +++++ 12 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNavigator.kt diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index e647ebb919..80ac4b3ba3 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) @@ -108,6 +109,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..9b523d1c34 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 @@ -34,13 +36,15 @@ 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 -@Inject +@AssistedInject class DeveloperSettingsPresenter( + @Assisted private val navigator: DeveloperSettingsNavigator, private val appDeveloperSettingsPresenter: Presenter, private val sessionId: SessionId, private val deviceId: DeviceId, @@ -52,7 +56,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 +77,9 @@ class DeveloperSettingsPresenter( val markAllRoomsAsReadAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + val pushRulesAction = remember { + mutableStateOf>(AsyncAction.Uninitialized) + } var showColorPicker by remember { mutableStateOf(false) } @@ -109,6 +122,10 @@ class DeveloperSettingsPresenter( DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation -> { markAllRoomsAsReadAction.value = AsyncAction.Uninitialized } + DeveloperSettingsEvent.OpenPushRules -> coroutineScope.openPushRules(pushRulesAction) + DeveloperSettingsEvent.DismissPushRulesError -> { + pushRulesAction.value = AsyncAction.Uninitialized + } } } @@ -119,6 +136,7 @@ class DeveloperSettingsPresenter( databaseSizes = databaseSizes.value, clearCacheAction = clearCacheAction.value, markAllRoomsAsReadAction = markAllRoomsAsReadAction.value, + pushRulesAction = pushRulesAction.value, isEnterpriseBuild = buildMeta.isEnterpriseBuild, showColorPicker = showColorPicker, deviceId = deviceId, @@ -164,4 +182,25 @@ 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(), + ) + } + .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" } 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..66f96e5eb3 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,9 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +private const val A_PUSH_RULES_CONTENT = "{\"global\":{}}" +private val AN_EXCEPTION = Exception("A failure") + class DeveloperSettingsPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -170,7 +176,53 @@ 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), + ) + } + } + + @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 +232,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 +247,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 { From 2fcf806f59de756c10e4b4a02666a9f53b1086a1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 26 Aug 2026 12:55:07 +0200 Subject: [PATCH 21/32] Pretty print the push rules content before displaying it The raw push rules returned by the SDK are a single long line of json, which is hard to read on a phone. Re-encode the content with a pretty printer using a two space indentation before handing it to the file viewer. Parsing to a JsonElement and re-encoding preserves the order of the keys, so the rules are still listed in the order the server returned them. Content which cannot be parsed is passed through unchanged, so that a malformed payload remains viewable. Co-Authored-By: Claude Opus 5 (1M context) --- features/preferences/impl/build.gradle.kts | 1 + .../developer/DeveloperSettingsPresenter.kt | 21 +++++++++++- .../DeveloperSettingsPresenterTest.kt | 33 +++++++++++++++++-- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 80ac4b3ba3..12554eb6fa 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -92,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) 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 9b523d1c34..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 @@ -32,6 +32,7 @@ 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 @@ -41,6 +42,9 @@ 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 @AssistedInject class DeveloperSettingsPresenter( @@ -190,7 +194,7 @@ class DeveloperSettingsPresenter( pushRulesAction.value = AsyncAction.Uninitialized navigator.openPushRules( filename = pushRulesFilename(), - content = content.orEmpty(), + content = content.orEmpty().prettyPrintJson(), ) } .onFailure { @@ -204,3 +208,18 @@ class DeveloperSettingsPresenter( */ 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/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 66f96e5eb3..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 @@ -44,7 +44,17 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -private const val A_PUSH_RULES_CONTENT = "{\"global\":{}}" +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 { @@ -195,7 +205,26 @@ class DeveloperSettingsPresenterTest { } openPushRulesLambda.assertions().isCalledOnce().with( value("push_rules@alice_server.org.json"), - value(A_PUSH_RULES_CONTENT), + 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"), ) } } From 0e93a943a5bfa9f8778d8d0231275bb723912bbc Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 26 Aug 2026 11:16:07 +0000 Subject: [PATCH 22/32] Update screenshots --- ...ferences.impl.developer_DeveloperSettingsView_Day_3_en.png | 3 +++ ...rences.impl.developer_DeveloperSettingsView_Night_3_en.png | 3 +++ ...es.preferences.impl.userstatus_UserStatusView_Day_5_en.png | 4 ++-- ...es.preferences.impl.userstatus_UserStatusView_Day_6_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_5_en.png | 4 ++-- ....preferences.impl.userstatus_UserStatusView_Night_6_en.png | 4 ++-- ...embermoderation.impl_RoomMemberModerationView_Day_5_en.png | 4 ++-- ...embermoderation.impl_RoomMemberModerationView_Day_7_en.png | 4 ++-- ...bermoderation.impl_RoomMemberModerationView_Night_5_en.png | 4 ++-- ...bermoderation.impl_RoomMemberModerationView_Night_7_en.png | 4 ++-- ...eme.components_HorizontalFloatingToolbarNoFab_Day_0_en.png | 4 ++-- ...e.components_HorizontalFloatingToolbarNoFab_Night_0_en.png | 4 ++-- ...em.theme.components_HorizontalFloatingToolbar_Day_0_en.png | 4 ++-- ....theme.components_HorizontalFloatingToolbar_Night_0_en.png | 4 ++-- ...es.mediaviewer.impl.fileviewer_FileViewerView_Day_0_en.png | 3 +++ ...es.mediaviewer.impl.fileviewer_FileViewerView_Day_1_en.png | 3 +++ ...es.mediaviewer.impl.fileviewer_FileViewerView_Day_2_en.png | 3 +++ ....mediaviewer.impl.fileviewer_FileViewerView_Night_0_en.png | 3 +++ ....mediaviewer.impl.fileviewer_FileViewerView_Night_1_en.png | 3 +++ ....mediaviewer.impl.fileviewer_FileViewerView_Night_2_en.png | 3 +++ 20 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.preferences.impl.developer_DeveloperSettingsView_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.fileviewer_FileViewerView_Night_2_en.png 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/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.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 From 267e48aa697bd8c5c280cffcf3ea76287ea77437 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 26 Aug 2026 13:48:11 +0200 Subject: [PATCH 23/32] Add instructions to let the CI record the screenshot if necessary. Also update instruction about PR size. --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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. --- From 94259962a07517722ce8e7ad47b1fc9d6526324a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 26 Aug 2026 13:56:12 +0200 Subject: [PATCH 24/32] Setting version for the release 26.08.3 --- plugins/src/main/kotlin/Versions.kt | 2 +- tools/manifest/gplay/release/aaptDump.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 89ad4a63fc..3e4b4f5c7f 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 = 3 object Versions { /** diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt index 7eae1a5a78..457a6ea1cc 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='202608030' versionName='26.08.3' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' sdkVersion:'24' targetSdkVersion:'37' uses-permission: name='android.permission.POST_NOTIFICATIONS' From cbc7de9982800b3da92992a965857be28f8dceae Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 26 Aug 2026 13:56:41 +0200 Subject: [PATCH 25/32] Adding fastlane file for version 26.08.3 --- fastlane/metadata/android/en-US/changelogs/202608030.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelogs/202608030.txt 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 From 0e86de4e584811d5ea9755d52184cfd4e466e530 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 26 Aug 2026 15:35:00 +0200 Subject: [PATCH 26/32] Changelog for version 26.08.3 --- CHANGES.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index db18e59251..a56dd4d1bb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,67 @@ +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 ============================= From c5f70de9043831d15e595802864f02b3e5f05883 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 26 Aug 2026 17:26:23 +0200 Subject: [PATCH 27/32] Add releaseV2.sh, a release script automating the GitHub release releaseV2.sh is a copy of release.sh in which the GitHub CLI is mandatory and does what was previously done by hand: it finds and waits for the release.yml run, downloads and extracts its artifacts, creates the pre-release with its 6 assets and the generated release notes, and prepends those notes to CHANGES.md. It also compares the version code and version name of the signed artifacts to the expected values instead of asking the user to check them, and no longer needs ELEMENT_GITHUB_TOKEN, since the CLI uses its own credentials. release.sh is left untouched, so that the next release can be done with releaseV2.sh while keeping a way back if something goes wrong. The installation documentation is also fixed, as it referred to an asset name that does not exist anymore. Co-Authored-By: Claude Opus 5 (1M context) --- docs/install_from_github_release.md | 6 +- tools/release/releaseV2.sh | 541 ++++++++++++++++++++++++++++ 2 files changed, 544 insertions(+), 3 deletions(-) create mode 100755 tools/release/releaseV2.sh 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/tools/release/releaseV2.sh b/tools/release/releaseV2.sh new file mode 100755 index 0000000000..071d416d00 --- /dev/null +++ b/tools/release/releaseV2.sh @@ -0,0 +1,541 @@ +#!/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 flow release finish "${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" From 3758aac6d3c64e8b61c11da1704f6a8135e12c4c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 27 Aug 2026 08:52:44 +0200 Subject: [PATCH 28/32] Do not build the enterprise build, it's built elsewhere. --- .github/workflows/release.yml | 38 ----------------------------------- 1 file changed, 38 deletions(-) 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 From 6fbccbc8972d660282b7540167858e436bbe6c54 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 27 Aug 2026 12:03:32 +0200 Subject: [PATCH 29/32] Update submodule ref. --- enterprise | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise b/enterprise index e633a343b1..53750903f2 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit e633a343b12088b1fe3fd6b27ec319c1f7f2931c +Subproject commit 53750903f229a410ec4dded2bb6357431a55662e From 2f3d197060ef7d83848fe31f4e7620479d78abae Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 27 Aug 2026 14:20:36 +0200 Subject: [PATCH 30/32] Setting version for the release 26.08.4 --- plugins/src/main/kotlin/Versions.kt | 2 +- tools/manifest/gplay/release/aaptDump.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 3e4b4f5c7f..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 = 3 +private const val versionReleaseNumber = 4 object Versions { /** diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt index 457a6ea1cc..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='202608030' versionName='26.08.3' 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' From 383519aff5b13597955b62470218de21007ad171 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 27 Aug 2026 14:21:17 +0200 Subject: [PATCH 31/32] Adding fastlane file for version 26.08.4 --- fastlane/metadata/android/en-US/changelogs/202608040.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelogs/202608040.txt 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 From 4996819812cee98a995123734c2ffc2ba1c0cf1b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 27 Aug 2026 14:53:48 +0200 Subject: [PATCH 32/32] Changelog for version 26.08.4 --- CHANGES.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index a56dd4d1bb..5f4be5d6eb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,23 @@ +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 =============================