Merge pull request #7577 from element-hq/feature/bma/explorePushRules

Add a "Push rules" entry in the developer options
This commit is contained in:
Benoit Marty
2026-08-26 13:54:07 +02:00
committed by GitHub
42 changed files with 1066 additions and 25 deletions
+2 -1
View File
@@ -50,9 +50,10 @@ PRs must meet these rules.
- Title = changelog entry — descriptive, no "Fixes #…".
- Leave description template for the developer. Redirect them to the [contributing etiquette](CONTRIBUTING.md#etiquette).
- Screenshots/videos for visual changes.
- 500 additions max — split large changes.
- 500 additions max into production code. Test code can be larger — split large changes.
- Commits need a title and description; no tiny or massive commits.
- No history rewrites.
- When creating a pull request and if changes contain new or update(s) of the Composable Previews, add the label "Record-Screenshots" to the PR so that the CI will record the screenshots.
---
@@ -64,6 +64,7 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
@@ -91,6 +92,7 @@ dependencies {
implementation(libs.color.picker)
implementation(libs.androidx.browser)
implementation(libs.androidx.datastore.preferences)
implementation(libs.serialization.json)
api(projects.features.preferences.api)
implementation(libs.showkase)
@@ -108,6 +110,7 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.libraries.roomselect.test)
testImplementation(projects.libraries.mediaviewer.test)
testImplementation(projects.libraries.troubleshoot.test)
testImplementation(projects.features.deactivation.test)
testImplementation(projects.features.enterprise.test)
@@ -40,10 +40,12 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.canPop
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
import kotlinx.parcelize.Parcelize
@@ -56,6 +58,7 @@ class PreferencesFlowNode(
private val lockScreenEntryPoint: LockScreenEntryPoint,
private val notificationTroubleShootEntryPoint: NotificationTroubleShootEntryPoint,
private val pushHistoryEntryPoint: PushHistoryEntryPoint,
private val fileViewerEntryPoint: FileViewerEntryPoint,
private val logoutEntryPoint: LogoutEntryPoint,
private val openSourceLicensesEntryPoint: OpenSourceLicensesEntryPoint,
private val accountDeactivationEntryPoint: AccountDeactivationEntryPoint,
@@ -95,6 +98,9 @@ class PreferencesFlowNode(
@Parcelize
data object PushHistory : NavTarget
@Parcelize
data class PushRules(val filename: String, val content: String) : NavTarget
@Parcelize
data object LockScreenSettings : NavTarget
@@ -187,6 +193,10 @@ class PreferencesFlowNode(
}
NavTarget.DeveloperSettings -> {
val developerSettingsCallback = object : DeveloperSettingsNode.Callback {
override fun navigateToPushRules(filename: String, content: String) {
backstack.push(NavTarget.PushRules(filename = filename, content = content))
}
override fun navigateToPushHistory() {
backstack.push(NavTarget.PushHistory)
}
@@ -270,6 +280,26 @@ class PreferencesFlowNode(
},
)
}
is NavTarget.PushRules -> {
fileViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = FileViewerEntryPoint.Params(
filename = navTarget.filename,
mimeType = MimeTypes.Json,
content = navTarget.content,
),
callback = object : FileViewerEntryPoint.Callback {
override fun onDone() {
if (backstack.canPop()) {
backstack.pop()
} else {
navigateUp()
}
}
},
)
}
is NavTarget.EditDefaultNotificationSetting -> {
val callback = object : EditDefaultNotificationSettingNode.Callback {
override fun navigateToRoomNotificationSettings(roomId: RoomId) {
@@ -17,4 +17,6 @@ sealed interface DeveloperSettingsEvent {
data object VacuumStores : DeveloperSettingsEvent
data class MarkAllRoomsAsRead(val needsConfirmation: Boolean) : DeveloperSettingsEvent
data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent
data object OpenPushRules : DeveloperSettingsEvent
data object DismissPushRulesError : DeveloperSettingsEvent
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.developer
/**
* Navigation performed by the developer settings screen, when data has to be loaded first.
*/
fun interface DeveloperSettingsNavigator {
fun openPushRules(filename: String, content: String)
}
@@ -28,15 +28,22 @@ import io.element.android.libraries.di.SessionScope
class DeveloperSettingsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: DeveloperSettingsPresenter,
presenterFactory: DeveloperSettingsPresenter.Factory,
private val extraDeveloperOptionsRenderer: ExtraDeveloperOptionsRenderer,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins),
DeveloperSettingsNavigator {
interface Callback : Plugin {
fun navigateToPushRules(filename: String, content: String)
fun navigateToPushHistory()
fun onDone()
}
private val callback: Callback = callback()
private val presenter = presenterFactory.create(navigator = this)
override fun openPushRules(filename: String, content: String) {
callback.navigateToPushRules(filename = filename, content = content)
}
@Composable
override fun View(modifier: Modifier) {
@@ -17,7 +17,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.toArgb
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
@@ -30,17 +32,23 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.data.ByteUnit
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
@Inject
@AssistedInject
class DeveloperSettingsPresenter(
@Assisted private val navigator: DeveloperSettingsNavigator,
private val appDeveloperSettingsPresenter: Presenter<AppDeveloperSettingsState>,
private val sessionId: SessionId,
private val deviceId: DeviceId,
@@ -52,7 +60,13 @@ class DeveloperSettingsPresenter(
private val fileSizeFormatter: FileSizeFormatter,
private val markAllRoomsAsRead: MarkAllRoomsAsRead,
private val buildMeta: BuildMeta,
private val notificationSettingsService: NotificationSettingsService,
) : Presenter<DeveloperSettingsState> {
@AssistedFactory
fun interface Factory {
fun create(navigator: DeveloperSettingsNavigator): DeveloperSettingsPresenter
}
@Composable
override fun present(): DeveloperSettingsState {
val cacheSize = remember {
@@ -67,6 +81,9 @@ class DeveloperSettingsPresenter(
val markAllRoomsAsReadAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val pushRulesAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
var showColorPicker by remember {
mutableStateOf(false)
}
@@ -109,6 +126,10 @@ class DeveloperSettingsPresenter(
DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation -> {
markAllRoomsAsReadAction.value = AsyncAction.Uninitialized
}
DeveloperSettingsEvent.OpenPushRules -> coroutineScope.openPushRules(pushRulesAction)
DeveloperSettingsEvent.DismissPushRulesError -> {
pushRulesAction.value = AsyncAction.Uninitialized
}
}
}
@@ -119,6 +140,7 @@ class DeveloperSettingsPresenter(
databaseSizes = databaseSizes.value,
clearCacheAction = clearCacheAction.value,
markAllRoomsAsReadAction = markAllRoomsAsReadAction.value,
pushRulesAction = pushRulesAction.value,
isEnterpriseBuild = buildMeta.isEnterpriseBuild,
showColorPicker = showColorPicker,
deviceId = deviceId,
@@ -164,4 +186,40 @@ class DeveloperSettingsPresenter(
markAllRoomsAsRead().getOrThrow()
}.runCatchingUpdatingState(state = markAllRoomsAsReadAction)
}
private fun CoroutineScope.openPushRules(pushRulesAction: MutableState<AsyncAction<Unit>>) = launch {
pushRulesAction.value = AsyncAction.Loading
notificationSettingsService.getRawPushRules()
.onSuccess { content ->
pushRulesAction.value = AsyncAction.Uninitialized
navigator.openPushRules(
filename = pushRulesFilename(),
content = content.orEmpty().prettyPrintJson(),
)
}
.onFailure {
pushRulesAction.value = AsyncAction.Failure(it)
}
}
/**
* The user id contains a colon, which is not a valid character for a file name on all the file systems,
* so replace it by an underscore.
*/
private fun pushRulesFilename() = "push_rules${sessionId.value.replace(':', '_')}.json"
}
@OptIn(ExperimentalSerializationApi::class)
private val prettyPrintJson = Json {
prettyPrint = true
// Keep the indentation small, for a better rendering on mobile.
prettyPrintIndent = " "
}
/**
* Pretty print this json content, so that it is readable when rendered.
* Return the content as is if it is not valid json.
*/
private fun String.prettyPrintJson(): String = runCatchingExceptions {
prettyPrintJson.encodeToString(JsonElement.serializer(), prettyPrintJson.parseToJsonElement(this))
}.getOrDefault(this)
@@ -20,10 +20,13 @@ data class DeveloperSettingsState(
val databaseSizes: AsyncData<ImmutableMap<String, String>>,
val clearCacheAction: AsyncAction<Unit>,
val markAllRoomsAsReadAction: AsyncAction<Unit>,
val pushRulesAction: AsyncAction<Unit>,
val isEnterpriseBuild: Boolean,
val showColorPicker: Boolean,
val deviceId: DeviceId,
val eventSink: (DeveloperSettingsEvent) -> Unit
) {
val showLoader = clearCacheAction is AsyncAction.Loading || markAllRoomsAsReadAction is AsyncAction.Loading
val showLoader = clearCacheAction is AsyncAction.Loading ||
markAllRoomsAsReadAction is AsyncAction.Loading ||
pushRulesAction is AsyncAction.Loading
}
@@ -32,6 +32,9 @@ open class DeveloperSettingsStatePreviewParam : PreviewParameterProvider<Develop
// at io.mhssn.colorpicker.pickers.ClassicColorPickerKt$ClassicColorPicker$1$1.invokeSuspend(ClassicColorPicker.kt:53)
showColorPicker = false,
),
aDeveloperSettingsState(
pushRulesAction = AsyncAction.Failure(Exception("A failure"))
),
)
}
@@ -39,6 +42,7 @@ fun aDeveloperSettingsState(
appDeveloperSettingsState: AppDeveloperSettingsState = anAppDeveloperSettingsState(),
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
markAllRoomsAsReadAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
pushRulesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
isEnterpriseBuild: Boolean = false,
showColorPicker: Boolean = false,
deviceId: DeviceId = DeviceId("ILAKNDNASDLK"),
@@ -49,6 +53,7 @@ fun aDeveloperSettingsState(
databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")),
clearCacheAction = clearCacheAction,
markAllRoomsAsReadAction = markAllRoomsAsReadAction,
pushRulesAction = pushRulesAction,
isEnterpriseBuild = isEnterpriseBuild,
showColorPicker = showColorPicker,
deviceId = deviceId,
@@ -23,8 +23,10 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@@ -60,6 +62,12 @@ fun DeveloperSettingsView(
onDismiss = { state.eventSink(DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation) },
)
}
(state.pushRulesAction as? AsyncAction.Failure)?.let { failure ->
ErrorDialog(
content = failure.error.message ?: stringResource(CommonStrings.error_unknown),
onSubmit = { state.eventSink(DeveloperSettingsEvent.DismissPushRulesError) },
)
}
BackHandler(
enabled = !state.showLoader,
onBack = onBackClick,
@@ -79,7 +87,10 @@ fun DeveloperSettingsView(
onOpenShowkase = onOpenShowkase,
)
SessionCategory(deviceId = state.deviceId)
NotificationCategory(onPushHistoryClick)
NotificationCategory(
onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) },
onPushHistoryClick = onPushHistoryClick,
)
MarkAllRoomsAsReadCategory(state)
if (state.isEnterpriseBuild) {
@@ -217,8 +228,17 @@ private fun MarkAllRoomsAsReadCategory(state: DeveloperSettingsState) {
}
@Composable
private fun NotificationCategory(onPushHistoryClick: () -> Unit) {
private fun NotificationCategory(
onPushRulesClick: () -> Unit,
onPushHistoryClick: () -> Unit,
) {
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) {
ListItem(
content = {
Text("Push rules")
},
onClick = onPushRulesClick,
)
ListItem(
content = {
Text(stringResource(R.string.troubleshoot_notifications_entry_point_push_history_title))
@@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.mediaviewer.test.FakeFileViewerEntryPoint
import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint
import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
@@ -43,6 +44,7 @@ class DefaultPreferencesEntryPointTest {
lockScreenEntryPoint = FakeLockScreenEntryPoint(),
notificationTroubleShootEntryPoint = FakeNotificationTroubleShootEntryPoint(),
pushHistoryEntryPoint = FakePushHistoryEntryPoint(),
fileViewerEntryPoint = FakeFileViewerEntryPoint(),
logoutEntryPoint = FakeLogoutEntryPoint(),
openSourceLicensesEntryPoint = FakeOpenSourceLicensesEntryPoint(),
accountDeactivationEntryPoint = FakeAccountDeactivationEntryPoint(),
@@ -28,10 +28,13 @@ import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
@@ -41,6 +44,19 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
private const val A_PUSH_RULES_CONTENT = """{"global":{"override":[{"rule_id":".m.rule.master","enabled":false}]}}"""
private const val A_PUSH_RULES_CONTENT_PRETTY_PRINTED = """{
"global": {
"override": [
{
"rule_id": ".m.rule.master",
"enabled": false
}
]
}
}"""
private val AN_EXCEPTION = Exception("A failure")
class DeveloperSettingsPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -170,7 +186,72 @@ class DeveloperSettingsPresenterTest {
}
}
@Test
fun `present - OpenPushRules event navigates to the push rules content`() = runTest {
val openPushRulesLambda = lambdaRecorder<String, String, Unit> { _, _ -> }
val presenter = createDeveloperSettingsPresenter(
navigator = DeveloperSettingsNavigator(openPushRulesLambda),
notificationSettingsService = FakeNotificationSettingsService(
getRawPushRulesResult = { Result.success(A_PUSH_RULES_CONTENT) },
),
)
presenter.test {
val state = awaitItem()
assertThat(state.pushRulesAction).isEqualTo(AsyncAction.Uninitialized)
state.eventSink(DeveloperSettingsEvent.OpenPushRules)
skipItems(1)
awaitItem().also {
assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Uninitialized)
}
openPushRulesLambda.assertions().isCalledOnce().with(
value("push_rules@alice_server.org.json"),
value(A_PUSH_RULES_CONTENT_PRETTY_PRINTED),
)
}
}
@Test
fun `present - OpenPushRules event keeps the content as is when it is not valid json`() = runTest {
val openPushRulesLambda = lambdaRecorder<String, String, Unit> { _, _ -> }
val presenter = createDeveloperSettingsPresenter(
navigator = DeveloperSettingsNavigator(openPushRulesLambda),
notificationSettingsService = FakeNotificationSettingsService(
getRawPushRulesResult = { Result.success("not json") },
),
)
presenter.test {
awaitItem().eventSink(DeveloperSettingsEvent.OpenPushRules)
skipItems(2)
openPushRulesLambda.assertions().isCalledOnce().with(
value("push_rules@alice_server.org.json"),
value("not json"),
)
}
}
@Test
fun `present - OpenPushRules event failure can be dismissed`() = runTest {
val presenter = createDeveloperSettingsPresenter(
notificationSettingsService = FakeNotificationSettingsService(
getRawPushRulesResult = { Result.failure(AN_EXCEPTION) },
),
)
presenter.test {
val state = awaitItem()
state.eventSink(DeveloperSettingsEvent.OpenPushRules)
skipItems(1)
awaitItem().also {
assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
it.eventSink(DeveloperSettingsEvent.DismissPushRulesError)
}
awaitItem().also {
assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Uninitialized)
}
}
}
private fun createDeveloperSettingsPresenter(
navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() },
sessionId: SessionId = A_SESSION_ID,
deviceId: DeviceId = A_DEVICE_ID,
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
@@ -180,8 +261,10 @@ class DeveloperSettingsPresenterTest {
databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(),
buildMeta: BuildMeta = aBuildMeta(),
notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
): DeveloperSettingsPresenter {
return DeveloperSettingsPresenter(
navigator = navigator,
appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() },
sessionId = sessionId,
deviceId = deviceId,
@@ -193,6 +276,7 @@ class DeveloperSettingsPresenterTest {
fileSizeFormatter = FakeFileSizeFormatter(),
markAllRoomsAsRead = markAllRoomsAsRead,
buildMeta = buildMeta,
notificationSettingsService = notificationSettingsService,
)
}
}
@@ -57,6 +57,19 @@ class DeveloperSettingsViewTest : RobolectricTest() {
}
}
@Config(qualifiers = "h2400dp")
@Test
fun `clicking on push rules emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvent>()
setDeveloperSettingsView(
state = aDeveloperSettingsState(
eventSink = eventsRecorder
),
)
onNodeWithText("Push rules").performScrollTo().performClick()
eventsRecorder.assertSingle(DeveloperSettingsEvent.OpenPushRules)
}
@Config(qualifiers = "h2000dp")
@Test
fun `clicking on open showkase invokes the expected callback`() = runAndroidComposeUiTest {
@@ -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()
}
}
@@ -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 = {},
)
}
@@ -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,
@@ -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,
)
}
}
@@ -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()
}