Browse Source

View Folders and files

Add test

Add test
pull/2283/head
Benoit Marty 8 months ago
parent
commit
5fa396d616
  1. 1
      appnav/build.gradle.kts
  2. 26
      appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
  3. 1
      features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/bugreport/BugReportEntryPoint.kt
  4. 5
      features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt
  5. 11
      features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt
  6. 8
      features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt
  7. 23
      features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt
  8. 1
      features/rageshake/impl/src/main/res/values/localazy.xml
  9. 26
      features/viewfolder/api/build.gradle.kts
  10. 40
      features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/ViewFolderEntryPoint.kt
  11. 50
      features/viewfolder/impl/build.gradle.kts
  12. 50
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultViewFolderEntryPoint.kt
  13. 44
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContentReader.kt
  14. 100
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt
  15. 72
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileShare.kt
  16. 22
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvents.kt
  17. 67
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt
  18. 78
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt
  19. 25
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileState.kt
  20. 50
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileStateProvider.kt
  21. 206
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt
  22. 61
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/FolderExplorer.kt
  23. 74
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt
  24. 56
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenter.kt
  25. 25
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderState.kt
  26. 43
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderStateProvider.kt
  27. 165
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt
  28. 35
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/model/Item.kt
  29. 148
      features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt
  30. 29
      features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt
  31. 28
      features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt
  32. 28
      features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt
  33. 105
      features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt
  34. 30
      features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt
  35. 99
      features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt

1
appnav/build.gradle.kts

@ -52,6 +52,7 @@ dependencies { @@ -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)

26
appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt

@ -45,6 +45,7 @@ import io.element.android.features.login.api.oidc.OidcAction @@ -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( @@ -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( @@ -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( @@ -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()
}
}
}

1
features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/bugreport/BugReportEntryPoint.kt

@ -31,5 +31,6 @@ interface BugReportEntryPoint : FeatureEntryPoint { @@ -31,5 +31,6 @@ interface BugReportEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onBugReportSent()
fun onViewLogs(basePath: String)
}
}

5
features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt

@ -52,4 +52,9 @@ interface BugReporter { @@ -52,4 +52,9 @@ interface BugReporter {
* Set the current tracing filter.
*/
fun setCurrentTracingFilter(tracingFilter: String)
/**
* Save the logcat.
*/
fun saveLogCat()
}

11
features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt

@ -28,6 +28,7 @@ import dagger.assisted.Assisted @@ -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( @@ -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( @@ -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)
}
)
}

8
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 @@ -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 @@ -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( @@ -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 @@ -169,5 +176,6 @@ internal fun BugReportViewPreview(@PreviewParameter(BugReportStateProvider::clas
state = state,
onDone = {},
onBackPressed = {},
onViewLogs = {},
)
}

23
features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt

@ -94,6 +94,8 @@ class DefaultBugReporter @Inject constructor( @@ -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( @@ -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( @@ -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( @@ -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
}
/**

1
features/rageshake/impl/src/main/res/values/localazy.xml

@ -11,5 +11,6 @@ @@ -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

@ -0,0 +1,26 @@ @@ -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)
}

40
features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/ViewFolderEntryPoint.kt

@ -0,0 +1,40 @@ @@ -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

@ -0,0 +1,50 @@ @@ -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)
}

50
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultViewFolderEntryPoint.kt

@ -0,0 +1,50 @@ @@ -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)
}
}
}
}

44
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContentReader.kt

@ -0,0 +1,44 @@ @@ -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())
}
}
}
}

100
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt

@ -0,0 +1,100 @@ @@ -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)
}
}
}
}

72
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileShare.kt

@ -0,0 +1,72 @@ @@ -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()
}
}

22
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvents.kt

@ -0,0 +1,22 @@ @@ -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
}

67
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt

@ -0,0 +1,67 @@ @@ -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,
)
}
}

78
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt

@ -0,0 +1,78 @@ @@ -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)
}
}

25
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileState.kt

@ -0,0 +1,25 @@ @@ -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,
)

50
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileStateProvider.kt

@ -0,0 +1,50 @@ @@ -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 = {},
)

206
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt

@ -0,0 +1,206 @@ @@ -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 = {},
)
}

61
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/FolderExplorer.kt

@ -0,0 +1,61 @@ @@ -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))
}
}
}

74
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt

@ -0,0 +1,74 @@ @@ -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,
)
}
}

56
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenter.kt

@ -0,0 +1,56 @@ @@ -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(),
)
}
}

25
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderState.kt

@ -0,0 +1,25 @@ @@ -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>,
)

43
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderStateProvider.kt

@ -0,0 +1,43 @@ @@ -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(),
)

165
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt

@ -0,0 +1,165 @@ @@ -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 = {},
)
}

35
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/model/Item.kt

@ -0,0 +1,35 @@ @@ -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
}

148
features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt

@ -0,0 +1,148 @@ @@ -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() }
}
}

29
features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt

@ -0,0 +1,29 @@ @@ -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
}

28
features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt

@ -0,0 +1,28 @@ @@ -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
}
}

28
features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt

@ -0,0 +1,28 @@ @@ -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
}
}

105
features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt

@ -0,0 +1,105 @@ @@ -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,
)
}

30
features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt

@ -0,0 +1,30 @@ @@ -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
}

99
features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt

@ -0,0 +1,99 @@ @@ -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,
)
}
Loading…
Cancel
Save