Merge branch 'develop' into fix/1874-empty-formatted-body

This commit is contained in:
hayaksi1
2026-08-30 02:04:57 +03:00
612 changed files with 5683 additions and 2482 deletions
@@ -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() }
@@ -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()
}
}
@@ -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)
}
@@ -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,
@@ -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,
)
}
}
}
+4
View File
@@ -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)
}
@@ -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)
}
}
@@ -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
}
}
@@ -56,7 +56,7 @@ class DefaultPinnedMessagesBannerFormatter(
is PollContent -> {
content.question.prefixWith(CommonStrings.a11y_poll)
}
RedactedContent -> {
is RedactedContent -> {
sp.getString(CommonStrings.common_message_removed)
}
else -> {
@@ -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)
}
@@ -62,7 +62,7 @@ class DefaultTimelineEventFormatter(
is CallNotifyContent -> {
sp.getString(CommonStrings.common_call_started)
}
RedactedContent,
is RedactedContent,
is LegacyCallInviteContent,
is StickerContent,
is PollContent,
@@ -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)
}
@@ -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)
@@ -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
}
@@ -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
}
@@ -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,
)
@@ -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,
@@ -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()
}
}
@@ -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()
}
@@ -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
}
@@ -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,
@@ -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
}
@@ -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() }
@@ -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())
}
@@ -81,7 +81,7 @@ class TimelineEventContentMapper(
)
}
is MsgLikeKind.Redacted -> {
RedactedContent
RedactedContent(threadInfo = extractThreadInfo(it.content))
}
is MsgLikeKind.Poll -> {
PollContent(
@@ -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
@@ -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,
@@ -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,
@@ -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,
)
@@ -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,
)
}
},
@@ -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(
@@ -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 {
@@ -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()
}
}
@@ -79,7 +79,7 @@ class EventItemFactory(
LegacyCallInviteContent,
is PollContent,
is ProfileChangeContent,
RedactedContent,
is RedactedContent,
is RoomMembershipContent,
is StateContent,
is StickerContent,
@@ -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),
)
}
}
@@ -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
}
@@ -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,
)
}
}
@@ -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
}
}
}
@@ -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,
)
@@ -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,
)
@@ -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 = {},
)
}
@@ -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)
}
}
}
@@ -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,
@@ -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,
),
@@ -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)
}
}
@@ -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,
)
}
@@ -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,
)
}
}
@@ -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(),
@@ -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()
}
@@ -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
}
@@ -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,
)
@@ -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,
@@ -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
@@ -298,6 +298,7 @@ class DefaultNotifiableEventResolver(
roomId = roomId,
eventId = eventId,
cause = "Unable to decrypt event content",
noisy = isNoisy,
)
ResolvedPushEvent.Event(fallbackNotifiableEvent)
}
@@ -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,
)
@@ -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,
@@ -170,6 +170,7 @@ class DefaultNotificationResultProcessor(
roomId = RoomId(request.roomId),
eventId = EventId(request.eventId),
cause = exception.message,
noisy = false,
)
)
}
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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",
)
@@ -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,
@@ -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()
@@ -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",
)
)
}
}
@@ -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
@@ -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"
@@ -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,
)
@@ -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
}
@@ -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)
}
}
@@ -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()
}
@@ -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(
@@ -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(),
)
@@ -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
}
@@ -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 {
@@ -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,
)
@@ -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(),
@@ -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))
}
}
)
@@ -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)
}
@@ -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),
)
)
}
@@ -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()
}
}
@@ -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,