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

Add a "Push rules" entry in the developer options
This commit is contained in:
Benoit Marty
2026-08-26 13:54:07 +02:00
committed by GitHub
42 changed files with 1066 additions and 25 deletions
@@ -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()
}