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/FileContent.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt new file mode 100644 index 0000000000..8a233d4506 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt @@ -0,0 +1,152 @@ +/* + * 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.file + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.androidutils.system.copyToClipboard +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun FileContent( + lines: ImmutableList, + colorationMode: ColorationMode, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + ) { + if (lines.isEmpty()) { + item { + Spacer(Modifier.size(80.dp)) + Text( + text = stringResource(CommonStrings.common_empty_file), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.fillMaxWidth() + ) + } + } else { + itemsIndexed( + items = lines, + ) { index, line -> + LineRow( + lineNumber = index + 1, + line = line, + colorationMode = colorationMode, + ) + } + } + } +} + +@Composable +private fun LineRow( + lineNumber: Int, + line: String, + colorationMode: ColorationMode, +) { + val context = LocalContext.current + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = { + context.copyToClipboard( + text = line, + toastMessage = context.getString(CommonStrings.common_line_copied_to_clipboard), + ) + }) + ) { + Text( + modifier = Modifier + .widthIn(min = 36.dp) + .padding(horizontal = 4.dp), + text = "$lineNumber", + textAlign = TextAlign.End, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdMedium, + ) + val color = ElementTheme.colors.textSecondary + val width = 0.5.dp.value + Text( + modifier = Modifier + .weight(1f) + .drawWithContent { + // Using .height(IntrinsicSize.Min) on the Row does not work well inside LazyColumn + drawLine( + color = color, + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = width + ) + drawContent() + } + .padding(horizontal = 4.dp), + text = line, + color = line.toColor(colorationMode), + style = ElementTheme.typography.fontBodyMdRegular + ) + } +} + +/** + * Convert a line to a color. + * Ex for logcat: + * `01-23 13:14:50.740 25818 25818 D org.matrix.rust.sdk: elementx: SyncIndicator = Hide | RustRoomListService.kt:81` + * ^ use this char to determine the color + * Ex for Rust logs: + * `2024-01-26T10:22:26.947416Z WARN elementx: Restore with non-empty map | MatrixClientsHolder.kt:68` + * ^ use this char to determine the color, see [LogLevel] + */ +@Composable +private fun String.toColor(colorationMode: ColorationMode): Color { + return when (colorationMode) { + ColorationMode.Logcat -> when (getOrNull(31)) { + 'D' -> colorDebug + 'I' -> colorInfo + 'W' -> colorWarning + 'E' -> colorError + 'A' -> colorError + else -> ElementTheme.colors.textPrimary + } + ColorationMode.RustLogs -> when (getOrNull(32)) { + 'E' -> ElementTheme.colors.textPrimary + 'G' -> colorDebug + 'O' -> colorInfo + 'N' -> colorWarning + 'R' -> colorError + else -> ElementTheme.colors.textPrimary + } + ColorationMode.None -> ElementTheme.colors.textPrimary + } +} + +private val colorDebug = Color(0xFF299999) +private val colorInfo = Color(0xFFABC023) +private val colorWarning = Color(0xFFBBB529) +private val colorError = Color(0xFFFF6B68) 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..3851a4a554 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 @@ -7,32 +7,16 @@ package io.element.android.features.viewfolder.impl.file -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.libraries.androidutils.system.copyToClipboard 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 @@ -46,7 +30,6 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalMaterial3Api::class) @@ -114,125 +97,6 @@ fun ViewFileView( ) } -@Composable -private fun FileContent( - lines: ImmutableList, - colorationMode: ColorationMode, - modifier: Modifier = Modifier, -) { - LazyColumn( - modifier = modifier - ) { - if (lines.isEmpty()) { - item { - Spacer(Modifier.size(80.dp)) - Text( - text = "Empty file", - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.fillMaxWidth() - ) - } - } else { - itemsIndexed( - items = lines, - ) { index, line -> - LineRow( - lineNumber = index + 1, - line = line, - colorationMode = colorationMode, - ) - } - } - } -} - -@Composable -private fun LineRow( - lineNumber: Int, - line: String, - colorationMode: ColorationMode, -) { - val context = LocalContext.current - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = { - context.copyToClipboard( - line, - "Line copied to clipboard", - ) - }) - ) { - Text( - modifier = Modifier - .widthIn(min = 36.dp) - .padding(horizontal = 4.dp), - text = "$lineNumber", - textAlign = TextAlign.End, - color = ElementTheme.colors.textSecondary, - style = ElementTheme.typography.fontBodyMdMedium, - ) - val color = ElementTheme.colors.textSecondary - val width = 0.5.dp.value - Text( - modifier = Modifier - .weight(1f) - .drawWithContent { - // Using .height(IntrinsicSize.Min) on the Row does not work well inside LazyColumn - drawLine( - color = color, - start = Offset(0f, 0f), - end = Offset(0f, size.height), - strokeWidth = width - ) - drawContent() - } - .padding(horizontal = 4.dp), - text = line, - color = line.toColor(colorationMode), - style = ElementTheme.typography.fontBodyMdRegular - ) - } -} - -/** - * Convert a line to a color. - * Ex for logcat: - * `01-23 13:14:50.740 25818 25818 D org.matrix.rust.sdk: elementx: SyncIndicator = Hide | RustRoomListService.kt:81` - * ^ use this char to determine the color - * Ex for Rust logs: - * `2024-01-26T10:22:26.947416Z WARN elementx: Restore with non-empty map | MatrixClientsHolder.kt:68` - * ^ use this char to determine the color, see [LogLevel] - */ -@Composable -private fun String.toColor(colorationMode: ColorationMode): Color { - return when (colorationMode) { - ColorationMode.Logcat -> when (getOrNull(31)) { - 'D' -> colorDebug - 'I' -> colorInfo - 'W' -> colorWarning - 'E' -> colorError - 'A' -> colorError - else -> ElementTheme.colors.textPrimary - } - ColorationMode.RustLogs -> when (getOrNull(32)) { - 'E' -> ElementTheme.colors.textPrimary - 'G' -> colorDebug - 'O' -> colorInfo - 'N' -> colorWarning - 'R' -> colorError - else -> ElementTheme.colors.textPrimary - } - ColorationMode.None -> ElementTheme.colors.textPrimary - } -} - -private val colorDebug = Color(0xFF299999) -private val colorInfo = Color(0xFFABC023) -private val colorWarning = Color(0xFFBBB529) -private val colorError = Color(0xFFFF6B68) - @PreviewsDayNight @Composable internal fun ViewFileViewPreview(@PreviewParameter(ViewFileStateProvider::class) state: ViewFileState) = ElementPreview { diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt index 7426251ca0..f5440a48b6 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt @@ -156,3 +156,24 @@ fun aVoiceMediaInfo( waveform = waveForm, duration = duration, ) + +fun aTxtMediaInfo( + filename: String = "a text file.txt", + caption: String? = null, + senderName: String? = null, + dateSent: String? = null, + dateSentFull: String? = null, +): MediaInfo = MediaInfo( + filename = filename, + caption = caption, + mimeType = MimeTypes.PlainText, + formattedFileSize = "2kB", + fileExtension = "txt", + senderId = UserId("@alice:server.org"), + senderName = senderName, + senderAvatar = null, + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = null, + duration = null, +) 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..10ac3a7c2a 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.TextFileView 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 -> TextFileView( + 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/TextFileContentProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TextFileContentProvider.kt new file mode 100644 index 0000000000..00480f2d42 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TextFileContentProvider.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 TextFileContentProvider : 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/TextFileView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TextFileView.kt new file mode 100644 index 0000000000..c025ab8bed --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TextFileView.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 TextFileView( + 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")) + } + } + } + TextFileContentView( + data = data.value, + textFileViewer = textFileViewer, + modifier = modifier, + ) +} + +@Composable +private fun TextFileContentView( + 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 TextFileContentViewPreview( + @PreviewParameter(TextFileContentProvider::class) text: AsyncData>, +) = ElementPreview { + TextFileContentView( + 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 00e634c2a3..3cc44165b0 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() @@ -125,6 +127,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/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt index 95557c13d6..6686cd9fce 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo +import io.element.android.libraries.mediaviewer.api.aTxtMediaInfo import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo import io.element.android.libraries.mediaviewer.api.anApkMediaInfo import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo @@ -159,6 +160,14 @@ open class MediaViewerStateProvider : PreviewParameterProvider MediaViewerPageData.Failure(Exception("error")) ), ), + aMediaViewerState( + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Loading(), + mediaInfo = aTxtMediaInfo(), + ) + ) + ), ) } 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 = {} ) } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt index bfc294098b..8bd23c2089 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt @@ -252,6 +252,7 @@ private fun AndroidComposeTestRule.setMedia setContent { MediaViewerView( state = state, + textFileViewer = { _, _ -> }, onBackClick = onBackClick, ) } diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index e9a32d7beb..10f3d08e38 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -160,6 +160,7 @@ "Editing" "Editing caption" "* %1$s %2$s" + "Empty file" "Encryption" "Encryption enabled" "Enter your PIN" @@ -184,6 +185,7 @@ Reason: %1$s." "This Matrix ID can\'t be found, so the invite might not be received." "Leaving room" "Light" + "Line copied to clipboard" "Link copied to clipboard" "Loading…" "Loading more…" diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt index b5e75b904d..020245b906 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt @@ -51,6 +51,7 @@ class KonsistClassNameTest { .withoutName( "AspectRatioProvider", "OverlapRatioProvider", + "TextFileContentProvider", ) .also { // Check that classes are actually found diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Day_0_en.png new file mode 100644 index 0000000000..1b3302c89c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:615107c5e6d654779b1a8fb0ac3e5511f03ac0eac2a5c3ae0424972b92401ca2 +size 5244 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Day_1_en.png new file mode 100644 index 0000000000..1b3302c89c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:615107c5e6d654779b1a8fb0ac3e5511f03ac0eac2a5c3ae0424972b92401ca2 +size 5244 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Day_2_en.png new file mode 100644 index 0000000000..cfe67410f0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:783bfa52d591b0ae98ac55cf5da8c4a7d7a28275e2714ec7278d4a7947009f4d +size 6187 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Day_3_en.png new file mode 100644 index 0000000000..a788378045 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3e94d893ac1bc9fc13806556332f6639469abc620a7617a730a8bbc9153f826 +size 7070 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Night_0_en.png new file mode 100644 index 0000000000..cba2bf39b2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a360e21538876df4d8aa1b4a3e95e4982df6307a69df4d887416cbbd76b8cd99 +size 5250 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Night_1_en.png new file mode 100644 index 0000000000..cba2bf39b2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a360e21538876df4d8aa1b4a3e95e4982df6307a69df4d887416cbbd76b8cd99 +size 5250 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Night_2_en.png new file mode 100644 index 0000000000..cf3bd3a0ea --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e374bcd5e119bf944eeaf28876b7e902c1bbd2e6bbf84e0f30ace9732675b9ae +size 6142 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Night_3_en.png new file mode 100644 index 0000000000..47cb88a813 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.txt_TextFileContentView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bd12380891a5af74a827ce14393bb5f79d9019eec7653e10ecc2923cec3c2a9 +size 6945 diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_16_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_16_en.png new file mode 100644 index 0000000000..504244640a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_16_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dec2e6f9dcfc2e92fef730a26599d6a4e2e09b6b9999dc912a82917f65908417 +size 6798