From 5fa396d616c7e2bb5ca251163b38a98e1064ab30 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 24 Jan 2024 23:11:52 +0100 Subject: [PATCH] View Folders and files Add test Add test --- appnav/build.gradle.kts | 1 + .../io/element/android/appnav/RootFlowNode.kt | 26 +++ .../api/bugreport/BugReportEntryPoint.kt | 1 + .../rageshake/api/reporter/BugReporter.kt | 5 + .../rageshake/impl/bugreport/BugReportNode.kt | 11 + .../rageshake/impl/bugreport/BugReportView.kt | 8 + .../impl/reporter/DefaultBugReporter.kt | 23 +- .../impl/src/main/res/values/localazy.xml | 1 + features/viewfolder/api/build.gradle.kts | 26 +++ .../viewfolder/api/ViewFolderEntryPoint.kt | 40 ++++ features/viewfolder/impl/build.gradle.kts | 50 +++++ .../impl/DefaultViewFolderEntryPoint.kt | 50 +++++ .../viewfolder/impl/file/FileContentReader.kt | 44 ++++ .../features/viewfolder/impl/file/FileSave.kt | 100 +++++++++ .../viewfolder/impl/file/FileShare.kt | 72 ++++++ .../viewfolder/impl/file/ViewFileEvents.kt | 22 ++ .../viewfolder/impl/file/ViewFileNode.kt | 67 ++++++ .../viewfolder/impl/file/ViewFilePresenter.kt | 78 +++++++ .../viewfolder/impl/file/ViewFileState.kt | 25 +++ .../impl/file/ViewFileStateProvider.kt | 50 +++++ .../viewfolder/impl/file/ViewFileView.kt | 206 ++++++++++++++++++ .../viewfolder/impl/folder/FolderExplorer.kt | 61 ++++++ .../viewfolder/impl/folder/ViewFolderNode.kt | 74 +++++++ .../impl/folder/ViewFolderPresenter.kt | 56 +++++ .../viewfolder/impl/folder/ViewFolderState.kt | 25 +++ .../impl/folder/ViewFolderStateProvider.kt | 43 ++++ .../viewfolder/impl/folder/ViewFolderView.kt | 165 ++++++++++++++ .../features/viewfolder/impl/model/Item.kt | 35 +++ .../impl/root/ViewFolderRootNode.kt | 148 +++++++++++++ .../test/file/FakeFileContentReader.kt | 29 +++ .../viewfolder/test/file/FakeFileSave.kt | 28 +++ .../viewfolder/test/file/FakeFileShare.kt | 28 +++ .../test/file/ViewFilePresenterTest.kt | 105 +++++++++ .../test/folder/FakeFolderExplorer.kt | 30 +++ .../test/folder/ViewFolderPresenterTest.kt | 99 +++++++++ 35 files changed, 1817 insertions(+), 15 deletions(-) create mode 100644 features/viewfolder/api/build.gradle.kts create mode 100644 features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/ViewFolderEntryPoint.kt create mode 100644 features/viewfolder/impl/build.gradle.kts create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultViewFolderEntryPoint.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContentReader.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileShare.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvents.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileState.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileStateProvider.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/FolderExplorer.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenter.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderState.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderStateProvider.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/model/Item.kt create mode 100644 features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt create mode 100644 features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt create mode 100644 features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt create mode 100644 features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt create mode 100644 features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt create mode 100644 features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt create mode 100644 features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 9407b16592..4e436ec718 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -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) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index fa7820b5d5..e4ecdd8864 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -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() + } } } diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/bugreport/BugReportEntryPoint.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/bugreport/BugReportEntryPoint.kt index cebc94f31d..758c153671 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/bugreport/BugReportEntryPoint.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/bugreport/BugReportEntryPoint.kt @@ -31,5 +31,6 @@ interface BugReportEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onBugReportSent() + fun onViewLogs(basePath: String) } } diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt index d50ce28778..d8e05f947b 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt @@ -52,4 +52,9 @@ interface BugReporter { * Set the current tracing filter. */ fun setCurrentTracingFilter(tracingFilter: String) + + /** + * Save the logcat. + */ + fun saveLogCat() } diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt index 90a81e279d..caed077228 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt @@ -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, private val presenter: BugReportPresenter, + private val bugReporter: BugReporter, ) : Node(buildContext, plugins = plugins) { + private fun onViewLogs(basePath: String) { + plugins().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) + } ) } diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt index 11843553e8..80f7d931e0 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt @@ -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 = {}, ) } diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt index 2123717f8e..2295ab8808 100755 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -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 } /** diff --git a/features/rageshake/impl/src/main/res/values/localazy.xml b/features/rageshake/impl/src/main/res/values/localazy.xml index 34ba8b5b30..83413c3919 100644 --- a/features/rageshake/impl/src/main/res/values/localazy.xml +++ b/features/rageshake/impl/src/main/res/values/localazy.xml @@ -11,5 +11,6 @@ "Allow logs" "Send screenshot" "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." + "View logs" "%1$s crashed the last time it was used. Would you like to share a crash report with us?" diff --git a/features/viewfolder/api/build.gradle.kts b/features/viewfolder/api/build.gradle.kts new file mode 100644 index 0000000000..98a53dad87 --- /dev/null +++ b/features/viewfolder/api/build.gradle.kts @@ -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) +} diff --git a/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/ViewFolderEntryPoint.kt b/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/ViewFolderEntryPoint.kt new file mode 100644 index 0000000000..f3fb62374e --- /dev/null +++ b/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/ViewFolderEntryPoint.kt @@ -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() + } +} diff --git a/features/viewfolder/impl/build.gradle.kts b/features/viewfolder/impl/build.gradle.kts new file mode 100644 index 0000000000..d3e838e1f5 --- /dev/null +++ b/features/viewfolder/impl/build.gradle.kts @@ -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) +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultViewFolderEntryPoint.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultViewFolderEntryPoint.kt new file mode 100644 index 0000000000..0383c03b8f --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultViewFolderEntryPoint.kt @@ -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() + + 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(buildContext, plugins) + } + } + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContentReader.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContentReader.kt new file mode 100644 index 0000000000..877f841fcb --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContentReader.kt @@ -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 +} + +@ContributesBinding(AppScope::class) +class DefaultFileContentReader @Inject constructor( + private val dispatchers: CoroutineDispatchers, +) : FileContentReader { + override suspend fun getLines(path: String): List = withContext(dispatchers.io) { + try { + File(path).readLines() + } catch (exception: Exception) { + buildList { + add("Error reading file $path") + add(exception.toString()) + } + } + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt new file mode 100644 index 0000000000..9c78bb21aa --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt @@ -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) + } + } + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileShare.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileShare.kt new file mode 100644 index 0000000000..c0dd573a3e --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileShare.kt @@ -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() + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvents.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvents.kt new file mode 100644 index 0000000000..dfea39d2e6 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvents.kt @@ -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 +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt new file mode 100644 index 0000000000..3d4ad727fe --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt @@ -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, + 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().forEach { it.onBackPressed() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ViewFileView( + state = state, + modifier = modifier, + onBackPressed = ::onBackPressed, + ) + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt new file mode 100644 index 0000000000..1a1e5ba3dd --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt @@ -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 { + @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()) } + 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) + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileState.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileState.kt new file mode 100644 index 0000000000..9971c4b7d0 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileState.kt @@ -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, + val eventSink: (ViewFileEvents) -> Unit, +) diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileStateProvider.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileStateProvider.kt new file mode 100644 index 0000000000..9687e82405 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileStateProvider.kt @@ -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 { + override val values: Sequence + 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 = emptyList(), +) = ViewFileState( + name = name, + lines = lines.toImmutableList(), + eventSink = {}, +) diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt new file mode 100644 index 0000000000..265f6892ad --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt @@ -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 = {}, + ) +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/FolderExplorer.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/FolderExplorer.kt new file mode 100644 index 0000000000..68a0373b8a --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/FolderExplorer.kt @@ -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 +} + +@ContributesBinding(AppScope::class) +class DefaultFolderExplorer @Inject constructor( + private val fileSizeFormatter: FileSizeFormatter, + private val dispatchers: CoroutineDispatchers, +) : FolderExplorer { + override suspend fun getItems(path: String): List = 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().sortedBy(Item.Folder::name)) + addAll(folderContent.filterIsInstance().sortedBy(Item.File::name)) + } + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt new file mode 100644 index 0000000000..23dac7bc4e --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt @@ -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, + 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().forEach { it.onBackPressed() } + } + + private fun onNavigateTo(item: Item) { + plugins().forEach { it.onNavigateTo(item) } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ViewFolderView( + state = state, + modifier = modifier, + onNavigateTo = ::onNavigateTo, + onBackPressed = ::onBackPressed, + ) + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenter.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenter.kt new file mode 100644 index 0000000000..64e58036e2 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenter.kt @@ -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 { + @AssistedFactory + interface Factory { + fun create(canGoUp: Boolean, path: String): ViewFolderPresenter + } + + @Composable + override fun present(): ViewFolderState { + var content by remember { mutableStateOf(emptyList()) } + LaunchedEffect(Unit) { + content = buildList { + if (canGoUp) add(Item.Parent) + addAll(folderExplorer.getItems(path)) + } + } + return ViewFolderState( + path = path, + content = content.toImmutableList(), + ) + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderState.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderState.kt new file mode 100644 index 0000000000..d31e2ff3cd --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderState.kt @@ -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, +) diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderStateProvider.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderStateProvider.kt new file mode 100644 index 0000000000..76e7fbbe8d --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderStateProvider.kt @@ -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 { + override val values: Sequence + get() = sequenceOf( + aViewFolderState(), + aViewFolderState( + content = listOf( + Item.Parent, + Item.Folder("aPath", "aFolder"), + Item.File("aPath", "aFile", "12kB"), + ) + ) + ) +} + +fun aViewFolderState( + path: String = "aPath", + content: List = emptyList(), +) = ViewFolderState( + path = path, + content = content.toImmutableList(), +) diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt new file mode 100644 index 0000000000..44453c253e --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt @@ -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 = {}, + ) +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/model/Item.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/model/Item.kt new file mode 100644 index 0000000000..2969ec3018 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/model/Item.kt @@ -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 +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt new file mode 100644 index 0000000000..697bd76d13 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt @@ -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, +) : BaseFlowNode( + 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(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(buildContext, plugins = listOf(inputs, callback)) + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } + + private fun onDone() { + plugins().forEach { it.onDone() } + } +} diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt new file mode 100644 index 0000000000..9a8d51e329 --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt @@ -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 = emptyList() + + fun givenResult(result: List) { + this.result = result + } + + override suspend fun getLines(path: String): List = result +} diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt new file mode 100644 index 0000000000..0a35526188 --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt @@ -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 + } +} diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt new file mode 100644 index 0000000000..34b30a99ef --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt @@ -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 + } +} diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt new file mode 100644 index 0000000000..868a812eb2 --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt @@ -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, + ) +} diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt new file mode 100644 index 0000000000..c4a60303b8 --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt @@ -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 = emptyList() + + fun givenResult(result: List) { + this.result = result + } + + override suspend fun getItems(path: String): List = result +} diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt new file mode 100644 index 0000000000..209d76cb9d --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt @@ -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, + ) +}