Merge pull request #4414 from element-hq/feature/bma/openTxtDocument

Open txt document inside the application
This commit is contained in:
Benoit Marty 2025-03-18 14:18:29 +01:00 committed by GitHub
commit c956c66921
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 427 additions and 139 deletions

View file

@ -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,
)

View file

@ -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)

View file

@ -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 = {}
)
}

View file

@ -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,

View file

@ -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]

View file

@ -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<AsyncData<ImmutableList<String>>> {
override val values: Sequence<AsyncData<ImmutableList<String>>>
get() = sequenceOf(
AsyncData.Uninitialized,
AsyncData.Loading(),
AsyncData.Success(persistentListOf("Hello, World!")),
AsyncData.Failure(Exception("Failed to load text")),
)
}

View file

@ -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<ImmutableList<String>>>(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<ImmutableList<String>>,
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<ImmutableList<String>>,
) = ElementPreview {
TextFileContentView(
data = text,
textFileViewer = { lines, modifier ->
Text(
modifier = modifier,
text = lines.firstOrNull() ?: "File content"
)
}
)
}

View file

@ -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<MediaViewerEntryPoint.Params>()
@ -125,6 +127,7 @@ class MediaViewerNode @AssistedInject constructor(
val state = presenter.present()
MediaViewerView(
state = state,
textFileViewer = textFileViewer,
modifier = modifier,
onBackClick = ::onDone
)

View file

@ -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<MediaViewerState>
MediaViewerPageData.Failure(Exception("error"))
),
),
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Loading(),
mediaInfo = aTxtMediaInfo(),
)
)
),
)
}

View file

@ -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 = {}
)
}

View file

@ -252,6 +252,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
setContent {
MediaViewerView(
state = state,
textFileViewer = { _, _ -> },
onBackClick = onBackClick,
)
}

View file

@ -160,6 +160,7 @@
<string name="common_editing">"Editing"</string>
<string name="common_editing_caption">"Editing caption"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_empty_file">"Empty file"</string>
<string name="common_encryption">"Encryption"</string>
<string name="common_encryption_enabled">"Encryption enabled"</string>
<string name="common_enter_your_pin">"Enter your PIN"</string>
@ -184,6 +185,7 @@ Reason: %1$s."</string>
<string name="common_invite_unknown_profile">"This Matrix ID can\'t be found, so the invite might not be received."</string>
<string name="common_leaving_room">"Leaving room"</string>
<string name="common_light">"Light"</string>
<string name="common_line_copied_to_clipboard">"Line copied to clipboard"</string>
<string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>
<string name="common_loading">"Loading…"</string>
<string name="common_loading_more">"Loading more…"</string>