diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/FileViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/FileViewerEntryPoint.kt new file mode 100644 index 0000000000..8f90a5802b --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/FileViewerEntryPoint.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.architecture.NodeInputs + +/** + * Renders content which is not a Matrix media as a file, the same way the media viewer does. + * + * The content is provided by the caller and written to a temporary file, so that it can be + * displayed, shared and saved on disk. + */ +interface FileViewerEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node + + /** + * @param filename the name of the file, used as the screen title and for the shared and saved files. + * @param mimeType the mime type of [content], it drives how the content is rendered. + * @param content the whole content of the file. + */ + data class Params( + val filename: String, + val mimeType: String, + val content: String, + ) : NodeInputs + + interface Callback : Plugin { + fun onDone() + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPoint.kt new file mode 100644 index 0000000000..2692e8461d --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPoint.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint + +@ContributesBinding(AppScope::class) +class DefaultFileViewerEntryPoint : FileViewerEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: FileViewerEntryPoint.Params, + callback: FileViewerEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(params, callback), + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerEvent.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerEvent.kt new file mode 100644 index 0000000000..f36e1b87cf --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerEvent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +sealed interface FileViewerEvent { + data object Share : FileViewerEvent + data object SaveOnDisk : FileViewerEvent +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerNode.kt new file mode 100644 index 0000000000..ee1b35c990 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerNode.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint + +@ContributesNode(AppScope::class) +@AssistedInject +class FileViewerNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: FileViewerPresenter.Factory, + private val textFileViewer: TextFileViewer, +) : Node(buildContext, plugins = plugins) { + private val callback: FileViewerEntryPoint.Callback = callback() + private val inputs = inputs() + + private val presenter = presenterFactory.create(params = inputs) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + FileViewerView( + state = state, + textFileViewer = textFileViewer, + onBackClick = callback::onDone, + modifier = modifier, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenter.kt new file mode 100644 index 0000000000..61be47ebb2 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenter.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import android.content.ActivityNotFoundException +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.core.net.toUri +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import io.element.android.libraries.androidutils.R as UtilsR + +@AssistedInject +class FileViewerPresenter( + @Assisted private val params: FileViewerEntryPoint.Params, + @ApplicationContext private val context: Context, + private val coroutineDispatchers: CoroutineDispatchers, + private val fileSizeFormatter: FileSizeFormatter, + private val fileExtensionExtractor: FileExtensionExtractor, + private val localMediaActions: LocalMediaActions, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(params: FileViewerEntryPoint.Params): FileViewerPresenter + } + + // Use a local snackbarDispatcher, because this screen can be rendered from any Node. + private val snackbarDispatcher = SnackbarDispatcher() + + @Composable + override fun present(): FileViewerState { + val coroutineScope = rememberCoroutineScope() + val localMedia = remember { mutableStateOf>(AsyncData.Uninitialized) } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + + localMediaActions.Configure() + + LaunchedEffect(Unit) { + suspend { createLocalMedia() }.runCatchingUpdatingState(localMedia) + } + + fun handleEvent(event: FileViewerEvent) { + when (event) { + FileViewerEvent.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) + FileViewerEvent.Share -> coroutineScope.share(localMedia.value) + } + } + + return FileViewerState( + filename = params.filename, + localMedia = localMedia.value, + snackbarMessage = snackbarMessage, + eventSink = ::handleEvent, + ) + } + + /** + * Write the content to a temporary file, so that it can be rendered, shared and saved on disk. + * The file is always written to the same location, so it does not need to be cleaned up. + */ + private suspend fun createLocalMedia(): LocalMedia = withContext(coroutineDispatchers.io) { + val folder = File(context.cacheDir, "temp/fileviewer").apply { mkdirs() } + val file = File(folder, params.filename) + file.writeText(params.content) + LocalMedia( + uri = file.toUri(), + info = MediaInfo( + filename = params.filename, + caption = null, + mimeType = params.mimeType, + fileSize = file.length(), + formattedFileSize = fileSizeFormatter.format(file.length()), + fileExtension = fileExtensionExtractor.extractFromName(params.filename), + senderId = null, + senderName = null, + senderAvatar = null, + dateSent = null, + dateSentFull = null, + waveform = null, + duration = null, + ), + ) + } + + private fun CoroutineScope.saveOnDisk(localMedia: AsyncData) = launch { + if (localMedia is AsyncData.Success) { + localMediaActions.saveOnDisk(localMedia.data) + .onSuccess { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_file_saved_on_disk_android)) + } + .onFailure { + snackbarDispatcher.post(SnackbarMessage(mediaActionsError(it))) + } + } + } + + private fun CoroutineScope.share(localMedia: AsyncData) = launch { + if (localMedia is AsyncData.Success) { + localMediaActions.share(localMedia.data) + .onFailure { + snackbarDispatcher.post(SnackbarMessage(mediaActionsError(it))) + } + } + } + + private fun mediaActionsError(throwable: Throwable): Int { + return if (throwable is ActivityNotFoundException) { + UtilsR.string.error_no_compatible_app_found + } else { + CommonStrings.error_unknown + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerState.kt new file mode 100644 index 0000000000..0edc198205 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +data class FileViewerState( + val filename: String, + val localMedia: AsyncData, + val snackbarMessage: SnackbarMessage?, + val eventSink: (FileViewerEvent) -> Unit, +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerStatePreviewParam.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerStatePreviewParam.kt new file mode 100644 index 0000000000..3f48dbdc31 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerStatePreviewParam.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import android.net.Uri +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +private const val A_FILENAME = "push_rules@alice_server.org.json" + +open class FileViewerStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aFileViewerState(), + aFileViewerState(localMedia = AsyncData.Failure(Exception("A failure"))), + aFileViewerState( + localMedia = AsyncData.Success( + LocalMedia( + uri = Uri.EMPTY, + info = aJsonMediaInfo(), + ) + ) + ), + ) +} + +fun aFileViewerState( + filename: String = A_FILENAME, + localMedia: AsyncData = AsyncData.Uninitialized, + eventSink: (FileViewerEvent) -> Unit = {}, +) = FileViewerState( + filename = filename, + localMedia = localMedia, + snackbarMessage = null, + eventSink = eventSink, +) + +fun aJsonMediaInfo( + filename: String = A_FILENAME, +) = MediaInfo( + filename = filename, + caption = null, + mimeType = MimeTypes.Json, + fileSize = 2 * 1024, + formattedFileSize = "2kB", + fileExtension = "json", + senderId = null, + senderName = null, + senderAvatar = null, + dateSent = null, + dateSentFull = null, + waveform = null, + duration = null, +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt new file mode 100644 index 0000000000..613e3af269 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.async.AsyncFailure +import io.element.android.libraries.designsystem.components.async.AsyncLoading +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView +import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun FileViewerView( + state: FileViewerState, + textFileViewer: TextFileViewer, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + Scaffold( + modifier = modifier, + topBar = { + FileViewerTopBar( + filename = state.filename, + actionsEnabled = state.localMedia.isSuccess(), + onBackClick = onBackClick, + onShareClick = { state.eventSink(FileViewerEvent.Share) }, + onSaveClick = { state.eventSink(FileViewerEvent.SaveOnDisk) }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + when (val localMedia = state.localMedia) { + AsyncData.Uninitialized, + is AsyncData.Loading -> AsyncLoading( + modifier = Modifier.padding(padding), + ) + is AsyncData.Failure -> AsyncFailure( + modifier = Modifier.padding(padding), + throwable = localMedia.error, + onRetry = null, + ) + is AsyncData.Success -> { + // Do not apply the padding, the content is rendered behind the translucent top bar, + // the same way the media viewer does. + LocalMediaView( + localMedia = localMedia.data, + bottomPaddingInPixels = 0, + audioFocus = null, + onClick = {}, + onOpenWith = null, + textFileViewer = textFileViewer, + forPreview = false, + modifier = Modifier.fillMaxSize(), + ) + } + } + } +} + +@Composable +private fun FileViewerTopBar( + filename: String, + actionsEnabled: Boolean, + onBackClick: () -> Unit, + onShareClick: () -> Unit, + onSaveClick: () -> Unit, +) { + TopAppBar( + titleStr = filename, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = bgCanvasWithTransparency, + ), + navigationIcon = { BackButton(onClick = onBackClick) }, + actions = { + IconButton( + onClick = onShareClick, + enabled = actionsEnabled, + ) { + Icon( + imageVector = CompoundIcons.ShareAndroid(), + contentDescription = stringResource(id = CommonStrings.action_share), + ) + } + IconButton( + onClick = onSaveClick, + enabled = actionsEnabled, + ) { + Icon( + imageVector = CompoundIcons.Download(), + contentDescription = stringResource(id = CommonStrings.action_download), + ) + } + }, + ) +} + +@PreviewsDayNight +@Composable +internal fun FileViewerViewPreview( + @PreviewParameter(FileViewerStatePreviewParam::class) state: FileViewerState, +) = ElementPreview { + FileViewerView( + state = state, + textFileViewer = { _, _ -> }, + onBackClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt index 01b44f1246..e370361696 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt @@ -59,7 +59,7 @@ fun LocalMediaView( forPreview = forPreview, modifier = modifier, ) - mimeType == MimeTypes.PlainText -> TextFileView( + mimeType == MimeTypes.PlainText || mimeType == MimeTypes.Json -> TextFileView( localMedia = localMedia, textFileViewer = textFileViewer, modifier = modifier, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPointTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPointTest.kt new file mode 100644 index 0000000000..2a84ad00b8 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/DefaultFileViewerEntryPointTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import io.element.android.tests.testutils.testCoroutineDispatchers +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultFileViewerEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultFileViewerEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + FileViewerNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { nodeParams -> + FileViewerPresenter( + params = nodeParams, + context = mockk(relaxed = true), + coroutineDispatchers = testCoroutineDispatchers(), + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + localMediaActions = FakeLocalMediaActions(), + ) + }, + textFileViewer = { _, _ -> lambdaError() }, + ) + } + val params = FileViewerEntryPoint.Params( + filename = "a file.json", + mimeType = MimeTypes.Json, + content = "{}", + ) + val callback = object : FileViewerEntryPoint.Callback { + override fun onDone() = lambdaError() + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(FileViewerNode::class.java) + assertThat(result.plugins).contains(params) + assertThat(result.plugins).contains(callback) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenterTest.kt new file mode 100644 index 0000000000..7a8a85d69e --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerPresenterTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import android.content.ActivityNotFoundException +import androidx.core.net.toFile +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import io.element.android.libraries.androidutils.R as UtilsR + +private const val A_FILENAME = "push_rules@alice_server.org.json" +private const val A_CONTENT = """{"global":{"override":[]}}""" + +class FileViewerPresenterTest : RobolectricTest() { + @Test + fun `present - writes the content to a file and exposes it as a local media`() = runTest { + val presenter = createFileViewerPresenter() + presenter.test { + assertThat(awaitItem().localMedia).isEqualTo(AsyncData.Uninitialized) + consumeItemsUntilPredicate { it.localMedia.isSuccess() }.last().also { state -> + assertThat(state.filename).isEqualTo(A_FILENAME) + val localMedia = state.localMedia.dataOrNull()!! + assertThat(localMedia.info.filename).isEqualTo(A_FILENAME) + assertThat(localMedia.info.mimeType).isEqualTo(MimeTypes.Json) + assertThat(localMedia.info.fileExtension).isEqualTo("json") + val file = localMedia.uri.toFile() + assertThat(file.name).isEqualTo(A_FILENAME) + assertThat(file.readText()).isEqualTo(A_CONTENT) + } + } + } + + @Test + fun `present - SaveOnDisk event saves the file and posts a snackbar message`() = runTest { + val saveOnDiskResult = lambdaRecorder> { Result.success(Unit) } + val presenter = createFileViewerPresenter( + localMediaActions = FakeLocalMediaActions(saveOnDiskResult = saveOnDiskResult), + ) + presenter.test { + consumeItemsUntilPredicate { it.localMedia.isSuccess() }.last().eventSink(FileViewerEvent.SaveOnDisk) + consumeItemsUntilPredicate { it.snackbarMessage != null }.last().also { state -> + assertThat(state.snackbarMessage?.messageResId).isEqualTo(CommonStrings.common_file_saved_on_disk_android) + } + saveOnDiskResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - Share event failure posts a snackbar message`() = runTest { + val shareResult = lambdaRecorder> { Result.failure(ActivityNotFoundException()) } + val presenter = createFileViewerPresenter( + localMediaActions = FakeLocalMediaActions(shareResult = shareResult), + ) + presenter.test { + consumeItemsUntilPredicate { it.localMedia.isSuccess() }.last().eventSink(FileViewerEvent.Share) + consumeItemsUntilPredicate { it.snackbarMessage != null }.last().also { state -> + assertThat(state.snackbarMessage?.messageResId).isEqualTo(UtilsR.string.error_no_compatible_app_found) + } + shareResult.assertions().isCalledOnce() + } + } + + private fun TestScope.createFileViewerPresenter( + params: FileViewerEntryPoint.Params = FileViewerEntryPoint.Params( + filename = A_FILENAME, + mimeType = MimeTypes.Json, + content = A_CONTENT, + ), + localMediaActions: LocalMediaActions = FakeLocalMediaActions(), + ) = FileViewerPresenter( + params = params, + context = RuntimeEnvironment.getApplication(), + coroutineDispatchers = testCoroutineDispatchers(), + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + localMediaActions = localMediaActions, + ) +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerViewTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerViewTest.kt new file mode 100644 index 0000000000..96f5de7007 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerViewTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalTestApi::class) + +package io.element.android.libraries.mediaviewer.impl.fileviewer + +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.annotation.StringRes +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class FileViewerViewTest : RobolectricTest() { + @Test + fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setFileViewerView( + state = aFileViewerState(eventSink = eventsRecorder), + onBackClick = it, + ) + pressBack() + } + } + + @Test + fun `clicking on share emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setFileViewerView( + state = aFileViewerState( + localMedia = AsyncData.Success(LocalMedia(Uri.EMPTY, aJsonMediaInfo())), + eventSink = eventsRecorder, + ), + ) + clickOnContentDescription(CommonStrings.action_share) + eventsRecorder.assertSingle(FileViewerEvent.Share) + } + + @Test + fun `clicking on download emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setFileViewerView( + state = aFileViewerState( + localMedia = AsyncData.Success(LocalMedia(Uri.EMPTY, aJsonMediaInfo())), + eventSink = eventsRecorder, + ), + ) + clickOnContentDescription(CommonStrings.action_download) + eventsRecorder.assertSingle(FileViewerEvent.SaveOnDisk) + } +} + +private fun AndroidComposeUiTest.clickOnContentDescription(@StringRes res: Int) { + val text = activity!!.getString(res) + onNode(hasContentDescription(text) and hasClickAction()).performClick() +} + +private fun AndroidComposeUiTest.setFileViewerView( + state: FileViewerState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + FileViewerView( + state = state, + textFileViewer = { _, _ -> }, + onBackClick = onBackClick, + ) + } +} diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeFileViewerEntryPoint.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeFileViewerEntryPoint.kt new file mode 100644 index 0000000000..0379bff47d --- /dev/null +++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeFileViewerEntryPoint.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeFileViewerEntryPoint : FileViewerEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: FileViewerEntryPoint.Params, + callback: FileViewerEntryPoint.Callback, + ): Node = lambdaError() +}