First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.viewfolder.api"
}
dependencies {
implementation(projects.libraries.architecture)
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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<String>,
modifier: Modifier,
)
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 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
interface ViewFolderEntryPoint : FeatureEntryPoint {
data class Params(
val rootPath: String,
)
fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: Params,
callback: Callback,
): Node
interface Callback : Plugin {
fun onDone()
}
}
+33
View File
@@ -0,0 +1,33 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024 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.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.viewfolder.impl"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
api(projects.features.viewfolder.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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 dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.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 kotlinx.collections.immutable.ImmutableList
@ContributesBinding(AppScope::class)
class DefaultTextFileViewer : TextFileViewer {
@Composable
override fun Render(
lines: ImmutableList<String>,
modifier: Modifier
) {
FileContent(
lines = lines,
colorationMode = ColorationMode.None,
modifier = modifier
)
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 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.features.viewfolder.api.ViewFolderEntryPoint
import io.element.android.features.viewfolder.impl.root.ViewFolderFlowNode
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
class DefaultViewFolderEntryPoint : ViewFolderEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: ViewFolderEntryPoint.Params,
callback: ViewFolderEntryPoint.Callback,
): Node {
return parentNode.createNode<ViewFolderFlowNode>(
buildContext = buildContext,
plugins = listOf(
ViewFolderFlowNode.Inputs(params.rootPath),
callback,
),
)
}
}
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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<String>,
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)
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import kotlinx.coroutines.withContext
import java.io.File
interface FileContentReader {
suspend fun getLines(path: String): Result<List<String>>
}
@ContributesBinding(AppScope::class)
class DefaultFileContentReader(
private val dispatchers: CoroutineDispatchers,
) : FileContentReader {
override suspend fun getLines(path: String): Result<List<String>> = withContext(dispatchers.io) {
runCatchingExceptions {
File(path).readLines()
}
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.annotations.ApplicationContext
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
interface FileSave {
suspend fun save(
path: String,
)
}
@ContributesBinding(AppScope::class)
class DefaultFileSave(
@ApplicationContext private val context: Context,
private val dispatchers: CoroutineDispatchers,
) : FileSave {
override suspend fun save(
path: String,
) {
withContext(dispatchers.io) {
runCatchingExceptions {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveOnDiskUsingMediaStore(path)
} else {
saveOnDiskUsingExternalStorageApi(path)
}
}.onSuccess {
Timber.v("Save on disk succeed")
withContext(dispatchers.main) {
context.toast("Save on disk succeed")
}
}.onFailure {
Timber.e(it, "Save on disk failed")
}
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveOnDiskUsingMediaStore(path: String) {
val file = File(path)
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, file.name)
put(MediaStore.MediaColumns.MIME_TYPE, MimeTypes.OctetStream)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val resolver = context.contentResolver
val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (outputUri != null) {
file.inputStream().use { input ->
resolver.openOutputStream(outputUri).use { output ->
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
}
}
}
}
private fun saveOnDiskUsingExternalStorageApi(path: String) {
val file = File(path)
val target = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
file.name
)
file.inputStream().use { input ->
FileOutputStream(target).use { output ->
input.copyTo(output)
}
}
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.annotations.ApplicationContext
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
interface FileShare {
suspend fun share(
path: String
)
}
@ContributesBinding(AppScope::class)
class DefaultFileShare(
@ApplicationContext private val context: Context,
private val dispatchers: CoroutineDispatchers,
private val buildMeta: BuildMeta,
) : FileShare {
override suspend fun share(
path: String,
) {
runCatchingExceptions {
val file = File(path)
val shareableUri = file.toShareableUri()
val shareMediaIntent = Intent(Intent.ACTION_SEND)
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putExtra(Intent.EXTRA_STREAM, shareableUri)
.setTypeAndNormalize(MimeTypes.OctetStream)
withContext(dispatchers.main) {
val intent = Intent.createChooser(shareMediaIntent, null)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
}.onSuccess {
Timber.v("Share file succeed")
}.onFailure {
Timber.e(it, "Share file failed")
}
}
private fun File.toShareableUri(): Uri {
val authority = "${buildMeta.applicationId}.fileprovider"
return FileProvider.getUriForFile(context, authority, this).normalizeScheme()
}
}
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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
sealed interface ViewFileEvents {
data object SaveOnDisk : ViewFileEvents
data object Share : ViewFileEvents
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.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.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
@ContributesNode(AppScope::class)
@AssistedInject
class ViewFileNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: ViewFilePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val path: String,
val name: String,
) : NodeInputs
interface Callback : Plugin {
fun onBackClick()
}
private val callback: Callback = callback()
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(
path = inputs.path,
name = inputs.name,
)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
ViewFileView(
state = state,
modifier = modifier,
onBackClick = callback::onBackClick,
)
}
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.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.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@AssistedInject
class ViewFilePresenter(
@Assisted("path") val path: String,
@Assisted("name") val name: String,
private val fileContentReader: FileContentReader,
private val fileShare: FileShare,
private val fileSave: FileSave,
) : Presenter<ViewFileState> {
@AssistedFactory
interface Factory {
fun create(
@Assisted("path") path: String,
@Assisted("name") name: String,
): ViewFilePresenter
}
@Composable
override fun present(): ViewFileState {
val coroutineScope = rememberCoroutineScope()
val colorationMode = remember { name.toColorationMode() }
fun handleEvent(event: ViewFileEvents) {
when (event) {
ViewFileEvents.Share -> coroutineScope.share(path)
ViewFileEvents.SaveOnDisk -> coroutineScope.save(path)
}
}
var lines: AsyncData<List<String>> by remember { mutableStateOf(AsyncData.Loading()) }
LaunchedEffect(Unit) {
lines = fileContentReader.getLines(path).fold(
onSuccess = { AsyncData.Success(it) },
onFailure = { AsyncData.Failure(it) }
)
}
return ViewFileState(
name = name,
lines = lines,
colorationMode = colorationMode,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.share(path: String) = launch {
fileShare.share(path)
}
private fun CoroutineScope.save(path: String) = launch {
fileSave.save(path)
}
}
private fun String.toColorationMode(): ColorationMode {
return when {
equals("logcat.log") -> ColorationMode.Logcat
startsWith("logs.") -> ColorationMode.RustLogs
else -> ColorationMode.None
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 io.element.android.libraries.architecture.AsyncData
data class ViewFileState(
val name: String,
val lines: AsyncData<List<String>>,
val colorationMode: ColorationMode,
val eventSink: (ViewFileEvents) -> Unit,
)
enum class ColorationMode {
Logcat,
RustLogs,
None,
}
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
open class ViewFileStateProvider : PreviewParameterProvider<ViewFileState> {
override val values: Sequence<ViewFileState>
get() = sequenceOf(
aViewFileState(),
aViewFileState(lines = AsyncData.Loading()),
aViewFileState(lines = AsyncData.Failure(Exception("A failure"))),
aViewFileState(lines = AsyncData.Success(emptyList())),
aViewFileState(
name = "logcat.log",
lines = AsyncData.Success(
listOf(
"Line 1",
"Line 2",
"Line 3 lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor" +
" incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,",
"01-23 13:14:50.740 25818 25818 V verbose",
"01-23 13:14:50.740 25818 25818 D debug",
"01-23 13:14:50.740 25818 25818 I info",
"01-23 13:14:50.740 25818 25818 W warning",
"01-23 13:14:50.740 25818 25818 E error",
"01-23 13:14:50.740 25818 25818 A assertion",
)
),
colorationMode = ColorationMode.Logcat,
),
aViewFileState(
name = "logs.2024-01-26",
lines = AsyncData.Success(
listOf(
"Line 1",
"Line 2",
"Line 3 lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor" +
" incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,",
"2024-01-26T10:22:26.947416Z TRACE trace",
"2024-01-26T10:22:26.947416Z DEBUG debug",
"2024-01-26T10:22:26.947416Z INFO info",
"2024-01-26T10:22:26.947416Z WARN warn",
"2024-01-26T10:22:26.947416Z ERROR error",
)
),
colorationMode = ColorationMode.RustLogs,
)
)
}
fun aViewFileState(
name: String = "aName",
lines: AsyncData<List<String>> = AsyncData.Uninitialized,
colorationMode: ColorationMode = ColorationMode.None,
) = ViewFileState(
name = name,
lines = lines,
colorationMode = colorationMode,
eventSink = {},
)
@@ -0,0 +1,100 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
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.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.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ViewFileView(
state: ViewFileState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(onClick = onBackClick)
},
titleStr = state.name,
actions = {
IconButton(
onClick = {
state.eventSink(ViewFileEvents.Share)
},
) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(id = CommonStrings.action_share),
)
}
IconButton(
onClick = {
state.eventSink(ViewFileEvents.SaveOnDisk)
},
) {
Icon(
imageVector = CompoundIcons.Download(),
contentDescription = stringResource(id = CommonStrings.action_save),
)
}
}
)
},
content = { padding ->
Column(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
) {
when (state.lines) {
AsyncData.Uninitialized,
is AsyncData.Loading -> AsyncLoading()
is AsyncData.Success -> FileContent(
modifier = Modifier.weight(1f),
lines = state.lines.data.toImmutableList(),
colorationMode = state.colorationMode,
)
is AsyncData.Failure -> AsyncFailure(throwable = state.lines.error, onRetry = null)
}
}
}
)
}
@PreviewsDayNight
@Composable
internal fun ViewFileViewPreview(@PreviewParameter(ViewFileStateProvider::class) state: ViewFileState) = ElementPreview {
ViewFileView(
state = state,
onBackClick = {},
)
}
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.folder
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.viewfolder.impl.model.Item
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import kotlinx.coroutines.withContext
import java.io.File
interface FolderExplorer {
suspend fun getItems(path: String): List<Item>
}
@ContributesBinding(AppScope::class)
class DefaultFolderExplorer(
private val fileSizeFormatter: FileSizeFormatter,
private val dispatchers: CoroutineDispatchers,
) : FolderExplorer {
override suspend fun getItems(path: String): List<Item> = withContext(dispatchers.io) {
val current = File(path)
if (current.isFile) {
error("Not a folder")
}
val folderContent = current.listFiles().orEmpty().map { file ->
if (file.isDirectory) {
Item.Folder(
path = file.path,
name = file.name
)
} else {
Item.File(
path = file.path,
name = file.name,
formattedSize = fileSizeFormatter.format(file.length()),
)
}
}
buildList {
addAll(folderContent.filterIsInstance<Item.Folder>().sortedBy(Item.Folder::name))
addAll(folderContent.filterIsInstance<Item.File>().sortedBy(Item.File::name))
}
}
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.folder
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.impl.model.Item
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
@ContributesNode(AppScope::class)
@AssistedInject
class ViewFolderNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: ViewFolderPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val canGoUp: Boolean,
val path: String,
) : NodeInputs
interface Callback : Plugin {
fun onBackClick()
fun navigateToItem(item: Item)
}
private val callback: Callback = callback()
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(
canGoUp = inputs.canGoUp,
path = inputs.path,
)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
ViewFolderView(
state = state,
modifier = modifier,
onNavigateTo = callback::navigateToItem,
onBackClick = callback::onBackClick,
)
}
}
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.folder
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.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.viewfolder.impl.model.Item
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@AssistedInject
class ViewFolderPresenter(
@Assisted val canGoUp: Boolean,
@Assisted val path: String,
private val folderExplorer: FolderExplorer,
private val buildMeta: BuildMeta,
) : Presenter<ViewFolderState> {
@AssistedFactory
interface Factory {
fun create(canGoUp: Boolean, path: String): ViewFolderPresenter
}
@Composable
override fun present(): ViewFolderState {
var content by remember { mutableStateOf<ImmutableList<Item>>(persistentListOf()) }
val title = remember {
buildString {
if (path.contains(buildMeta.applicationId)) {
append("")
}
append(path.substringAfter(buildMeta.applicationId))
}
}
LaunchedEffect(Unit) {
content = buildList {
if (canGoUp) add(Item.Parent)
addAll(folderExplorer.getItems(path))
}.toImmutableList()
}
return ViewFolderState(
title = title,
content = content,
)
}
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.folder
import io.element.android.features.viewfolder.impl.model.Item
import kotlinx.collections.immutable.ImmutableList
data class ViewFolderState(
val title: String,
val content: ImmutableList<Item>,
)
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.folder
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.viewfolder.impl.model.Item
import kotlinx.collections.immutable.toImmutableList
open class ViewFolderStateProvider : PreviewParameterProvider<ViewFolderState> {
override val values: Sequence<ViewFolderState>
get() = sequenceOf(
aViewFolderState(),
aViewFolderState(
content = listOf(
Item.Parent,
Item.Folder("aPath", "aFolder"),
Item.File("aPath", "aFile", "12kB"),
)
)
)
}
fun aViewFolderState(
title: String = "aPath",
content: List<Item> = emptyList(),
) = ViewFolderState(
title = title,
content = content.toImmutableList(),
)
@@ -0,0 +1,151 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.folder
import androidx.compose.foundation.layout.Column
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.SubdirectoryArrowLeft
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.features.viewfolder.impl.model.Item
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ViewFolderView(
state: ViewFolderState,
onNavigateTo: (Item) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(onClick = onBackClick)
},
titleStr = state.title,
)
},
content = { padding ->
Column(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
) {
LazyColumn(
modifier = Modifier.weight(1f)
) {
items(
items = state.content,
) { item ->
ItemRow(
item = item,
onItemClick = { onNavigateTo(item) },
)
}
if (state.content.none { it !is Item.Parent }) {
item {
Spacer(Modifier.size(80.dp))
Text(
text = "Empty folder",
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
)
}
@Composable
private fun ItemRow(
item: Item,
onItemClick: () -> Unit,
) {
when (item) {
Item.Parent -> {
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(Icons.Outlined.SubdirectoryArrowLeft)),
headlineContent = {
Text(
text = "..",
modifier = Modifier.padding(16.dp),
style = ElementTheme.typography.fontBodyMdMedium,
)
},
onClick = onItemClick,
)
}
is Item.Folder -> {
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(Icons.Outlined.Folder)),
headlineContent = {
Text(
text = item.name,
modifier = Modifier.padding(16.dp),
style = ElementTheme.typography.fontBodyMdMedium,
)
},
onClick = onItemClick,
)
}
is Item.File -> {
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Document())),
headlineContent = {
Text(
text = item.name,
modifier = Modifier.padding(16.dp),
style = ElementTheme.typography.fontBodyMdMedium,
)
},
trailingContent = ListItemContent.Text(item.formattedSize),
onClick = onItemClick,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun ViewFolderViewPreview(@PreviewParameter(ViewFolderStateProvider::class) state: ViewFolderState) = ElementPreview {
ViewFolderView(
state = state,
onNavigateTo = {},
onBackClick = {},
)
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.model
import androidx.compose.runtime.Immutable
@Immutable
sealed interface Item {
data object Parent : Item
data class Folder(
val path: String,
val name: String,
) : Item
data class File(
val path: String,
val name: String,
val formattedSize: String,
) : Item
}
@@ -0,0 +1,138 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.root
import android.os.Parcelable
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 com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
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.ViewFolderEntryPoint
import io.element.android.features.viewfolder.impl.file.ViewFileNode
import io.element.android.features.viewfolder.impl.folder.ViewFolderNode
import io.element.android.features.viewfolder.impl.model.Item
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@AssistedInject
class ViewFolderFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<ViewFolderFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data class Folder(
val path: String,
) : NavTarget
@Parcelize
data class File(
val path: String,
val name: String,
) : NavTarget
}
data class Inputs(
val rootPath: String,
) : NodeInputs
private val callback: ViewFolderEntryPoint.Callback = callback()
private val inputs: Inputs = inputs()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.Root -> {
createViewFolderNode(
buildContext,
inputs = ViewFolderNode.Inputs(
canGoUp = false,
path = inputs.rootPath,
)
)
}
is NavTarget.Folder -> {
createViewFolderNode(
buildContext,
inputs = ViewFolderNode.Inputs(
canGoUp = true,
path = navTarget.path,
)
)
}
is NavTarget.File -> {
val callback: ViewFileNode.Callback = object : ViewFileNode.Callback {
override fun onBackClick() {
backstack.pop()
}
}
val inputs = ViewFileNode.Inputs(
path = navTarget.path,
name = navTarget.name,
)
createNode<ViewFileNode>(buildContext, plugins = listOf(inputs, callback))
}
}
}
private fun createViewFolderNode(
buildContext: BuildContext,
inputs: ViewFolderNode.Inputs,
): Node {
val callback: ViewFolderNode.Callback = object : ViewFolderNode.Callback {
override fun onBackClick() {
callback.onDone()
}
override fun navigateToItem(item: Item) {
when (item) {
Item.Parent -> {
// Should not happen when in Root since parent is not accessible from root (canGoUp set to false)
backstack.pop()
}
is Item.Folder -> {
backstack.push(NavTarget.Folder(path = item.path))
}
is Item.File -> {
backstack.push(NavTarget.File(path = item.path, name = item.name))
}
}
}
}
return createNode<ViewFolderNode>(buildContext, plugins = listOf(inputs, callback))
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.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.features.viewfolder.api.ViewFolderEntryPoint
import io.element.android.features.viewfolder.impl.root.ViewFolderFlowNode
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultViewFolderEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultViewFolderEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
ViewFolderFlowNode(
buildContext = buildContext,
plugins = plugins,
)
}
val callback = object : ViewFolderEntryPoint.Callback {
override fun onDone() = lambdaError()
}
val params = ViewFolderEntryPoint.Params(
rootPath = "path",
)
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
params = params,
callback = callback,
)
assertThat(result).isInstanceOf(ViewFolderFlowNode::class.java)
assertThat(result.plugins).contains(ViewFolderFlowNode.Inputs(params.rootPath))
assertThat(result.plugins).contains(callback)
}
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.test.file
import io.element.android.features.viewfolder.impl.file.FileContentReader
class FakeFileContentReader : FileContentReader {
private var result: Result<List<String>> = Result.success(emptyList())
fun givenResult(result: Result<List<String>>) {
this.result = result
}
override suspend fun getLines(path: String): Result<List<String>> = result
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.test.file
import io.element.android.features.viewfolder.impl.file.FileSave
class FakeFileSave : FileSave {
var hasBeenCalled = false
private set
override suspend fun save(path: String) {
hasBeenCalled = true
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.test.file
import io.element.android.features.viewfolder.impl.file.FileShare
class FakeFileShare : FileShare {
var hasBeenCalled = false
private set
override suspend fun share(path: String) {
hasBeenCalled = true
}
}
@@ -0,0 +1,142 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.test.file
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.viewfolder.impl.file.ColorationMode
import io.element.android.features.viewfolder.impl.file.FileContentReader
import io.element.android.features.viewfolder.impl.file.FileSave
import io.element.android.features.viewfolder.impl.file.FileShare
import io.element.android.features.viewfolder.impl.file.ViewFileEvents
import io.element.android.features.viewfolder.impl.file.ViewFilePresenter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ViewFilePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val fileContentReader = FakeFileContentReader().apply {
givenResult(Result.success(listOf("aLine")))
}
val presenter = createPresenter(fileContentReader = fileContentReader)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.name).isEqualTo("aName")
assertThat(initialState.lines).isInstanceOf(AsyncData.Loading::class.java)
assertThat(initialState.colorationMode).isEqualTo(ColorationMode.None)
val loadedState = awaitItem()
val lines = (loadedState.lines as AsyncData.Success).data
assertThat(lines.size).isEqualTo(1)
assertThat(lines.first()).isEqualTo("aLine")
}
}
@Test
fun `present - coloration mode for logcat`() = runTest {
val presenter = createPresenter(name = "logcat.log")
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.colorationMode).isEqualTo(ColorationMode.Logcat)
cancelAndConsumeRemainingEvents()
}
}
@Test
fun `present - coloration mode for logs`() = runTest {
val presenter = createPresenter(name = "logs.date")
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.colorationMode).isEqualTo(ColorationMode.RustLogs)
cancelAndConsumeRemainingEvents()
}
}
@Test
fun `present - share should not have any side effect`() = runTest {
val fileContentReader = FakeFileContentReader().apply {
givenResult(Result.success(listOf("aLine")))
}
val fileShare = FakeFileShare()
val fileSave = FakeFileSave()
val presenter = createPresenter(fileContentReader = fileContentReader, fileShare = fileShare, fileSave = fileSave)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(ViewFileEvents.Share)
assertThat(fileShare.hasBeenCalled).isTrue()
assertThat(fileSave.hasBeenCalled).isFalse()
}
}
@Test
fun `present - with error loading file`() = runTest {
val fileContentReader = FakeFileContentReader().apply {
givenResult(Result.failure(AN_EXCEPTION))
}
val presenter = createPresenter(fileContentReader = fileContentReader)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val errorState = awaitItem()
assertThat(errorState.lines).isInstanceOf(AsyncData.Failure::class.java)
}
}
@Test
fun `present - save should not have any side effect`() = runTest {
val fileContentReader = FakeFileContentReader().apply {
givenResult(Result.success(listOf("aLine")))
}
val fileShare = FakeFileShare()
val fileSave = FakeFileSave()
val presenter = createPresenter(fileContentReader = fileContentReader, fileShare = fileShare, fileSave = fileSave)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(ViewFileEvents.SaveOnDisk)
assertThat(fileShare.hasBeenCalled).isFalse()
assertThat(fileSave.hasBeenCalled).isTrue()
}
}
private fun createPresenter(
path: String = "aPath",
name: String = "aName",
fileContentReader: FileContentReader = FakeFileContentReader(),
fileShare: FileShare = FakeFileShare(),
fileSave: FileSave = FakeFileSave(),
) = ViewFilePresenter(
path = path,
name = name,
fileContentReader = fileContentReader,
fileShare = fileShare,
fileSave = fileSave,
)
}
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.test.folder
import io.element.android.features.viewfolder.impl.folder.FolderExplorer
import io.element.android.features.viewfolder.impl.model.Item
class FakeFolderExplorer : FolderExplorer {
private var result: List<Item> = emptyList()
fun givenResult(result: List<Item>) {
this.result = result
}
override suspend fun getItems(path: String): List<Item> = result
}
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.test.folder
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.viewfolder.impl.folder.FolderExplorer
import io.element.android.features.viewfolder.impl.folder.ViewFolderPresenter
import io.element.android.features.viewfolder.impl.model.Item
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ViewFolderPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.title).isEqualTo("aPath")
assertThat(initialState.content).isEmpty()
}
}
@Test
fun `present - title is built regarding the applicationId`() = runTest {
val presenter = createPresenter(
path = "/data/user/O/appId/cache/logs",
buildMeta = aBuildMeta(
applicationId = "appId",
)
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.title).isEqualTo("…/cache/logs")
}
}
@Test
fun `present - list items from root`() = runTest {
val items = listOf(
Item.Folder("aFilePath", "aFilename"),
Item.File("aFolderPath", "aFolderName", "aSize"),
)
val folderExplorer = FakeFolderExplorer().apply {
givenResult(items)
}
val presenter = createPresenter(folderExplorer = folderExplorer)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.title).isEqualTo("aPath")
assertThat(initialState.content.toList()).isEqualTo(items)
}
}
@Test
fun `present - list items from a folder`() = runTest {
val items = listOf(
Item.Folder("aFilePath", "aFilename"),
Item.File("aFolderPath", "aFolderName", "aSize"),
)
val folderExplorer = FakeFolderExplorer().apply {
givenResult(items)
}
val presenter = createPresenter(
canGoUp = true,
folderExplorer = folderExplorer
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.title).isEqualTo("aPath")
assertThat(initialState.content.toList()).isEqualTo(listOf(Item.Parent) + items)
}
}
private fun createPresenter(
canGoUp: Boolean = false,
path: String = "aPath",
folderExplorer: FolderExplorer = FakeFolderExplorer(),
buildMeta: BuildMeta = aBuildMeta(
applicationId = "appId",
),
) = ViewFolderPresenter(
path = path,
canGoUp = canGoUp,
folderExplorer = folderExplorer,
buildMeta = buildMeta,
)
}
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.viewfolder.test"
}
dependencies {
implementation(projects.features.viewfolder.api)
implementation(projects.libraries.architecture)
implementation(projects.tests.testutils)
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakeViewFolderEntryPoint : ViewFolderEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: ViewFolderEntryPoint.Params,
callback: ViewFolderEntryPoint.Callback,
): Node = lambdaError()
}