View Folders and files
Add test Add test
This commit is contained in:
@@ -52,6 +52,7 @@ dependencies {
|
||||
implementation(libs.coil)
|
||||
|
||||
implementation(projects.features.ftue.api)
|
||||
implementation(projects.features.viewfolder.api)
|
||||
|
||||
implementation(projects.services.apperror.impl)
|
||||
implementation(projects.services.appnavstate.api)
|
||||
|
||||
@@ -45,6 +45,7 @@ import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.api.oidc.OidcActionFlow
|
||||
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||
import io.element.android.features.signedout.api.SignedOutEntryPoint
|
||||
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
@@ -70,6 +71,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||
private val matrixClientsHolder: MatrixClientsHolder,
|
||||
private val presenter: RootPresenter,
|
||||
private val bugReportEntryPoint: BugReportEntryPoint,
|
||||
private val viewFolderEntryPoint: ViewFolderEntryPoint,
|
||||
private val signedOutEntryPoint: SignedOutEntryPoint,
|
||||
private val intentResolver: IntentResolver,
|
||||
private val oidcActionFlow: OidcActionFlow,
|
||||
@@ -194,6 +196,11 @@ class RootFlowNode @AssistedInject constructor(
|
||||
|
||||
@Parcelize
|
||||
data object BugReport : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class ViewLogs(
|
||||
val rootPath: String,
|
||||
) : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
@@ -233,12 +240,31 @@ class RootFlowNode @AssistedInject constructor(
|
||||
override fun onBugReportSent() {
|
||||
backstack.pop()
|
||||
}
|
||||
|
||||
override fun onViewLogs(basePath: String) {
|
||||
backstack.push(NavTarget.ViewLogs(rootPath = basePath))
|
||||
}
|
||||
}
|
||||
bugReportEntryPoint
|
||||
.nodeBuilder(this, buildContext)
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
is NavTarget.ViewLogs -> {
|
||||
val callback = object : ViewFolderEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
backstack.pop()
|
||||
}
|
||||
}
|
||||
val params = ViewFolderEntryPoint.Params(
|
||||
rootPath = navTarget.rootPath,
|
||||
)
|
||||
viewFolderEntryPoint
|
||||
.nodeBuilder(this, buildContext)
|
||||
.params(params)
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,5 +31,6 @@ interface BugReportEntryPoint : FeatureEntryPoint {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onBugReportSent()
|
||||
fun onViewLogs(basePath: String)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,4 +52,9 @@ interface BugReporter {
|
||||
* Set the current tracing filter.
|
||||
*/
|
||||
fun setCurrentTracingFilter(tracingFilter: String)
|
||||
|
||||
/**
|
||||
* Save the logcat.
|
||||
*/
|
||||
fun saveLogCat()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||
import io.element.android.libraries.androidutils.system.toast
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -37,7 +38,12 @@ class BugReportNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: BugReportPresenter,
|
||||
private val bugReporter: BugReporter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
private fun onViewLogs(basePath: String) {
|
||||
plugins<BugReportEntryPoint.Callback>().forEach { it.onViewLogs(basePath) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
@@ -50,6 +56,11 @@ class BugReportNode @AssistedInject constructor(
|
||||
activity?.toast(CommonStrings.common_report_submitted)
|
||||
onDone()
|
||||
},
|
||||
onViewLogs = {
|
||||
// Force a logcat dump
|
||||
bugReporter.saveLogCat()
|
||||
onViewLogs(bugReporter.logDirectory().absolutePath)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import io.element.android.libraries.designsystem.components.form.textFieldState
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceRow
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground
|
||||
@@ -55,6 +56,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@Composable
|
||||
fun BugReportView(
|
||||
state: BugReportState,
|
||||
onViewLogs: () -> Unit,
|
||||
onDone: () -> Unit,
|
||||
onBackPressed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -97,6 +99,11 @@ fun BugReportView(
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
PreferenceText(
|
||||
title = stringResource(id = R.string.screen_bug_report_view_logs),
|
||||
enabled = isFormEnabled,
|
||||
onClick = onViewLogs,
|
||||
)
|
||||
PreferenceSwitch(
|
||||
isChecked = state.formState.sendLogs,
|
||||
onCheckedChange = { eventSink(BugReportEvents.SetSendLog(it)) },
|
||||
@@ -169,5 +176,6 @@ internal fun BugReportViewPreview(@PreviewParameter(BugReportStateProvider::clas
|
||||
state = state,
|
||||
onDone = {},
|
||||
onBackPressed = {},
|
||||
onViewLogs = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ class DefaultBugReporter @Inject constructor(
|
||||
private val logcatCommandDebug = arrayOf("logcat", "-d", "-v", "threadtime", "*:*")
|
||||
private var currentTracingFilter: String? = null
|
||||
|
||||
private val logCatErrFile = File(logDirectory().absolutePath, LOG_CAT_FILENAME)
|
||||
|
||||
override suspend fun sendBugReport(
|
||||
withDevicesLogs: Boolean,
|
||||
withCrashLogs: Boolean,
|
||||
@@ -130,8 +132,8 @@ class DefaultBugReporter @Inject constructor(
|
||||
}
|
||||
|
||||
if (!isCancelled && (withCrashLogs || withDevicesLogs)) {
|
||||
val gzippedLogcat = saveLogCat()
|
||||
|
||||
saveLogCat()
|
||||
val gzippedLogcat = compressFile(logCatErrFile)
|
||||
if (null != gzippedLogcat) {
|
||||
if (gzippedFiles.size == 0) {
|
||||
gzippedFiles.add(gzippedLogcat)
|
||||
@@ -321,7 +323,9 @@ class DefaultBugReporter @Inject constructor(
|
||||
}
|
||||
|
||||
override fun logDirectory(): File {
|
||||
return File(context.cacheDir, LOG_DIRECTORY_NAME)
|
||||
return File(context.cacheDir, LOG_DIRECTORY_NAME).apply {
|
||||
mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanLogDirectoryIfNeeded() {
|
||||
@@ -381,30 +385,19 @@ class DefaultBugReporter @Inject constructor(
|
||||
*
|
||||
* @return the file if the operation succeeds
|
||||
*/
|
||||
private fun saveLogCat(): File? {
|
||||
val logCatErrFile = File(context.cacheDir.absolutePath, LOG_CAT_FILENAME)
|
||||
|
||||
override fun saveLogCat() {
|
||||
if (logCatErrFile.exists()) {
|
||||
logCatErrFile.safeDelete()
|
||||
}
|
||||
|
||||
try {
|
||||
logCatErrFile.writer().use {
|
||||
getLogCatError(it)
|
||||
}
|
||||
|
||||
return compressFile(logCatErrFile)
|
||||
} catch (error: OutOfMemoryError) {
|
||||
Timber.e(error, "## saveLogCat() : fail to write logcat OOM")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "## saveLogCat() : fail to write logcat")
|
||||
} finally {
|
||||
if (logCatErrFile.exists()) {
|
||||
logCatErrFile.safeDelete()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,5 +11,6 @@
|
||||
<string name="screen_bug_report_include_logs">"Allow logs"</string>
|
||||
<string name="screen_bug_report_include_screenshot">"Send screenshot"</string>
|
||||
<string name="screen_bug_report_logs_description">"Logs will be included with your message to make sure that everything is working properly. To send your message without logs, turn off this setting."</string>
|
||||
<string name="screen_bug_report_view_logs">"View logs"</string>
|
||||
<string name="screen_bug_report_rash_logs_alert_title">"%1$s crashed the last time it was used. Would you like to share a crash report with us?"</string>
|
||||
</resources>
|
||||
|
||||
26
features/viewfolder/api/build.gradle.kts
Normal file
26
features/viewfolder/api/build.gradle.kts
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.viewfolder.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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 nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
||||
interface NodeBuilder {
|
||||
fun params(params: Params): NodeBuilder
|
||||
fun callback(callback: Callback): NodeBuilder
|
||||
fun build(): Node
|
||||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onDone()
|
||||
}
|
||||
}
|
||||
50
features/viewfolder/impl/build.gradle.kts
Normal file
50
features/viewfolder/impl/build.gradle.kts
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.ksp)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.viewfolder.impl"
|
||||
}
|
||||
|
||||
anvil {
|
||||
generateDaggerFactories.set(true)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.anvilannotations)
|
||||
anvil(projects.anvilcodegen)
|
||||
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)
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.tests.testutils)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.viewfolder.impl
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
|
||||
import io.element.android.features.viewfolder.impl.root.ViewFolderRootNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultViewFolderEntryPoint @Inject constructor() : ViewFolderEntryPoint {
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): ViewFolderEntryPoint.NodeBuilder {
|
||||
val plugins = ArrayList<Plugin>()
|
||||
|
||||
return object : ViewFolderEntryPoint.NodeBuilder {
|
||||
override fun params(params: ViewFolderEntryPoint.Params): ViewFolderEntryPoint.NodeBuilder {
|
||||
plugins += ViewFolderRootNode.Inputs(params.rootPath)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun callback(callback: ViewFolderEntryPoint.Callback): ViewFolderEntryPoint.NodeBuilder {
|
||||
plugins += callback
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<ViewFolderRootNode>(buildContext, plugins)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.viewfolder.impl.file
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
interface FileContentReader {
|
||||
suspend fun getLines(path: String): List<String>
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultFileContentReader @Inject constructor(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : FileContentReader {
|
||||
override suspend fun getLines(path: String): List<String> = withContext(dispatchers.io) {
|
||||
try {
|
||||
File(path).readLines()
|
||||
} catch (exception: Exception) {
|
||||
buildList {
|
||||
add("Error reading file $path")
|
||||
add(exception.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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 com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.system.toast
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
interface FileSave {
|
||||
suspend fun save(
|
||||
path: String,
|
||||
)
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultFileSave @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : FileSave {
|
||||
override suspend fun save(
|
||||
path: String,
|
||||
) {
|
||||
withContext(dispatchers.io) {
|
||||
runCatching {
|
||||
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,72 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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 com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
interface FileShare {
|
||||
suspend fun share(
|
||||
path: String
|
||||
)
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultFileShare @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : FileShare {
|
||||
override suspend fun share(
|
||||
path: String,
|
||||
) {
|
||||
runCatching {
|
||||
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,22 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.viewfolder.impl.file
|
||||
|
||||
sealed interface ViewFileEvents {
|
||||
data object SaveOnDisk : ViewFileEvents
|
||||
data object Share : ViewFileEvents
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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 com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class ViewFileNode @AssistedInject constructor(
|
||||
@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 onBackPressed()
|
||||
}
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
|
||||
private val presenter = presenterFactory.create(
|
||||
path = inputs.path,
|
||||
name = inputs.name,
|
||||
)
|
||||
|
||||
private fun onBackPressed() {
|
||||
plugins<Callback>().forEach { it.onBackPressed() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
ViewFileView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onBackPressed = ::onBackPressed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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 dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ViewFilePresenter @AssistedInject constructor(
|
||||
@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()
|
||||
|
||||
fun handleEvent(event: ViewFileEvents) {
|
||||
when (event) {
|
||||
ViewFileEvents.Share -> coroutineScope.share(path)
|
||||
ViewFileEvents.SaveOnDisk -> coroutineScope.save(path)
|
||||
}
|
||||
}
|
||||
|
||||
var lines by remember { mutableStateOf(emptyList<String>()) }
|
||||
LaunchedEffect(Unit) {
|
||||
lines = fileContentReader.getLines(path)
|
||||
}
|
||||
return ViewFileState(
|
||||
name = name,
|
||||
lines = lines.toImmutableList(),
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.share(path: String) = launch {
|
||||
fileShare.share(path)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.save(path: String) = launch {
|
||||
fileSave.save(path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.viewfolder.impl.file
|
||||
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class ViewFileState(
|
||||
val name: String,
|
||||
val lines: ImmutableList<String>,
|
||||
val eventSink: (ViewFileEvents) -> Unit,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.viewfolder.impl.file
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
open class ViewFileStateProvider : PreviewParameterProvider<ViewFileState> {
|
||||
override val values: Sequence<ViewFileState>
|
||||
get() = sequenceOf(
|
||||
aViewFileState(),
|
||||
aViewFileState(
|
||||
lines = 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",
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun aViewFileState(
|
||||
name: String = "aName",
|
||||
lines: List<String> = emptyList(),
|
||||
) = ViewFileState(
|
||||
name = name,
|
||||
lines = lines.toImmutableList(),
|
||||
eventSink = {},
|
||||
)
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.viewfolder.impl.file
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
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.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ViewFileView(
|
||||
state: ViewFileState,
|
||||
onBackPressed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
BackButton(onClick = onBackPressed)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = state.name,
|
||||
style = ElementTheme.typography.aliasScreenTitle,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
state.eventSink(ViewFileEvents.Share)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
resourceId = CompoundDrawables.ic_share_android,
|
||||
contentDescription = stringResource(id = CommonStrings.action_share),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
state.eventSink(ViewFileEvents.SaveOnDisk)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
resourceId = CompoundDrawables.ic_download,
|
||||
contentDescription = stringResource(id = CommonStrings.action_save),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
content = { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
if (state.lines.isEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.size(80.dp))
|
||||
Text(
|
||||
text = "Empty file",
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
items = state.lines,
|
||||
) { index, line ->
|
||||
LineRow(
|
||||
lineNumber = index + 1,
|
||||
line = line,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LineRow(
|
||||
lineNumber: Int,
|
||||
line: String,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = {
|
||||
context.copyToClipboard(
|
||||
line,
|
||||
"Line copied to clipboard",
|
||||
)
|
||||
})
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.widthIn(min = 36.dp)
|
||||
.padding(horizontal = 4.dp),
|
||||
text = "$lineNumber",
|
||||
textAlign = TextAlign.End,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
)
|
||||
val color = ElementTheme.colors.textSecondary
|
||||
val width = 0.5.dp.value
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.drawWithContent {
|
||||
// Using .height(IntrinsicSize.Min) on the Row does not work well inside LazyColumn
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(0f, size.height),
|
||||
strokeWidth = width
|
||||
)
|
||||
drawContent()
|
||||
}
|
||||
.padding(horizontal = 4.dp),
|
||||
text = line,
|
||||
color = line.toColor(),
|
||||
style = ElementTheme.typography.fontBodyMdRegular
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a logcat line to a color.
|
||||
* Ex: `01-23 13:14:50.740 25818 25818 D org.matrix.rust.sdk: elementx: SyncIndicator = Hide | RustRoomListService.kt:81`
|
||||
*/
|
||||
@Composable
|
||||
private fun String.toColor(): Color {
|
||||
return when (getOrNull(31)) {
|
||||
'D' -> Color(0xFF299999)
|
||||
'I' -> Color(0xFFABC023)
|
||||
'W' -> Color(0xFFBBB529)
|
||||
'E' -> Color(0xFFFF6B68)
|
||||
'A' -> Color(0xFFFF6B68)
|
||||
else -> ElementTheme.colors.textPrimary
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ViewFileViewPreview(@PreviewParameter(ViewFileStateProvider::class) state: ViewFileState) = ElementPreview {
|
||||
ViewFileView(
|
||||
state = state,
|
||||
onBackPressed = {},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.viewfolder.impl.folder
|
||||
|
||||
import com.squareup.anvil.annotations.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 io.element.android.libraries.di.AppScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
interface FolderExplorer {
|
||||
suspend fun getItems(path: String): List<Item>
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultFolderExplorer @Inject constructor(
|
||||
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,74 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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 com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.viewfolder.impl.model.Item
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class ViewFolderNode @AssistedInject constructor(
|
||||
@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 onBackPressed()
|
||||
fun onNavigateTo(item: Item)
|
||||
}
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
|
||||
private val presenter = presenterFactory.create(
|
||||
canGoUp = inputs.canGoUp,
|
||||
path = inputs.path,
|
||||
)
|
||||
|
||||
private fun onBackPressed() {
|
||||
plugins<Callback>().forEach { it.onBackPressed() }
|
||||
}
|
||||
|
||||
private fun onNavigateTo(item: Item) {
|
||||
plugins<Callback>().forEach { it.onNavigateTo(item) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
ViewFolderView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onNavigateTo = ::onNavigateTo,
|
||||
onBackPressed = ::onBackPressed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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 dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.viewfolder.impl.model.Item
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class ViewFolderPresenter @AssistedInject constructor(
|
||||
@Assisted val canGoUp: Boolean,
|
||||
@Assisted val path: String,
|
||||
private val folderExplorer: FolderExplorer,
|
||||
) : Presenter<ViewFolderState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(canGoUp: Boolean, path: String): ViewFolderPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): ViewFolderState {
|
||||
var content by remember { mutableStateOf(emptyList<Item>()) }
|
||||
LaunchedEffect(Unit) {
|
||||
content = buildList {
|
||||
if (canGoUp) add(Item.Parent)
|
||||
addAll(folderExplorer.getItems(path))
|
||||
}
|
||||
}
|
||||
return ViewFolderState(
|
||||
path = path,
|
||||
content = content.toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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 path: String,
|
||||
val content: ImmutableList<Item>,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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(
|
||||
path: String = "aPath",
|
||||
content: List<Item> = emptyList(),
|
||||
) = ViewFolderState(
|
||||
path = path,
|
||||
content = content.toImmutableList(),
|
||||
)
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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.Description
|
||||
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.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.aliasScreenTitle
|
||||
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,
|
||||
onBackPressed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
BackButton(onClick = onBackPressed)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = state.path,
|
||||
style = ElementTheme.typography.aliasScreenTitle,
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
content = { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
items(
|
||||
items = state.content,
|
||||
) { item ->
|
||||
ItemRow(
|
||||
item = item,
|
||||
onItemClicked = { 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,
|
||||
onItemClicked: () -> 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 = onItemClicked,
|
||||
)
|
||||
}
|
||||
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 = onItemClicked,
|
||||
)
|
||||
}
|
||||
is Item.File -> {
|
||||
ListItem(
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(Icons.Outlined.Description)),
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = item.name,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
)
|
||||
},
|
||||
trailingContent = ListItemContent.Text(item.formattedSize),
|
||||
onClick = onItemClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ViewFolderViewPreview(@PreviewParameter(ViewFolderStateProvider::class) state: ViewFolderState) = ElementPreview {
|
||||
ViewFolderView(
|
||||
state = state,
|
||||
onNavigateTo = {},
|
||||
onBackPressed = {},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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,148 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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.core.plugin.plugins
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.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.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class ViewFolderRootNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : BaseFlowNode<ViewFolderRootNode.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 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 onBackPressed() {
|
||||
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 onBackPressed() {
|
||||
onDone()
|
||||
}
|
||||
|
||||
override fun onNavigateTo(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()
|
||||
}
|
||||
|
||||
private fun onDone() {
|
||||
plugins<ViewFolderEntryPoint.Callback>().forEach { it.onDone() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.viewfolder.test.file
|
||||
|
||||
import io.element.android.features.viewfolder.impl.file.FileContentReader
|
||||
|
||||
class FakeFileContentReader : FileContentReader {
|
||||
private var result: List<String> = emptyList()
|
||||
|
||||
fun givenResult(result: List<String>) {
|
||||
this.result = result
|
||||
}
|
||||
|
||||
override suspend fun getLines(path: String): List<String> = result
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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,28 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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,105 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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.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.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(listOf("aLine"))
|
||||
}
|
||||
val presenter = createPresenter(fileContentReader = fileContentReader)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.name).isEqualTo("aName")
|
||||
assertThat(initialState.lines.size).isEqualTo(1)
|
||||
assertThat(initialState.lines.first()).isEqualTo("aLine")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - share should not have any side effect`() = runTest {
|
||||
val fileContentReader = FakeFileContentReader().apply {
|
||||
givenResult(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 - save should not have any side effect`() = runTest {
|
||||
val fileContentReader = FakeFileContentReader().apply {
|
||||
givenResult(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,30 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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,99 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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.tests.testutils.WarmUpRule
|
||||
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.path).isEqualTo("aPath")
|
||||
assertThat(initialState.content).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@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.path).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.path).isEqualTo("aPath")
|
||||
assertThat(initialState.content.toList()).isEqualTo(listOf(Item.Parent) + items)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
canGoUp: Boolean = false,
|
||||
path: String = "aPath",
|
||||
folderExplorer: FolderExplorer = FakeFolderExplorer(),
|
||||
) = ViewFolderPresenter(
|
||||
path = path,
|
||||
canGoUp = canGoUp,
|
||||
folderExplorer = folderExplorer,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user