mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 04:15:12 +00:00
Merge branch 'develop' into fix/1874-empty-formatted-body
This commit is contained in:
+26
@@ -42,6 +42,32 @@ fun File.safeRenameTo(dest: File) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this file name with [suffix] inserted before its extension, i.e. `"image.png"` with the
|
||||
* suffix `"_1"` gives `"image_1.png"`. A name without extension simply gets the suffix appended.
|
||||
*/
|
||||
fun String.withFileNameSuffix(suffix: String): String {
|
||||
val extension = substringAfterLast('.', "")
|
||||
return if (extension.isEmpty()) {
|
||||
"$this$suffix"
|
||||
} else {
|
||||
"${substringBeforeLast('.')}$suffix.$extension"
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> 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() }
|
||||
|
||||
+89
@@ -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<String>()
|
||||
|
||||
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<String>()
|
||||
|
||||
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<String>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.core.log.logger
|
||||
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Wraps a list of strings in a box with borders.
|
||||
* Allow to print pretty logs.
|
||||
*
|
||||
* @param minBoxInsideWidth The minimum width of the box inside, default is 80.
|
||||
* @return A list of strings representing the boxed lines.
|
||||
*/
|
||||
fun List<String>.wrapInBox(
|
||||
minBoxInsideWidth: Int = 80,
|
||||
): List<String> {
|
||||
val maxLength = maxOfOrNull { it.length } ?: 0
|
||||
val boxWidth = max(maxLength, minBoxInsideWidth) + 4
|
||||
val topBorder = "┌" + "─".repeat(boxWidth - 2) + "┐"
|
||||
val bottomBorder = "└" + "─".repeat(boxWidth - 2) + "┘"
|
||||
val boxedLines = map { line ->
|
||||
val padding = " ".repeat(max(0, boxWidth - line.length - 4))
|
||||
"│ $line$padding │"
|
||||
}
|
||||
return listOf(topBorder) + boxedLines + listOf(bottomBorder)
|
||||
}
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+144
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+23
-7
@@ -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<QueryResult.Value<Unit>>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -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<String>()
|
||||
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<QueryResult.Value<Unit>> {
|
||||
val migrations = mutableListOf<Pair<Long, Long>>()
|
||||
|
||||
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<Unit> {
|
||||
migrations.add(oldVersion to newVersion)
|
||||
onMigrate()
|
||||
return QueryResult.Unit
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -56,7 +56,7 @@ class DefaultPinnedMessagesBannerFormatter(
|
||||
is PollContent -> {
|
||||
content.question.prefixWith(CommonStrings.a11y_poll)
|
||||
}
|
||||
RedactedContent -> {
|
||||
is RedactedContent -> {
|
||||
sp.getString(CommonStrings.common_message_removed)
|
||||
}
|
||||
else -> {
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ class DefaultTimelineEventFormatter(
|
||||
is CallNotifyContent -> {
|
||||
sp.getString(CommonStrings.common_call_started)
|
||||
}
|
||||
RedactedContent,
|
||||
is RedactedContent,
|
||||
is LegacyCallInviteContent,
|
||||
is StickerContent,
|
||||
is PollContent,
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+5
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@
|
||||
|
||||
package io.element.android.libraries.fullscreenintent.api
|
||||
|
||||
sealed interface FullScreenIntentPermissionsEvents {
|
||||
data object Dismiss : FullScreenIntentPermissionsEvents
|
||||
data object OpenSettings : FullScreenIntentPermissionsEvents
|
||||
sealed interface FullScreenIntentPermissionsEvent {
|
||||
data object Dismiss : FullScreenIntentPermissionsEvent
|
||||
data object OpenSettings : FullScreenIntentPermissionsEvent
|
||||
}
|
||||
+1
-1
@@ -11,5 +11,5 @@ package io.element.android.libraries.fullscreenintent.api
|
||||
data class FullScreenIntentPermissionsState(
|
||||
val permissionGranted: Boolean,
|
||||
val shouldDisplayBanner: Boolean,
|
||||
val eventSink: (FullScreenIntentPermissionsEvents) -> Unit,
|
||||
val eventSink: (FullScreenIntentPermissionsEvent) -> Unit,
|
||||
)
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ package io.element.android.libraries.fullscreenintent.api
|
||||
fun aFullScreenIntentPermissionsState(
|
||||
permissionGranted: Boolean = true,
|
||||
shouldDisplay: Boolean = false,
|
||||
eventSink: (FullScreenIntentPermissionsEvents) -> Unit = {},
|
||||
eventSink: (FullScreenIntentPermissionsEvent) -> Unit = {},
|
||||
) = FullScreenIntentPermissionsState(
|
||||
permissionGranted = permissionGranted,
|
||||
shouldDisplayBanner = shouldDisplay,
|
||||
|
||||
+4
-4
@@ -25,7 +25,7 @@ import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
|
||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent
|
||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
|
||||
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
|
||||
@@ -64,12 +64,12 @@ class FullScreenIntentPermissionsPresenter(
|
||||
val isGranted = notificationManagerCompat.canUseFullScreenIntent()
|
||||
val isBannerDismissed by isFullScreenIntentBannerDismissed.collectAsState(initial = true)
|
||||
|
||||
fun handleEvent(event: FullScreenIntentPermissionsEvents) {
|
||||
fun handleEvent(event: FullScreenIntentPermissionsEvent) {
|
||||
when (event) {
|
||||
FullScreenIntentPermissionsEvents.Dismiss -> coroutineScope.launch {
|
||||
FullScreenIntentPermissionsEvent.Dismiss -> coroutineScope.launch {
|
||||
dismissFullScreenIntentBanner()
|
||||
}
|
||||
FullScreenIntentPermissionsEvents.OpenSettings -> openFullScreenIntentSettings()
|
||||
FullScreenIntentPermissionsEvent.OpenSettings -> openFullScreenIntentSettings()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -16,7 +16,7 @@ import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
|
||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent
|
||||
import io.element.android.libraries.fullscreenintent.impl.FullScreenIntentPermissionsPresenter
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
|
||||
@@ -78,7 +78,7 @@ class FullScreenIntentPermissionsPresenterTest {
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val loadedItem = awaitItem()
|
||||
loadedItem.eventSink(FullScreenIntentPermissionsEvents.Dismiss)
|
||||
loadedItem.eventSink(FullScreenIntentPermissionsEvent.Dismiss)
|
||||
runCurrent()
|
||||
assertThat(awaitItem().shouldDisplayBanner).isFalse()
|
||||
}
|
||||
@@ -94,7 +94,7 @@ class FullScreenIntentPermissionsPresenterTest {
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val loadedItem = awaitItem()
|
||||
loadedItem.eventSink(FullScreenIntentPermissionsEvents.OpenSettings)
|
||||
loadedItem.eventSink(FullScreenIntentPermissionsEvent.OpenSettings)
|
||||
launchLambda.assertions().isCalledOnce()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
@@ -113,7 +113,7 @@ class FullScreenIntentPermissionsPresenterTest {
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val loadedItem = awaitItem()
|
||||
loadedItem.eventSink(FullScreenIntentPermissionsEvents.OpenSettings)
|
||||
loadedItem.eventSink(FullScreenIntentPermissionsEvent.OpenSettings)
|
||||
launchLambda.assertions().isNeverCalled()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ sealed interface StateEventType {
|
||||
data object RoomImagePack : StateEventType
|
||||
data object RoomLanguage : StateEventType
|
||||
data object RoomPolicy : StateEventType
|
||||
data object RoomRetention : StateEventType
|
||||
|
||||
data class Custom(val type: String) : StateEventType
|
||||
}
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -99,7 +99,6 @@ class RustMatrixClientFactory(
|
||||
isMessageSearchAvailable = isMessageSearchAvailable,
|
||||
)
|
||||
.homeserverUrl(sessionData.homeserverUrl)
|
||||
.username(sessionData.userId)
|
||||
.let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner }
|
||||
.use { it.build() }
|
||||
|
||||
|
||||
+2
@@ -39,6 +39,7 @@ fun StateEventType.map(): RustStateEventType = when (this) {
|
||||
StateEventType.RoomImagePack -> RustStateEventType.RoomImagePack
|
||||
StateEventType.RoomLanguage -> RustStateEventType.RoomLanguage
|
||||
StateEventType.RoomPolicy -> RustStateEventType.RoomPolicy
|
||||
StateEventType.RoomRetention -> RustStateEventType.RoomRetention
|
||||
is StateEventType.Custom -> stateEventTypeFromString(type)
|
||||
}
|
||||
|
||||
@@ -69,5 +70,6 @@ fun RustStateEventType.map(): StateEventType = when (this) {
|
||||
RustStateEventType.RoomImagePack -> StateEventType.RoomImagePack
|
||||
RustStateEventType.RoomLanguage -> StateEventType.RoomLanguage
|
||||
RustStateEventType.RoomPolicy -> StateEventType.RoomPolicy
|
||||
RustStateEventType.RoomRetention -> StateEventType.RoomRetention
|
||||
is RustStateEventType.Custom -> StateEventType.Custom(this.toString())
|
||||
}
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ class TimelineEventContentMapper(
|
||||
)
|
||||
}
|
||||
is MsgLikeKind.Redacted -> {
|
||||
RedactedContent
|
||||
RedactedContent(threadInfo = extractThreadInfo(it.content))
|
||||
}
|
||||
is MsgLikeKind.Poll -> {
|
||||
PollContent(
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ class FakeFfiClientBuilder(
|
||||
override fun setSessionDelegate(sessionDelegate: ClientSessionDelegate) = this
|
||||
override fun slidingSyncVersionBuilder(versionBuilder: SlidingSyncVersionBuilder) = this
|
||||
override fun userAgent(userAgent: String) = this
|
||||
override fun username(username: String) = this
|
||||
override fun serverNameFromUserId(userId: String): ClientBuilder = this
|
||||
override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this
|
||||
override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this
|
||||
override fun sqliteStore(config: SqliteStoreBuilder): ClientBuilder = this
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ fun aNotificationData(
|
||||
senderIsNameAmbiguous: Boolean = false,
|
||||
roomDisplayName: String? = A_ROOM_NAME,
|
||||
roomJoinRule: JoinRule? = null,
|
||||
isNoisy: Boolean = false,
|
||||
): NotificationData {
|
||||
return NotificationData(
|
||||
sessionId = A_SESSION_ID,
|
||||
@@ -45,7 +46,7 @@ fun aNotificationData(
|
||||
isDm = false,
|
||||
isSpace = isSpace,
|
||||
isEncrypted = false,
|
||||
isNoisy = false,
|
||||
isNoisy = isNoisy,
|
||||
timestamp = timestamp,
|
||||
content = content,
|
||||
hasMention = hasMention,
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
)
|
||||
|
||||
+7
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
@@ -118,7 +118,7 @@ class InReplyToDetailsDisambiguatedPreviewParam : InReplyToDetailsPreviewParam()
|
||||
class InReplyToDetailsInformativePreviewParam : InReplyToDetailsPreviewParam() {
|
||||
override val values: Sequence<InReplyToDetails>
|
||||
get() = sequenceOf(
|
||||
RedactedContent,
|
||||
RedactedContent(threadInfo = null),
|
||||
UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null),
|
||||
).map {
|
||||
aInReplyToDetails(
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+44
@@ -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()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -79,7 +79,7 @@ class EventItemFactory(
|
||||
LegacyCallInviteContent,
|
||||
is PollContent,
|
||||
is ProfileChangeContent,
|
||||
RedactedContent,
|
||||
is RedactedContent,
|
||||
is RoomMembershipContent,
|
||||
is StateContent,
|
||||
is StickerContent,
|
||||
|
||||
+30
@@ -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<FileViewerNode>(
|
||||
buildContext = buildContext,
|
||||
plugins = listOf(params, callback),
|
||||
)
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
}
|
||||
+47
@@ -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<Plugin>,
|
||||
presenterFactory: FileViewerPresenter.Factory,
|
||||
private val textFileViewer: TextFileViewer,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
private val callback: FileViewerEntryPoint.Callback = callback()
|
||||
private val inputs = inputs<FileViewerEntryPoint.Params>()
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+143
@@ -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<FileViewerState> {
|
||||
@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<LocalMedia>>(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<LocalMedia>) = 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<LocalMedia>) = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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<LocalMedia>,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val eventSink: (FileViewerEvent) -> Unit,
|
||||
)
|
||||
+62
@@ -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<FileViewerState> {
|
||||
override val values: Sequence<FileViewerState>
|
||||
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<LocalMedia> = 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,
|
||||
)
|
||||
+134
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+13
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
),
|
||||
|
||||
+73
@@ -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<Context>(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)
|
||||
}
|
||||
}
|
||||
+100
@@ -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<LocalMedia, Result<Unit>> { 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<LocalMedia, Result<Unit>> { 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,
|
||||
)
|
||||
}
|
||||
+87
@@ -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<FileViewerEvent>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
setFileViewerView(
|
||||
state = aFileViewerState(eventSink = eventsRecorder),
|
||||
onBackClick = it,
|
||||
)
|
||||
pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on share emits the expected event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<FileViewerEvent>()
|
||||
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<FileViewerEvent>()
|
||||
setFileViewerView(
|
||||
state = aFileViewerState(
|
||||
localMedia = AsyncData.Success(LocalMedia(Uri.EMPTY, aJsonMediaInfo())),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
clickOnContentDescription(CommonStrings.action_download)
|
||||
eventsRecorder.assertSingle(FileViewerEvent.SaveOnDisk)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.clickOnContentDescription(@StringRes res: Int) {
|
||||
val text = activity!!.getString(res)
|
||||
onNode(hasContentDescription(text) and hasClickAction()).performClick()
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setFileViewerView(
|
||||
state: FileViewerState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
FileViewerView(
|
||||
state = state,
|
||||
textFileViewer = { _, _ -> },
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
+41
@@ -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(),
|
||||
|
||||
+22
@@ -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()
|
||||
}
|
||||
+3
-3
@@ -8,7 +8,7 @@
|
||||
|
||||
package io.element.android.libraries.push.api.battery
|
||||
|
||||
sealed interface BatteryOptimizationEvents {
|
||||
data object Dismiss : BatteryOptimizationEvents
|
||||
data object RequestDisableOptimizations : BatteryOptimizationEvents
|
||||
sealed interface BatteryOptimizationEvent {
|
||||
data object Dismiss : BatteryOptimizationEvent
|
||||
data object RequestDisableOptimizations : BatteryOptimizationEvent
|
||||
}
|
||||
+1
-1
@@ -10,5 +10,5 @@ package io.element.android.libraries.push.api.battery
|
||||
|
||||
data class BatteryOptimizationState(
|
||||
val shouldDisplayBanner: Boolean,
|
||||
val eventSink: (BatteryOptimizationEvents) -> Unit,
|
||||
val eventSink: (BatteryOptimizationEvent) -> Unit,
|
||||
)
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ package io.element.android.libraries.push.api.battery
|
||||
|
||||
fun aBatteryOptimizationState(
|
||||
shouldDisplayBanner: Boolean = false,
|
||||
eventSink: (BatteryOptimizationEvents) -> Unit = {},
|
||||
eventSink: (BatteryOptimizationEvent) -> Unit = {},
|
||||
) = BatteryOptimizationState(
|
||||
shouldDisplayBanner = shouldDisplayBanner,
|
||||
eventSink = eventSink,
|
||||
|
||||
+4
-4
@@ -18,7 +18,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents
|
||||
import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent
|
||||
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
|
||||
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
|
||||
import io.element.android.libraries.push.impl.store.PushDataStore
|
||||
@@ -48,12 +48,12 @@ class BatteryOptimizationPresenter(
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
fun handleEvent(event: BatteryOptimizationEvents) {
|
||||
fun handleEvent(event: BatteryOptimizationEvent) {
|
||||
when (event) {
|
||||
BatteryOptimizationEvents.Dismiss -> coroutineScope.launch {
|
||||
BatteryOptimizationEvent.Dismiss -> coroutineScope.launch {
|
||||
mutableBatteryOptimizationStore.onOptimizationBannerDismissed()
|
||||
}
|
||||
BatteryOptimizationEvents.RequestDisableOptimizations -> {
|
||||
BatteryOptimizationEvent.RequestDisableOptimizations -> {
|
||||
isRequestSent = true
|
||||
if (batteryOptimization.requestDisablingBatteryOptimization().not()) {
|
||||
// If not able to perform the request, ensure that we do not display the banner again
|
||||
|
||||
+1
@@ -298,6 +298,7 @@ class DefaultNotifiableEventResolver(
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
cause = "Unable to decrypt event content",
|
||||
noisy = isNoisy,
|
||||
)
|
||||
ResolvedPushEvent.Event(fallbackNotifiableEvent)
|
||||
}
|
||||
|
||||
+7
@@ -274,6 +274,12 @@ private fun NotifiableEvent.toFallbackNotifiableEvent(): FallbackNotifiableEvent
|
||||
is FallbackNotifiableEvent -> timestamp
|
||||
is NotifiableRingingCallEvent -> return null
|
||||
}
|
||||
val noisy = when (this) {
|
||||
is NotifiableMessageEvent -> noisy
|
||||
is InviteNotifiableEvent -> noisy
|
||||
is SimpleNotifiableEvent -> noisy
|
||||
is FallbackNotifiableEvent -> noisy
|
||||
}
|
||||
return FallbackNotifiableEvent(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
@@ -283,6 +289,7 @@ private fun NotifiableEvent.toFallbackNotifiableEvent(): FallbackNotifiableEvent
|
||||
canBeReplaced = false,
|
||||
isRedacted = false,
|
||||
isUpdated = false,
|
||||
noisy = noisy,
|
||||
timestamp = timestamp,
|
||||
cause = null,
|
||||
)
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ class FallbackNotificationFactory(
|
||||
roomId: RoomId,
|
||||
eventId: EventId,
|
||||
cause: String?,
|
||||
noisy: Boolean,
|
||||
): FallbackNotifiableEvent = FallbackNotifiableEvent(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
@@ -32,6 +33,7 @@ class FallbackNotificationFactory(
|
||||
canBeReplaced = true,
|
||||
isRedacted = false,
|
||||
isUpdated = false,
|
||||
noisy = noisy,
|
||||
timestamp = clock.epochMillis(),
|
||||
description = "",
|
||||
cause = cause,
|
||||
|
||||
+1
@@ -170,6 +170,7 @@ class DefaultNotificationResultProcessor(
|
||||
roomId = RoomId(request.roomId),
|
||||
eventId = EventId(request.eventId),
|
||||
cause = exception.message,
|
||||
noisy = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+8
-1
@@ -42,6 +42,12 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Category shared with the `<share-target>` in `app/src/main/res/xml/shortcuts.xml`. The system only offers a shortcut
|
||||
* in the Direct Share row of the share sheet when the two match, so the literal has to be kept in step with that file.
|
||||
*/
|
||||
private const val SHARE_TARGET_CATEGORY = "io.element.android.category.SHARE_TARGET"
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultNotificationConversationService(
|
||||
@@ -86,7 +92,8 @@ class DefaultNotificationConversationService(
|
||||
}
|
||||
|
||||
val categories = setOfNotNull(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION else null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION else null,
|
||||
SHARE_TARGET_CATEGORY,
|
||||
)
|
||||
|
||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return
|
||||
|
||||
+1
-1
@@ -317,7 +317,7 @@ class DefaultNotificationCreator(
|
||||
val fallbackNotifiableEvent = fallbackNotifiableEvents.first()
|
||||
val channelId = notificationChannels.getChannelIdForMessage(
|
||||
sessionId = fallbackNotifiableEvent.sessionId,
|
||||
noisy = false,
|
||||
noisy = fallbackNotifiableEvents.any { it.noisy },
|
||||
)
|
||||
val existingCounter = existingNotification
|
||||
?.extras
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ data class FallbackNotifiableEvent(
|
||||
override val canBeReplaced: Boolean,
|
||||
override val isRedacted: Boolean,
|
||||
override val isUpdated: Boolean,
|
||||
val noisy: Boolean,
|
||||
val timestamp: Long,
|
||||
val cause: String?,
|
||||
) : NotifiableEvent
|
||||
|
||||
+4
-4
@@ -10,7 +10,7 @@ package io.element.android.libraries.push.impl.battery
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents
|
||||
import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent
|
||||
import io.element.android.libraries.push.impl.push.FakeMutableBatteryOptimizationStore
|
||||
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
|
||||
import io.element.android.libraries.push.impl.store.InMemoryPushDataStore
|
||||
@@ -98,7 +98,7 @@ class BatteryOptimizationPresenterTest {
|
||||
assertThat(initialState.shouldDisplayBanner).isFalse()
|
||||
val displayedItem = awaitItem()
|
||||
assertThat(displayedItem.shouldDisplayBanner).isTrue()
|
||||
displayedItem.eventSink(BatteryOptimizationEvents.Dismiss)
|
||||
displayedItem.eventSink(BatteryOptimizationEvent.Dismiss)
|
||||
onOptimizationBannerDismissedResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ class BatteryOptimizationPresenterTest {
|
||||
assertThat(initialState.shouldDisplayBanner).isFalse()
|
||||
val displayedItem = awaitItem()
|
||||
assertThat(displayedItem.shouldDisplayBanner).isTrue()
|
||||
displayedItem.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations)
|
||||
displayedItem.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations)
|
||||
requestDisablingBatteryOptimizationResult.assertions().isCalledOnce()
|
||||
onOptimizationBannerDismissedResult.assertions().isCalledOnce()
|
||||
}
|
||||
@@ -150,7 +150,7 @@ class BatteryOptimizationPresenterTest {
|
||||
assertThat(initialState.shouldDisplayBanner).isFalse()
|
||||
val displayedItem = awaitItem()
|
||||
assertThat(displayedItem.shouldDisplayBanner).isTrue()
|
||||
displayedItem.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations)
|
||||
displayedItem.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations)
|
||||
requestDisablingBatteryOptimizationResult.assertions().isCalledOnce()
|
||||
batteryOptimization.isIgnoringBatteryOptimizationsResult = true
|
||||
lifeCycleOwner.givenState(Lifecycle.State.RESUMED)
|
||||
|
||||
+35
@@ -712,6 +712,41 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() {
|
||||
canBeReplaced = true,
|
||||
isRedacted = false,
|
||||
isUpdated = false,
|
||||
noisy = false,
|
||||
timestamp = A_FAKE_TIMESTAMP,
|
||||
cause = "Unable to decrypt event content",
|
||||
)
|
||||
)
|
||||
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve RoomEncrypted noisy`() = runTest {
|
||||
val sut = createDefaultNotifiableEventResolver(
|
||||
notificationResult = Result.success(
|
||||
mapOf(
|
||||
AN_EVENT_ID to Result.success(
|
||||
aNotificationData(
|
||||
content = NotificationContent.MessageLike.RoomEncrypted,
|
||||
isNoisy = true,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
|
||||
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||
val expectedResult = ResolvedPushEvent.Event(
|
||||
FallbackNotifiableEvent(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
editedEventId = null,
|
||||
description = "",
|
||||
canBeReplaced = true,
|
||||
isRedacted = false,
|
||||
isUpdated = false,
|
||||
noisy = true,
|
||||
timestamp = A_FAKE_TIMESTAMP,
|
||||
cause = "Unable to decrypt event content",
|
||||
)
|
||||
|
||||
+142
-1
@@ -15,28 +15,35 @@ import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.features.lockscreen.api.LockScreenService
|
||||
import io.element.android.features.lockscreen.test.FakeLockScreenService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||
import io.element.android.libraries.matrix.test.A_THREAD_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_TIMESTAMP
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
|
||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
|
||||
import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams
|
||||
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
|
||||
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
|
||||
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
|
||||
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
|
||||
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
||||
@@ -48,6 +55,8 @@ import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
|
||||
import io.element.android.services.appnavstate.test.aNavigationState
|
||||
import io.element.android.services.appnavstate.test.anAppNavigationState
|
||||
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
|
||||
import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder
|
||||
import io.element.android.tests.testutils.lambda.any
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
@@ -472,6 +481,137 @@ class DefaultNotificationDrawerManagerTest {
|
||||
extraInvocationsForNotificationSummary = 1,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when the app is locked, a noisy message event produces a noisy fallback notification`() = testAppLockedNotifiableEventReceived(
|
||||
notifiableEvent = aNotifiableMessageEvent(noisy = true),
|
||||
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = true, timestamp = A_TIMESTAMP),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when the app is locked, a silent message event produces a silent fallback notification`() = testAppLockedNotifiableEventReceived(
|
||||
notifiableEvent = aNotifiableMessageEvent(noisy = false),
|
||||
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = false, timestamp = A_TIMESTAMP),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when the app is locked, a noisy invite event produces a noisy fallback notification`() = testAppLockedNotifiableEventReceived(
|
||||
notifiableEvent = anInviteNotifiableEvent(noisy = true),
|
||||
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = true, timestamp = 0),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when the app is locked, a silent invite event produces a silent fallback notification`() = testAppLockedNotifiableEventReceived(
|
||||
notifiableEvent = anInviteNotifiableEvent(noisy = false),
|
||||
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = false, timestamp = 0),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when the app is locked, a noisy simple event produces a noisy fallback notification`() = testAppLockedNotifiableEventReceived(
|
||||
notifiableEvent = aSimpleNotifiableEvent(noisy = true),
|
||||
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = true, timestamp = 0),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when the app is locked, a silent simple event produces a silent fallback notification`() = testAppLockedNotifiableEventReceived(
|
||||
notifiableEvent = aSimpleNotifiableEvent(noisy = false),
|
||||
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = false, timestamp = 0),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when the app is locked, a noisy fallback event stays noisy`() = testAppLockedNotifiableEventReceived(
|
||||
notifiableEvent = aFallbackNotifiableEvent(noisy = true),
|
||||
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = true, timestamp = A_FAKE_TIMESTAMP),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when the app is locked, a silent fallback event stays silent`() = testAppLockedNotifiableEventReceived(
|
||||
notifiableEvent = aFallbackNotifiableEvent(noisy = false),
|
||||
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = false, timestamp = A_FAKE_TIMESTAMP),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when the app is locked, all the events are grouped into a single fallback notification`() = runTest {
|
||||
val createFallbackNotificationResult = aCreateFallbackNotificationRecorder()
|
||||
val sut = createAppLockedNotificationDrawerManager(createFallbackNotificationResult)
|
||||
sut.onNotifiableEventsReceived(
|
||||
listOf(
|
||||
aNotifiableMessageEvent(noisy = false),
|
||||
aNotifiableMessageEvent(eventId = AN_EVENT_ID_2, noisy = true),
|
||||
)
|
||||
)
|
||||
createFallbackNotificationResult.assertions()
|
||||
.isCalledOnce()
|
||||
.with(
|
||||
value(null),
|
||||
any(),
|
||||
value(
|
||||
listOf(
|
||||
anExpectedFallbackNotifiableEvent(noisy = false, timestamp = A_TIMESTAMP),
|
||||
anExpectedFallbackNotifiableEvent(eventId = AN_EVENT_ID_2, noisy = true, timestamp = A_TIMESTAMP),
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the app is not locked, no fallback notification is created`() = runTest {
|
||||
val createFallbackNotificationResult = aCreateFallbackNotificationRecorder()
|
||||
val sut = createDefaultNotificationDrawerManager(
|
||||
appNavigationStateService = FakeAppNavigationStateService(
|
||||
initialAppNavigationState = anAppNavigationState(isInForeground = false),
|
||||
),
|
||||
notificationCreator = FakeNotificationCreator(
|
||||
createFallbackNotificationResult = createFallbackNotificationResult,
|
||||
),
|
||||
lockScreenService = FakeLockScreenService().apply { setIsPinSetup(false) },
|
||||
)
|
||||
sut.onNotifiableEventsReceived(listOf(aNotifiableMessageEvent(noisy = true)))
|
||||
createFallbackNotificationResult.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
/**
|
||||
* The content of the original event must be stripped, only the noisy flag, the ids and the timestamp are kept.
|
||||
*/
|
||||
private fun anExpectedFallbackNotifiableEvent(
|
||||
eventId: EventId = AN_EVENT_ID,
|
||||
noisy: Boolean,
|
||||
timestamp: Long,
|
||||
) = aFallbackNotifiableEvent(
|
||||
eventId = eventId,
|
||||
description = null,
|
||||
canBeReplaced = false,
|
||||
noisy = noisy,
|
||||
timestamp = timestamp,
|
||||
cause = null,
|
||||
)
|
||||
|
||||
private fun aCreateFallbackNotificationRecorder() =
|
||||
lambdaRecorder<Notification?, NotificationAccountParams, List<FallbackNotifiableEvent>, Notification> { _, _, _ -> A_NOTIFICATION }
|
||||
|
||||
private fun TestScope.createAppLockedNotificationDrawerManager(
|
||||
createFallbackNotificationResult: LambdaThreeParamsRecorder<Notification?, NotificationAccountParams, List<FallbackNotifiableEvent>, Notification>,
|
||||
) = createDefaultNotificationDrawerManager(
|
||||
appNavigationStateService = FakeAppNavigationStateService(
|
||||
initialAppNavigationState = anAppNavigationState(isInForeground = false),
|
||||
),
|
||||
notificationCreator = FakeNotificationCreator(
|
||||
createFallbackNotificationResult = createFallbackNotificationResult,
|
||||
),
|
||||
lockScreenService = FakeLockScreenService().apply { setIsPinSetup(true) },
|
||||
)
|
||||
|
||||
private fun testAppLockedNotifiableEventReceived(
|
||||
notifiableEvent: NotifiableEvent,
|
||||
expectedFallbackEvent: FallbackNotifiableEvent,
|
||||
) = runTest {
|
||||
val createFallbackNotificationResult = aCreateFallbackNotificationRecorder()
|
||||
val sut = createAppLockedNotificationDrawerManager(createFallbackNotificationResult)
|
||||
sut.onNotifiableEventsReceived(listOf(notifiableEvent))
|
||||
createFallbackNotificationResult.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(null), any(), value(listOf(expectedFallbackEvent)))
|
||||
}
|
||||
|
||||
private fun testOnNotifiableEventReceived(
|
||||
appNavigationState: AppNavigationState,
|
||||
notifiableEvents: List<NotifiableEvent>,
|
||||
@@ -503,6 +643,7 @@ class DefaultNotificationDrawerManagerTest {
|
||||
fun TestScope.createDefaultNotificationDrawerManager(
|
||||
notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(),
|
||||
notificationRenderer: NotificationRenderer? = null,
|
||||
notificationCreator: NotificationCreator = FakeNotificationCreator(),
|
||||
appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(),
|
||||
roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(),
|
||||
summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(),
|
||||
@@ -519,7 +660,7 @@ fun TestScope.createDefaultNotificationDrawerManager(
|
||||
notificationRenderer = notificationRenderer ?: NotificationRenderer(
|
||||
notificationDisplayer = notificationDisplayer,
|
||||
notificationDataFactory = DefaultNotificationDataFactory(
|
||||
notificationCreator = FakeNotificationCreator(),
|
||||
notificationCreator = notificationCreator,
|
||||
roomGroupMessageCreator = roomGroupMessageCreator,
|
||||
summaryGroupMessageCreator = summaryGroupMessageCreator,
|
||||
activeNotificationsProvider = activeNotificationsProvider,
|
||||
|
||||
+36
@@ -267,6 +267,42 @@ class DefaultNotificationResultProcessorTest {
|
||||
assertThat(receivedFallbackEvent).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when an event cannot be resolved, the generated fallback event is silent`() = runTest {
|
||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||
val processor = createDefaultNotificationResultProcessor(
|
||||
mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(
|
||||
showBatteryOptimizationBannerResult = lambdaRecorder<Unit> {},
|
||||
),
|
||||
pushHistoryService = FakePushHistoryService(
|
||||
onPushReceivedResult = onPushReceivedResult,
|
||||
),
|
||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
||||
)
|
||||
|
||||
runningProcessor(processor) {
|
||||
emit(mapOf(aPushRequest() to Result.failure(NotificationResolverException.UnknownError("Unable to resolve event"))))
|
||||
}
|
||||
|
||||
advanceTimeBy(300.milliseconds)
|
||||
|
||||
onNotifiableEventsReceived.assertions()
|
||||
.isCalledOnce()
|
||||
.with(
|
||||
value(
|
||||
listOf(
|
||||
aFallbackNotifiableEvent(
|
||||
description = "",
|
||||
canBeReplaced = true,
|
||||
noisy = false,
|
||||
cause = "Unable to resolve event",
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun TestScope.runningProcessor(processor: NotificationResultProcessor, block: suspend NotificationResultProcessor.() -> Unit) {
|
||||
processor.start()
|
||||
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import org.junit.Test
|
||||
|
||||
class FallbackNotificationFactoryTest {
|
||||
@Test
|
||||
fun `create silent event`() {
|
||||
val sut = FallbackNotificationFactory(FakeSystemClock())
|
||||
val result = sut.create(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
cause = "A cause",
|
||||
noisy = false,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
aFallbackNotifiableEvent(
|
||||
description = "",
|
||||
canBeReplaced = true,
|
||||
noisy = false,
|
||||
cause = "A cause",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create noisy event`() {
|
||||
val sut = FallbackNotificationFactory(FakeSystemClock())
|
||||
val result = sut.create(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
cause = "A cause",
|
||||
noisy = true,
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
aFallbackNotifiableEvent(
|
||||
description = "",
|
||||
canBeReplaced = true,
|
||||
noisy = true,
|
||||
cause = "A cause",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+22
@@ -10,6 +10,7 @@ package io.element.android.libraries.push.impl.notifications.conversations
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ShortcutInfo
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
@@ -52,6 +53,27 @@ class DefaultNotificationConversationServiceTest : RobolectricTest() {
|
||||
assertThat(shortcuts).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shortcut is offered to the share sheet as a conversation`() = runTest {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
val service = createService(context)
|
||||
|
||||
service.onSendMessage(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
roomName = "Room title",
|
||||
roomIsDirect = false,
|
||||
roomAvatarUrl = null,
|
||||
)
|
||||
|
||||
val shortcut = ShortcutManagerCompat.getDynamicShortcuts(context).single()
|
||||
assertThat(shortcut.categories).containsAtLeast(
|
||||
ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION,
|
||||
// Matches the <share-target> category in app/src/main/res/xml/shortcuts.xml
|
||||
"io.element.android.category.SHARE_TARGET",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onLeftRoom removes a shortcut`() = runTest {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
|
||||
+54
-1
@@ -18,6 +18,7 @@ import io.element.android.appconfig.NotificationConfig
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_COLOR_INT
|
||||
@@ -33,6 +34,7 @@ import io.element.android.libraries.push.impl.notifications.DefaultNotificationB
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
|
||||
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
|
||||
import io.element.android.libraries.push.impl.notifications.channels.DefaultNotificationChannels
|
||||
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels
|
||||
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
|
||||
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
|
||||
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
|
||||
@@ -47,6 +49,8 @@ import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvid
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
@@ -82,7 +86,10 @@ class DefaultNotificationCreatorTest : RobolectricTest() {
|
||||
|
||||
@Test
|
||||
fun `test createFallbackNotification`() {
|
||||
val sut = createNotificationCreator()
|
||||
val channelIdForMessage = lambdaRecorder<SessionId, Boolean, String> { _, _ -> A_CHANNEL_ID }
|
||||
val sut = createNotificationCreator(
|
||||
notificationChannels = FakeNotificationChannels(channelIdForMessage = channelIdForMessage),
|
||||
)
|
||||
val result = sut.createFallbackNotification(
|
||||
existingNotification = null,
|
||||
notificationAccountParams = aNotificationAccountParams(),
|
||||
@@ -93,6 +100,50 @@ class DefaultNotificationCreatorTest : RobolectricTest() {
|
||||
result.commonAssertions(
|
||||
expectedCategory = null,
|
||||
)
|
||||
assertThat(result.channelId).isEqualTo(A_CHANNEL_ID)
|
||||
// The notification must be posted on the silent channel.
|
||||
channelIdForMessage.assertions().isCalledOnce().with(value(A_SESSION_ID), value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test createFallbackNotification noisy`() {
|
||||
val channelIdForMessage = lambdaRecorder<SessionId, Boolean, String> { _, _ -> A_CHANNEL_ID }
|
||||
val sut = createNotificationCreator(
|
||||
notificationChannels = FakeNotificationChannels(channelIdForMessage = channelIdForMessage),
|
||||
)
|
||||
val result = sut.createFallbackNotification(
|
||||
existingNotification = null,
|
||||
notificationAccountParams = aNotificationAccountParams(),
|
||||
fallbackNotifiableEvents = listOf(
|
||||
aFallbackNotifiableEvent(noisy = true),
|
||||
)
|
||||
)
|
||||
result.commonAssertions(
|
||||
expectedCategory = null,
|
||||
)
|
||||
assertThat(result.channelId).isEqualTo(A_CHANNEL_ID)
|
||||
// The notification must be posted on the noisy channel.
|
||||
channelIdForMessage.assertions().isCalledOnce().with(value(A_SESSION_ID), value(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test createFallbackNotification is noisy when at least one event is noisy`() {
|
||||
val channelIdForMessage = lambdaRecorder<SessionId, Boolean, String> { _, _ -> A_CHANNEL_ID }
|
||||
val sut = createNotificationCreator(
|
||||
notificationChannels = FakeNotificationChannels(channelIdForMessage = channelIdForMessage),
|
||||
)
|
||||
val result = sut.createFallbackNotification(
|
||||
existingNotification = null,
|
||||
notificationAccountParams = aNotificationAccountParams(),
|
||||
fallbackNotifiableEvents = listOf(
|
||||
aFallbackNotifiableEvent(noisy = false),
|
||||
aFallbackNotifiableEvent(eventId = AN_EVENT_ID_2, noisy = true),
|
||||
)
|
||||
)
|
||||
result.commonAssertions(
|
||||
expectedCategory = null,
|
||||
)
|
||||
channelIdForMessage.assertions().isCalledOnce().with(value(A_SESSION_ID), value(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -389,6 +440,8 @@ class DefaultNotificationCreatorTest : RobolectricTest() {
|
||||
}
|
||||
}
|
||||
|
||||
private const val A_CHANNEL_ID = "aChannelId"
|
||||
|
||||
const val MARK_AS_READ_ACTION_TITLE = "MarkAsReadAction"
|
||||
const val QUICK_REPLY_ACTION_TITLE = "QuickReplyAction"
|
||||
const val ACCEPT_INVITATION_ACTION_TITLE = "AcceptInvitationAction"
|
||||
|
||||
+22
-11
@@ -39,13 +39,14 @@ fun aSimpleNotifiableEvent(
|
||||
type: String? = null,
|
||||
isRedacted: Boolean = false,
|
||||
canBeReplaced: Boolean = false,
|
||||
editedEventId: EventId? = null
|
||||
editedEventId: EventId? = null,
|
||||
noisy: Boolean = false,
|
||||
) = SimpleNotifiableEvent(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
editedEventId = editedEventId,
|
||||
noisy = false,
|
||||
noisy = noisy,
|
||||
title = "title",
|
||||
description = "description",
|
||||
type = type,
|
||||
@@ -59,14 +60,15 @@ fun anInviteNotifiableEvent(
|
||||
sessionId: SessionId = A_SESSION_ID,
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
eventId: EventId = AN_EVENT_ID,
|
||||
isRedacted: Boolean = false
|
||||
isRedacted: Boolean = false,
|
||||
noisy: Boolean = false,
|
||||
) = InviteNotifiableEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = eventId,
|
||||
roomId = roomId,
|
||||
roomName = A_ROOM_NAME,
|
||||
editedEventId = null,
|
||||
noisy = false,
|
||||
noisy = noisy,
|
||||
title = "title",
|
||||
description = "description",
|
||||
type = null,
|
||||
@@ -89,11 +91,12 @@ fun aNotifiableMessageEvent(
|
||||
senderId: UserId = A_USER_ID_2,
|
||||
senderDisambiguatedDisplayName: String = A_USER_NAME_2,
|
||||
roomName: String? = A_ROOM_NAME,
|
||||
noisy: Boolean = false,
|
||||
) = NotifiableMessageEvent(
|
||||
sessionId = sessionId,
|
||||
eventId = eventId,
|
||||
editedEventId = null,
|
||||
noisy = false,
|
||||
noisy = noisy,
|
||||
timestamp = timestamp,
|
||||
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
|
||||
senderId = senderId,
|
||||
@@ -149,15 +152,23 @@ fun aNotifiableCallEvent(
|
||||
|
||||
fun aFallbackNotifiableEvent(
|
||||
sessionId: SessionId = A_SESSION_ID,
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
eventId: EventId = AN_EVENT_ID,
|
||||
description: String? = "A fallback notification",
|
||||
canBeReplaced: Boolean = false,
|
||||
noisy: Boolean = false,
|
||||
timestamp: Long = A_FAKE_TIMESTAMP,
|
||||
cause: String? = "Unable to decrypt event",
|
||||
) = FallbackNotifiableEvent(
|
||||
sessionId = sessionId,
|
||||
roomId = A_ROOM_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
editedEventId = null,
|
||||
description = "A fallback notification",
|
||||
canBeReplaced = false,
|
||||
description = description,
|
||||
canBeReplaced = canBeReplaced,
|
||||
isRedacted = false,
|
||||
isUpdated = false,
|
||||
timestamp = A_FAKE_TIMESTAMP,
|
||||
cause = "Unable to decrypt event",
|
||||
noisy = noisy,
|
||||
timestamp = timestamp,
|
||||
cause = cause,
|
||||
)
|
||||
|
||||
+4
-4
@@ -8,8 +8,8 @@
|
||||
|
||||
package io.element.android.libraries.troubleshoot.impl
|
||||
|
||||
sealed interface TroubleshootNotificationsEvents {
|
||||
data object StartTests : TroubleshootNotificationsEvents
|
||||
data object RetryFailedTests : TroubleshootNotificationsEvents
|
||||
data class QuickFix(val testIndex: Int) : TroubleshootNotificationsEvents
|
||||
sealed interface TroubleshootNotificationsEvent {
|
||||
data object StartTests : TroubleshootNotificationsEvent
|
||||
data object RetryFailedTests : TroubleshootNotificationsEvent
|
||||
data class QuickFix(val testIndex: Int) : TroubleshootNotificationsEvent
|
||||
}
|
||||
+4
-4
@@ -38,19 +38,19 @@ class TroubleshootNotificationsPresenter(
|
||||
}
|
||||
|
||||
val testSuiteState by troubleshootTestSuite.state.collectAsState()
|
||||
fun handleEvent(event: TroubleshootNotificationsEvents) {
|
||||
fun handleEvent(event: TroubleshootNotificationsEvent) {
|
||||
when (event) {
|
||||
TroubleshootNotificationsEvents.StartTests -> coroutineScope.launch {
|
||||
TroubleshootNotificationsEvent.StartTests -> coroutineScope.launch {
|
||||
troubleshootTestSuite.runTestSuite(this)
|
||||
}
|
||||
is TroubleshootNotificationsEvents.QuickFix -> coroutineScope.launch {
|
||||
is TroubleshootNotificationsEvent.QuickFix -> coroutineScope.launch {
|
||||
troubleshootTestSuite.quickFix(
|
||||
testIndex = event.testIndex,
|
||||
coroutineScope = this,
|
||||
navigator = navigator,
|
||||
)
|
||||
}
|
||||
TroubleshootNotificationsEvents.RetryFailedTests -> coroutineScope.launch {
|
||||
TroubleshootNotificationsEvent.RetryFailedTests -> coroutineScope.launch {
|
||||
troubleshootTestSuite.retryFailedTest(this)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ package io.element.android.libraries.troubleshoot.impl
|
||||
|
||||
data class TroubleshootNotificationsState(
|
||||
val testSuiteState: TroubleshootTestSuiteState,
|
||||
val eventSink: (TroubleshootNotificationsEvents) -> Unit,
|
||||
val eventSink: (TroubleshootNotificationsEvent) -> Unit,
|
||||
) {
|
||||
val hasFailedTests: Boolean = testSuiteState.mainState.isFailure()
|
||||
}
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ open class TroubleshootNotificationsStatePreviewParam : PreviewParameterProvider
|
||||
|
||||
fun aTroubleshootNotificationsState(
|
||||
tests: List<NotificationTroubleshootTestState> = emptyList(),
|
||||
eventSink: (TroubleshootNotificationsEvents) -> Unit = {},
|
||||
eventSink: (TroubleshootNotificationsEvent) -> Unit = {},
|
||||
) = TroubleshootNotificationsState(
|
||||
eventSink = eventSink,
|
||||
testSuiteState = TroubleshootTestSuiteState(
|
||||
|
||||
+3
-3
@@ -44,7 +44,7 @@ fun TroubleshootNotificationsView(
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
if (state.hasFailedTests) {
|
||||
state.eventSink(TroubleshootNotificationsEvents.RetryFailedTests)
|
||||
state.eventSink(TroubleshootNotificationsEvent.RetryFailedTests)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
@@ -129,7 +129,7 @@ private fun ColumnScope.TroubleshootNotificationsContent(state: TroubleshootNoti
|
||||
TestSuiteView(
|
||||
testSuiteState = state.testSuiteState,
|
||||
onQuickFixClick = {
|
||||
state.eventSink(TroubleshootNotificationsEvents.QuickFix(it))
|
||||
state.eventSink(TroubleshootNotificationsEvent.QuickFix(it))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -181,7 +181,7 @@ private fun RunTestButton(state: TroubleshootNotificationsState) {
|
||||
}
|
||||
),
|
||||
onClick = {
|
||||
state.eventSink(TroubleshootNotificationsEvents.StartTests)
|
||||
state.eventSink(TroubleshootNotificationsEvent.StartTests)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
+5
-5
@@ -12,9 +12,9 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
sealed interface PushHistoryEvents {
|
||||
data class SetShowOnlyErrors(val showOnlyErrors: Boolean) : PushHistoryEvents
|
||||
data class Reset(val requiresConfirmation: Boolean) : PushHistoryEvents
|
||||
data class NavigateTo(val sessionId: SessionId, val roomId: RoomId, val eventId: EventId) : PushHistoryEvents
|
||||
data object ClearDialog : PushHistoryEvents
|
||||
sealed interface PushHistoryEvent {
|
||||
data class SetShowOnlyErrors(val showOnlyErrors: Boolean) : PushHistoryEvent
|
||||
data class Reset(val requiresConfirmation: Boolean) : PushHistoryEvent
|
||||
data class NavigateTo(val sessionId: SessionId, val roomId: RoomId, val eventId: EventId) : PushHistoryEvent
|
||||
data object ClearDialog : PushHistoryEvent
|
||||
}
|
||||
+5
-5
@@ -62,12 +62,12 @@ class PushHistoryPresenter(
|
||||
var resetAction: AsyncAction<Unit> by remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
var showNotSameAccountError by remember { mutableStateOf(false) }
|
||||
|
||||
fun handleEvent(event: PushHistoryEvents) {
|
||||
fun handleEvent(event: PushHistoryEvent) {
|
||||
when (event) {
|
||||
is PushHistoryEvents.SetShowOnlyErrors -> {
|
||||
is PushHistoryEvent.SetShowOnlyErrors -> {
|
||||
showOnlyErrors = event.showOnlyErrors
|
||||
}
|
||||
is PushHistoryEvents.Reset -> {
|
||||
is PushHistoryEvent.Reset -> {
|
||||
if (event.requiresConfirmation) {
|
||||
resetAction = AsyncAction.ConfirmingNoParams
|
||||
} else {
|
||||
@@ -78,11 +78,11 @@ class PushHistoryPresenter(
|
||||
}
|
||||
}
|
||||
}
|
||||
PushHistoryEvents.ClearDialog -> {
|
||||
PushHistoryEvent.ClearDialog -> {
|
||||
resetAction = AsyncAction.Uninitialized
|
||||
showNotSameAccountError = false
|
||||
}
|
||||
is PushHistoryEvents.NavigateTo -> {
|
||||
is PushHistoryEvent.NavigateTo -> {
|
||||
if (event.sessionId != sessionId) {
|
||||
showNotSameAccountError = true
|
||||
} else {
|
||||
|
||||
+1
-1
@@ -18,5 +18,5 @@ data class PushHistoryState(
|
||||
val showOnlyErrors: Boolean,
|
||||
val resetAction: AsyncAction<Unit>,
|
||||
val showNotSameAccountError: Boolean,
|
||||
val eventSink: (PushHistoryEvents) -> Unit,
|
||||
val eventSink: (PushHistoryEvent) -> Unit,
|
||||
)
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ fun aPushHistoryState(
|
||||
showOnlyErrors: Boolean = false,
|
||||
resetAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
showNotSameAccountError: Boolean = false,
|
||||
eventSink: (PushHistoryEvents) -> Unit = {},
|
||||
eventSink: (PushHistoryEvent) -> Unit = {},
|
||||
) = PushHistoryState(
|
||||
pushCounter = pushCounter,
|
||||
pushHistoryItems = pushHistoryItems.toImmutableList(),
|
||||
|
||||
+6
-6
@@ -100,14 +100,14 @@ fun PushHistoryView(
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
state.eventSink(PushHistoryEvents.SetShowOnlyErrors(state.showOnlyErrors.not()))
|
||||
state.eventSink(PushHistoryEvent.SetShowOnlyErrors(state.showOnlyErrors.not()))
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(id = CommonStrings.action_reset)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
state.eventSink(PushHistoryEvents.Reset(requiresConfirmation = true))
|
||||
state.eventSink(PushHistoryEvent.Reset(requiresConfirmation = true))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -132,8 +132,8 @@ fun PushHistoryView(
|
||||
title = stringResource(CommonStrings.dialog_title_confirmation),
|
||||
submitText = stringResource(CommonStrings.action_reset),
|
||||
cancelText = stringResource(CommonStrings.action_cancel),
|
||||
onSubmitClick = { state.eventSink(PushHistoryEvents.Reset(requiresConfirmation = false)) },
|
||||
onDismiss = { state.eventSink(PushHistoryEvents.ClearDialog) },
|
||||
onSubmitClick = { state.eventSink(PushHistoryEvent.Reset(requiresConfirmation = false)) },
|
||||
onDismiss = { state.eventSink(PushHistoryEvent.ClearDialog) },
|
||||
)
|
||||
},
|
||||
onErrorDismiss = {},
|
||||
@@ -142,7 +142,7 @@ fun PushHistoryView(
|
||||
if (state.showNotSameAccountError) {
|
||||
ErrorDialog(
|
||||
content = "Please switch account first to navigate to the event.",
|
||||
onSubmit = { state.eventSink(PushHistoryEvents.ClearDialog) }
|
||||
onSubmit = { state.eventSink(PushHistoryEvent.ClearDialog) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,7 @@ private fun PushHistoryContent(
|
||||
val roomId = pushHistory.roomId
|
||||
val eventId = pushHistory.eventId
|
||||
if (sessionId != null && roomId != null && eventId != null) {
|
||||
state.eventSink(PushHistoryEvents.NavigateTo(sessionId, roomId, eventId))
|
||||
state.eventSink(PushHistoryEvent.NavigateTo(sessionId, roomId, eventId))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+3
-3
@@ -42,7 +42,7 @@ class TroubleshootNotificationsPresenterTest {
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(TroubleshootNotificationsEvents.StartTests)
|
||||
initialState.eventSink(TroubleshootNotificationsEvent.StartTests)
|
||||
skipItems(1)
|
||||
val stateAfterStart = awaitItem()
|
||||
assertThat(stateAfterStart.testSuiteState.mainState).isEqualTo(AsyncAction.Loading)
|
||||
@@ -63,7 +63,7 @@ class TroubleshootNotificationsPresenterTest {
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(TroubleshootNotificationsEvents.RetryFailedTests)
|
||||
initialState.eventSink(TroubleshootNotificationsEvent.RetryFailedTests)
|
||||
skipItems(1)
|
||||
val stateAfterStart = awaitItem()
|
||||
assertThat(stateAfterStart.testSuiteState.mainState).isEqualTo(AsyncAction.Loading)
|
||||
@@ -160,7 +160,7 @@ class TroubleshootNotificationsPresenterTest {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.testSuiteState.mainState).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
initialState.eventSink(TroubleshootNotificationsEvents.QuickFix(0))
|
||||
initialState.eventSink(TroubleshootNotificationsEvent.QuickFix(0))
|
||||
val stateAfterStart = awaitItem()
|
||||
assertThat(stateAfterStart.testSuiteState.mainState).isEqualTo(AsyncAction.Loading)
|
||||
}
|
||||
|
||||
+9
-9
@@ -27,7 +27,7 @@ import org.robolectric.annotation.Config
|
||||
class TroubleshootNotificationsViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `press menu back invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TroubleshootNotificationsEvents>(expectEvents = false)
|
||||
val eventsRecorder = EventsRecorder<TroubleshootNotificationsEvent>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
setTroubleshootNotificationsView(
|
||||
state = aTroubleshootNotificationsState(
|
||||
@@ -41,20 +41,20 @@ class TroubleshootNotificationsViewTest : RobolectricTest() {
|
||||
|
||||
@Test
|
||||
fun `clicking on run test emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TroubleshootNotificationsEvents>()
|
||||
val eventsRecorder = EventsRecorder<TroubleshootNotificationsEvent>()
|
||||
setTroubleshootNotificationsView(
|
||||
aTroubleshootNotificationsState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
onNodeWithText("Run tests").performClick()
|
||||
eventsRecorder.assertSingle(TroubleshootNotificationsEvents.StartTests)
|
||||
eventsRecorder.assertSingle(TroubleshootNotificationsEvent.StartTests)
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `clicking on run test again emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TroubleshootNotificationsEvents>()
|
||||
val eventsRecorder = EventsRecorder<TroubleshootNotificationsEvent>()
|
||||
setTroubleshootNotificationsView(
|
||||
aTroubleshootNotificationsState(
|
||||
tests = listOf(
|
||||
@@ -68,8 +68,8 @@ class TroubleshootNotificationsViewTest : RobolectricTest() {
|
||||
onNodeWithText("Run tests again").performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
TroubleshootNotificationsEvents.RetryFailedTests,
|
||||
TroubleshootNotificationsEvents.StartTests,
|
||||
TroubleshootNotificationsEvent.RetryFailedTests,
|
||||
TroubleshootNotificationsEvent.StartTests,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ class TroubleshootNotificationsViewTest : RobolectricTest() {
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `clicking on quick fix emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<TroubleshootNotificationsEvents>()
|
||||
val eventsRecorder = EventsRecorder<TroubleshootNotificationsEvent>()
|
||||
setTroubleshootNotificationsView(
|
||||
aTroubleshootNotificationsState(
|
||||
tests = listOf(
|
||||
@@ -91,8 +91,8 @@ class TroubleshootNotificationsViewTest : RobolectricTest() {
|
||||
onNodeWithText("Attempt to fix").performClick()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
TroubleshootNotificationsEvents.RetryFailedTests,
|
||||
TroubleshootNotificationsEvents.QuickFix(0),
|
||||
TroubleshootNotificationsEvent.RetryFailedTests,
|
||||
TroubleshootNotificationsEvent.QuickFix(0),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+9
-9
@@ -73,9 +73,9 @@ class PushHistoryPresenterTest {
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(PushHistoryEvents.Reset(requiresConfirmation = true))
|
||||
initialState.eventSink(PushHistoryEvent.Reset(requiresConfirmation = true))
|
||||
assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.ConfirmingNoParams)
|
||||
initialState.eventSink(PushHistoryEvents.ClearDialog)
|
||||
initialState.eventSink(PushHistoryEvent.ClearDialog)
|
||||
assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
resetPushHistoryResult.assertions().isNeverCalled()
|
||||
}
|
||||
@@ -92,9 +92,9 @@ class PushHistoryPresenterTest {
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(PushHistoryEvents.Reset(requiresConfirmation = true))
|
||||
initialState.eventSink(PushHistoryEvent.Reset(requiresConfirmation = true))
|
||||
assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.ConfirmingNoParams)
|
||||
initialState.eventSink(PushHistoryEvents.Reset(requiresConfirmation = false))
|
||||
initialState.eventSink(PushHistoryEvent.Reset(requiresConfirmation = false))
|
||||
assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.Loading)
|
||||
assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
resetPushHistoryResult.assertions().isCalledOnce()
|
||||
@@ -115,13 +115,13 @@ class PushHistoryPresenterTest {
|
||||
pushService.emitPushHistoryItems(listOf(item, itemError))
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.pushHistoryItems).containsExactly(item, itemError)
|
||||
state.eventSink(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = true))
|
||||
state.eventSink(PushHistoryEvent.SetShowOnlyErrors(showOnlyErrors = true))
|
||||
}
|
||||
skipItems(1)
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.showOnlyErrors).isTrue()
|
||||
assertThat(state.pushHistoryItems).containsExactly(itemError)
|
||||
state.eventSink(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = false))
|
||||
state.eventSink(PushHistoryEvent.SetShowOnlyErrors(showOnlyErrors = false))
|
||||
}
|
||||
skipItems(1)
|
||||
awaitItem().let { state ->
|
||||
@@ -142,7 +142,7 @@ class PushHistoryPresenterTest {
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(
|
||||
PushHistoryEvents.NavigateTo(
|
||||
PushHistoryEvent.NavigateTo(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
@@ -160,7 +160,7 @@ class PushHistoryPresenterTest {
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(
|
||||
PushHistoryEvents.NavigateTo(
|
||||
PushHistoryEvent.NavigateTo(
|
||||
sessionId = A_SESSION_ID_2,
|
||||
roomId = A_ROOM_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
@@ -168,7 +168,7 @@ class PushHistoryPresenterTest {
|
||||
)
|
||||
assertThat(awaitItem().showNotSameAccountError).isTrue()
|
||||
// Reset error
|
||||
initialState.eventSink(PushHistoryEvents.ClearDialog)
|
||||
initialState.eventSink(PushHistoryEvent.ClearDialog)
|
||||
assertThat(awaitItem().showNotSameAccountError).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -30,8 +30,8 @@ import org.junit.Test
|
||||
|
||||
class PushHistoryViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `clicking on Reset sends a PushHistoryEvents`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
|
||||
fun `clicking on Reset sends a PushHistoryEvent`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvent>()
|
||||
setPushHistoryView(
|
||||
aPushHistoryState(
|
||||
pushCounter = 123,
|
||||
@@ -41,14 +41,14 @@ class PushHistoryViewTest : RobolectricTest() {
|
||||
val menuContentDescription = activity!!.getString(CommonStrings.a11y_user_menu)
|
||||
onNodeWithContentDescription(menuContentDescription).performClick()
|
||||
clickOn(CommonStrings.action_reset)
|
||||
eventsRecorder.assertSingle(PushHistoryEvents.Reset(requiresConfirmation = true))
|
||||
eventsRecorder.assertSingle(PushHistoryEvent.Reset(requiresConfirmation = true))
|
||||
// Also check that the push counter is rendered
|
||||
onNodeWithText("123").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on show only errors sends a PushHistoryEvents(true)`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
|
||||
fun `clicking on show only errors sends a PushHistoryEvent(true)`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvent>()
|
||||
setPushHistoryView(
|
||||
aPushHistoryState(
|
||||
showOnlyErrors = false,
|
||||
@@ -58,12 +58,12 @@ class PushHistoryViewTest : RobolectricTest() {
|
||||
val menuContentDescription = activity!!.getString(CommonStrings.a11y_user_menu)
|
||||
onNodeWithContentDescription(menuContentDescription).performClick()
|
||||
onNodeWithText("Show only errors").performClick()
|
||||
eventsRecorder.assertSingle(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = true))
|
||||
eventsRecorder.assertSingle(PushHistoryEvent.SetShowOnlyErrors(showOnlyErrors = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on show only errors sends a PushHistoryEvents(false)`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
|
||||
fun `clicking on show only errors sends a PushHistoryEvent(false)`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvent>()
|
||||
setPushHistoryView(
|
||||
aPushHistoryState(
|
||||
showOnlyErrors = true,
|
||||
@@ -73,12 +73,12 @@ class PushHistoryViewTest : RobolectricTest() {
|
||||
val menuContentDescription = activity!!.getString(CommonStrings.a11y_user_menu)
|
||||
onNodeWithContentDescription(menuContentDescription).performClick()
|
||||
onNodeWithText("Show only errors").performClick()
|
||||
eventsRecorder.assertSingle(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = false))
|
||||
eventsRecorder.assertSingle(PushHistoryEvent.SetShowOnlyErrors(showOnlyErrors = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on an invalid event has no effect`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvents>(expectEvents = false)
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvent>(expectEvents = false)
|
||||
setPushHistoryView(
|
||||
aPushHistoryState(
|
||||
pushHistoryItems = listOf(
|
||||
@@ -95,7 +95,7 @@ class PushHistoryViewTest : RobolectricTest() {
|
||||
|
||||
@Test
|
||||
fun `clicking on a valid event emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
|
||||
val eventsRecorder = EventsRecorder<PushHistoryEvent>()
|
||||
setPushHistoryView(
|
||||
aPushHistoryState(
|
||||
pushHistoryItems = listOf(
|
||||
@@ -111,7 +111,7 @@ class PushHistoryViewTest : RobolectricTest() {
|
||||
)
|
||||
onNodeWithText(A_FORMATTED_DATE).performClick()
|
||||
eventsRecorder.assertSingle(
|
||||
PushHistoryEvents.NavigateTo(
|
||||
PushHistoryEvent.NavigateTo(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomId = A_ROOM_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
|
||||
Reference in New Issue
Block a user