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