First Commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+33
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+35
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+153
@@ -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)
|
||||
+31
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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()
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
}
|
||||
+57
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+84
@@ -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
|
||||
}
|
||||
}
|
||||
+24
@@ -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,
|
||||
}
|
||||
+68
@@ -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 = {},
|
||||
)
|
||||
+100
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+52
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+61
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
@@ -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>,
|
||||
)
|
||||
+35
@@ -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(),
|
||||
)
|
||||
+151
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
+138
@@ -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()
|
||||
}
|
||||
}
|
||||
+54
@@ -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)
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
}
|
||||
}
|
||||
+142
@@ -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,
|
||||
)
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
+112
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user