diff --git a/features/viewfolder/api/build.gradle.kts b/features/viewfolder/api/build.gradle.kts index b24ad7490e..4df696b2b2 100644 --- a/features/viewfolder/api/build.gradle.kts +++ b/features/viewfolder/api/build.gradle.kts @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ plugins { - id("io.element.android-library") + id("io.element.android-compose-library") } android { diff --git a/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/TextFileViewer.kt b/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/TextFileViewer.kt new file mode 100644 index 0000000000..fa1d3511e4 --- /dev/null +++ b/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/TextFileViewer.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.collections.immutable.ImmutableList + +fun interface TextFileViewer { + @Composable + fun Render( + lines: ImmutableList, + modifier: Modifier, + ) +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt new file mode 100644 index 0000000000..55743f54cc --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.features.viewfolder.impl.file.ColorationMode +import io.element.android.features.viewfolder.impl.file.FileContent +import io.element.android.libraries.di.AppScope +import kotlinx.collections.immutable.ImmutableList +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultTextFileViewer @Inject constructor() : TextFileViewer { + @Composable + override fun Render( + lines: ImmutableList, + modifier: Modifier + ) { + FileContent( + lines = lines, + colorationMode = ColorationMode.None, + modifier = modifier + ) + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt index bae8166f49..f6780504ad 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt @@ -115,7 +115,7 @@ fun ViewFileView( } @Composable -private fun FileContent( +internal fun FileContent( lines: ImmutableList, colorationMode: ColorationMode, modifier: Modifier = Modifier, diff --git a/libraries/mediaviewer/impl/build.gradle.kts b/libraries/mediaviewer/impl/build.gradle.kts index bbe984fe9c..46a9944a84 100644 --- a/libraries/mediaviewer/impl/build.gradle.kts +++ b/libraries/mediaviewer/impl/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(libs.vanniktech.blurhash) implementation(libs.telephoto.flick) + implementation(projects.features.viewfolder.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.core) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt index 8120788c5f..3d3276c612 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.libraries.di.AppScope import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer @@ -20,7 +21,9 @@ import me.saket.telephoto.zoomable.rememberZoomableState import javax.inject.Inject @ContributesBinding(AppScope::class) -class DefaultLocalMediaRenderer @Inject constructor() : LocalMediaRenderer { +class DefaultLocalMediaRenderer @Inject constructor( + private val textFileViewer: TextFileViewer, +) : LocalMediaRenderer { @Composable override fun Render(localMedia: LocalMedia) { val localMediaViewState = rememberLocalMediaViewState( @@ -33,6 +36,7 @@ class DefaultLocalMediaRenderer @Inject constructor() : LocalMediaRenderer { bottomPaddingInPixels = 0, localMedia = localMedia, localMediaViewState = localMediaViewState, + textFileViewer = textFileViewer, onClick = {} ) } 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 8752b19080..f58bd34066 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 @@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.impl.local import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage @@ -19,6 +20,7 @@ import io.element.android.libraries.mediaviewer.impl.local.audio.MediaAudioView import io.element.android.libraries.mediaviewer.impl.local.file.MediaFileView import io.element.android.libraries.mediaviewer.impl.local.image.MediaImageView import io.element.android.libraries.mediaviewer.impl.local.pdf.MediaPdfView +import io.element.android.libraries.mediaviewer.impl.local.txt.TxtFileView import io.element.android.libraries.mediaviewer.impl.local.video.MediaVideoView @Composable @@ -26,6 +28,7 @@ fun LocalMediaView( localMedia: LocalMedia?, bottomPaddingInPixels: Int, onClick: () -> Unit, + textFileViewer: TextFileViewer, modifier: Modifier = Modifier, isDisplayed: Boolean = true, localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(), @@ -46,6 +49,11 @@ fun LocalMediaView( localMedia = localMedia, modifier = modifier, ) + mimeType == MimeTypes.PlainText -> TxtFileView( + localMedia = localMedia, + textFileViewer = textFileViewer, + modifier = modifier, + ) mimeType == MimeTypes.Pdf -> MediaPdfView( localMediaViewState = localMediaViewState, localMedia = localMedia, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfViewer.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfViewer.kt index b8b87f4605..3493c277cb 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfViewer.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfViewer.kt @@ -37,6 +37,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.roundToPx import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.impl.viewer.topAppBarHeight import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import me.saket.telephoto.zoomable.zoomable @@ -126,7 +127,7 @@ private fun PdfPagesContentView( ) { // Add a fake item to the top so that the first item is not at the top of the screen. item { - Spacer(modifier = Modifier.height(80.dp)) + Spacer(modifier = Modifier.height(topAppBarHeight)) } items(pdfPages.size) { index -> val pdfPage = pdfPages[index] diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TxtFileProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TxtFileProvider.kt new file mode 100644 index 0000000000..29b50ef85b --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TxtFileProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2025 New Vector 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.local.txt + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class TxtFileProvider : PreviewParameterProvider>> { + override val values: Sequence>> + get() = sequenceOf( + AsyncData.Uninitialized, + AsyncData.Loading(), + AsyncData.Success(persistentListOf("Hello, World!")), + AsyncData.Failure(Exception("Failed to load text")), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TxtFileView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TxtFileView.kt new file mode 100644 index 0000000000..461efa0dc9 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TxtFileView.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2025 New Vector 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.local.txt + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.viewer.topAppBarHeight +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun TxtFileView( + localMedia: LocalMedia?, + textFileViewer: TextFileViewer, + modifier: Modifier = Modifier, +) { + val data = remember { mutableStateOf>>(AsyncData.Uninitialized) } + val context = LocalContext.current + LaunchedEffect(localMedia?.uri) { + data.value = AsyncData.Loading() + if (localMedia?.uri != null) { + // Load the file content + val result = runCatching { + context.contentResolver.openInputStream(localMedia.uri).use { + it?.bufferedReader()?.readLines()?.toList().orEmpty() + } + } + data.value = if (result.isSuccess) { + AsyncData.Success(result.getOrNull().orEmpty().toImmutableList()) + } else { + AsyncData.Failure(result.exceptionOrNull() ?: Exception("An error occurred")) + } + } + } + TxtFileViewContent( + data = data.value, + textFileViewer = textFileViewer, + modifier = modifier, + ) +} + +@Composable +private fun TxtFileViewContent( + data: AsyncData>, + textFileViewer: TextFileViewer, + modifier: Modifier = Modifier, +) { + when (data) { + AsyncData.Uninitialized, + is AsyncData.Loading -> Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + is AsyncData.Failure -> Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = data.error.message ?: stringResource(id = CommonStrings.error_unknown)) + } + is AsyncData.Success -> { + textFileViewer.Render( + lines = data.data, + modifier = modifier + .fillMaxSize() + .padding(top = topAppBarHeight), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TxtFileViewPreview( + @PreviewParameter(TxtFileProvider::class) text: AsyncData>, +) = ElementPreview { + TxtFileViewContent( + data = text, + textFileViewer = { lines, modifier -> + Text( + modifier = modifier, + text = lines.firstOrNull() ?: "File content" + ) + } + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index d994f374f9..44d41ee794 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -17,6 +17,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.compound.theme.ForcedDarkElementTheme +import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.libraries.architecture.inputs import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.RoomScope @@ -42,6 +43,7 @@ class MediaViewerNode @AssistedInject constructor( coroutineDispatchers: CoroutineDispatchers, systemClock: SystemClock, pagerKeysHandler: PagerKeysHandler, + private val textFileViewer: TextFileViewer, ) : Node(buildContext, plugins = plugins), MediaViewerNavigator { private val inputs = inputs() @@ -128,6 +130,7 @@ class MediaViewerNode @AssistedInject constructor( val state = presenter.present() MediaViewerView( state = state, + textFileViewer = textFileViewer, modifier = modifier, onBackClick = ::onDone ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 5ebe8e8087..501c434674 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import io.element.android.compound.theme.ElementTheme 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.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo @@ -83,9 +84,12 @@ import me.saket.telephoto.zoomable.OverzoomEffect import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.rememberZoomableState +val topAppBarHeight = 88.dp + @Composable fun MediaViewerView( state: MediaViewerState, + textFileViewer: TextFileViewer, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -143,6 +147,7 @@ fun MediaViewerView( showOverlay = showOverlay, bottomPaddingInPixels = bottomPaddingInPixels, data = dataForPage, + textFileViewer = textFileViewer, onDismiss = onBackClick, onRetry = { state.eventSink(MediaViewerEvents.LoadMedia(dataForPage)) @@ -267,6 +272,7 @@ private fun MediaViewerPage( showOverlay: Boolean, bottomPaddingInPixels: Int, data: MediaViewerPageData.MediaViewerData, + textFileViewer: TextFileViewer, onDismiss: () -> Unit, onRetry: () -> Unit, onDismissError: () -> Unit, @@ -316,6 +322,7 @@ private fun MediaViewerPage( localMediaViewState = localMediaViewState, localMedia = downloadedMedia.dataOrNull(), mediaInfo = data.mediaInfo, + textFileViewer = textFileViewer, onClick = { if (playableState is PlayableState.NotPlayable) { currentOnShowOverlayChange(!currentShowOverlay) @@ -563,6 +570,7 @@ private fun ErrorView( internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark { MediaViewerView( state = state, + textFileViewer = { _, _ -> }, onBackClick = {} ) }