Add a FileViewerEntryPoint to render in-memory content as a file

Introduce io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint, a
reusable screen which renders content that is not a Matrix media the same way the
media viewer renders a file, with Share and Download actions.

The caller provides a filename, a mime type and the whole content as a String.
The node writes it to a temporary file in the cache directory, then reuses
LocalMediaView (so text content goes through TextFileViewer) and LocalMediaActions
for sharing and saving on disk. The node is contributed to AppScope so that the
screen can be used from any graph, since MediaViewerNode itself is bound to
RoomScope and depends on the timeline data sources.

Also route MimeTypes.Json to TextFileView in LocalMediaView, so that a json file
is rendered as text instead of falling back to the generic file card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-08-26 12:11:04 +02:00
parent a2d3488426
commit 0801a415b5
13 changed files with 775 additions and 1 deletions
@@ -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()
}