Repository: element-hq/element-x-android (language: kotlin)

Issue:
In app logs viewer
Implement a way to display app logs as the application is running similar to https://github.com/element-hq/element-x-ios/pull/2369


Discussion hints:
nan

Pull request: View Folders and logs
<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
 
## Type of change

- [x] Feature
- [ ] Bugfix
- [ ] Technical
- [ ] Other :

## Content

<!-- Describe shortly what has been changed -->
Introduce a `viewfolder` module, able to navigate to a folder hierarchy and to display the content of any file (ofc it works better with TXT files).
When viewing file, it possible to copy a line to the clipboard, or download or share the file.
Line numbers are displayed, as well as a basic syntactic coloration, using logcat colors.

Add a entry to the problem report screen so that user can inspect the logs that will be sent when reporting a problem.

`viewfolder` may be used later in the developer screen to be able to navigate to the `files` or `cache` directories.

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Closes #2276 

## Screenshots / GIFs

<!--
We have screenshot tests in the project, so attaching screenshots to a PR is not mandatory, as far as there
is a Composable Preview covering the changes. In this case, the change will appear in the file diff.
Note that all the UI composables should be covered by a Composable Preview.

Providing a video of the change is still very useful for the reviewer and for the history of the project.

You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|

|Before|After|
|-|-|
|||
 -->
see recorded screenshot.

## Tests

<!-- Explain how you tested your development -->

- Go to the "Report a problem" screen
- Click on "View logs" to open the new screen. The folder does not contain subfolder, so the navigation functionnality cannot be fully tested with the app right now. It's possible to change the rootPath to do so, but the app has to be built again.

## Tested devices

- [ ] Physical
- [ ] Emulator
- OS version(s):

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 23
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request includes a new file under ./changelog.d. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#changelog
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [ ] You've made a self review of your PR


Test command: ./gradlew :features:viewfolder:impl:testDebugUnitTest --tests "io.element.android.features.viewfolder.test.file.ViewFilePresenterTest" --tests "io.element.android.features.viewfolder.test.folder.ViewFolderPresenterTest"

Fix patch:
diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
index 9407b165927..4e436ec7189 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 fa7820b5d5d..e4ecdd88640 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/changelog.d/2276.misc b/changelog.d/2276.misc
new file mode 100644
index 00000000000..47efbb63e9d
--- /dev/null
+++ b/changelog.d/2276.misc
@@ -0,0 +1,1 @@
+Add in app logs viewer to the "Report a problem" screen.
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 cebc94f31d5..758c153671d 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 d50ce287785..d8e05f947b7 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 90a81e279da..caed077228b 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<Plugin>,
     private val presenter: BugReportPresenter,
+    private val bugReporter: BugReporter,
 ) : Node(buildContext, plugins = plugins) {
+    private fun onViewLogs(basePath: String) {
+        plugins<BugReportEntryPoint.Callback>().forEach { it.onViewLogs(basePath) }
+    }
+
     @Composable
     override fun View(modifier: Modifier) {
         val state = presenter.present()
@@ -50,6 +56,11 @@ class BugReportNode @AssistedInject constructor(
                 activity?.toast(CommonStrings.common_report_submitted)
                 onDone()
             },
+            onViewLogs = {
+                // Force a logcat dump
+                bugReporter.saveLogCat()
+                onViewLogs(bugReporter.logDirectory().absolutePath)
+            }
         )
     }
 
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 11843553e86..e10e9250e39 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
@@ -40,9 +40,11 @@ import io.element.android.features.rageshake.impl.R
 import io.element.android.libraries.architecture.AsyncAction
 import io.element.android.libraries.designsystem.components.async.AsyncActionView
 import io.element.android.libraries.designsystem.components.form.textFieldState
+import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider
 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 +57,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 +100,13 @@ fun BugReportView(
                 )
             }
             Spacer(modifier = Modifier.height(16.dp))
+            PreferenceDivider()
+            PreferenceText(
+                title = stringResource(id = R.string.screen_bug_report_view_logs),
+                enabled = isFormEnabled,
+                onClick = onViewLogs,
+            )
+            PreferenceDivider()
             PreferenceSwitch(
                 isChecked = state.formState.sendLogs,
                 onCheckedChange = { eventSink(BugReportEvents.SetSendLog(it)) },
@@ -169,5 +179,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 2123717f8e8..2295ab8808f 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 34ba8b5b30d..83413c39198 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 @@
   <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>
diff --git a/features/viewfolder/api/build.gradle.kts b/features/viewfolder/api/build.gradle.kts
new file mode 100644
index 00000000000..98a53dad87a
--- /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 00000000000..f3fb62374ee
--- /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 00000000000..67c818f1853
--- /dev/null
+++ b/features/viewfolder/impl/build.gradle.kts
@@ -0,0 +1,51 @@
+/*
+ * 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)
+    testImplementation(projects.libraries.matrix.test)
+}
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 00000000000..0383c03b8f2
--- /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<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)
+            }
+        }
+    }
+}
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 00000000000..bc723f70cb4
--- /dev/null
+++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContentReader.kt
@@ -0,0 +1,39 @@
+/*
+ * 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): Result<List<String>>
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultFileContentReader @Inject constructor(
+    private val dispatchers: CoroutineDispatchers,
+) : FileContentReader {
+    override suspend fun getLines(path: String): Result<List<String>> = withContext(dispatchers.io) {
+        runCatching {
+            File(path).readLines()
+        }
+    }
+}
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 00000000000..9c78bb21aad
--- /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 00000000000..c0dd573a3e1
--- /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 00000000000..dfea39d2e68
--- /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 00000000000..3d4ad727fe2
--- /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<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,
+        )
+    }
+}
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 00000000000..91cc59ec053
--- /dev/null
+++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt
@@ -0,0 +1,81 @@
+/*
+ * 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.AsyncData
+import io.element.android.libraries.architecture.Presenter
+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: AsyncData<List<String>> by remember { mutableStateOf(AsyncData.Loading()) }
+        LaunchedEffect(Unit) {
+            lines = fileContentReader.getLines(path).fold(
+                onSuccess = { AsyncData.Success(it) },
+                onFailure = { AsyncData.Failure(it) }
+            )
+        }
+        return ViewFileState(
+            name = name,
+            lines = lines,
+            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 00000000000..9fb531c478d
--- /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 io.element.android.libraries.architecture.AsyncData
+
+data class ViewFileState(
+    val name: String,
+    val lines: AsyncData<List<String>>,
+    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 00000000000..f0612c07148
--- /dev/null
+++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileStateProvider.kt
@@ -0,0 +1,55 @@
+/*
+ * 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 io.element.android.libraries.architecture.AsyncData
+
+open class ViewFileStateProvider : PreviewParameterProvider<ViewFileState> {
+    override val values: Sequence<ViewFileState>
+        get() = sequenceOf(
+            aViewFileState(),
+            aViewFileState(lines = AsyncData.Loading()),
+            aViewFileState(lines = AsyncData.Failure(Exception("A failure"))),
+            aViewFileState(lines = AsyncData.Success(emptyList())),
+            aViewFileState(
+                lines = AsyncData.Success(
+                    listOf(
+                        "Line 1",
+                        "Line 2",
+                        "Line 3 lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor" +
+                            " incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,",
+                        "01-23 13:14:50.740 25818 25818 V verbose",
+                        "01-23 13:14:50.740 25818 25818 D debug",
+                        "01-23 13:14:50.740 25818 25818 I info",
+                        "01-23 13:14:50.740 25818 25818 W warning",
+                        "01-23 13:14:50.740 25818 25818 E error",
+                        "01-23 13:14:50.740 25818 25818 A assertion",
+                    )
+                )
+            )
+        )
+}
+
+fun aViewFileState(
+    name: String = "aName",
+    lines: AsyncData<List<String>> = AsyncData.Uninitialized,
+) = ViewFileState(
+    name = name,
+    lines = lines,
+    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 00000000000..f58f5399d78
--- /dev/null
+++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt
@@ -0,0 +1,227 @@
+/*
+ * 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.architecture.AsyncData
+import io.element.android.libraries.designsystem.components.async.AsyncFailure
+import io.element.android.libraries.designsystem.components.async.AsyncLoading
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.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
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+@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)
+            ) {
+                when (state.lines) {
+                    AsyncData.Uninitialized,
+                    is AsyncData.Loading -> AsyncLoading()
+                    is AsyncData.Success -> FileContent(
+                        modifier = Modifier.weight(1f),
+                        lines = state.lines.data.toImmutableList(),
+                    )
+                    is AsyncData.Failure -> AsyncFailure(throwable = state.lines.error, onRetry = null)
+                }
+            }
+        }
+    )
+}
+
+@Composable
+private fun FileContent(
+    lines: ImmutableList<String>,
+    modifier: Modifier = Modifier,
+) {
+    LazyColumn(
+        modifier = modifier
+    ) {
+        if (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 = 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 00000000000..68a0373b8ad
--- /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<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))
+        }
+    }
+}
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 00000000000..23dac7bc4eb
--- /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<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,
+        )
+    }
+}
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 00000000000..64e58036e22
--- /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<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(),
+        )
+    }
+}
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 00000000000..d31e2ff3cdf
--- /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<Item>,
+)
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 00000000000..76e7fbbe8d6
--- /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<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(),
+)
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 00000000000..44453c253e2
--- /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 00000000000..2969ec30188
--- /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 00000000000..697bd76d130
--- /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<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() }
+    }
+}


Test patch:
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt
index 2cd9e61398f..0a67a79f578 100644
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt
@@ -63,6 +63,10 @@ class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Succes
     override fun setCurrentTracingFilter(tracingFilter: String) {
         // No op
     }
+
+    override fun saveLogCat() {
+        // No op
+    }
 }
 
 enum class FakeBugReporterMode {
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 00000000000..5d8b658587e
--- /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: Result<List<String>> = Result.success(emptyList())
+
+    fun givenResult(result: Result<List<String>>) {
+        this.result = result
+    }
+
+    override suspend fun getLines(path: String): Result<List<String>> = result
+}
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 00000000000..0a355261886
--- /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 00000000000..34b30a99efe
--- /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 00000000000..3b593410b22
--- /dev/null
+++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt
@@ -0,0 +1,124 @@
+/*
+ * 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.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.tests.testutils.WarmUpRule
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class ViewFilePresenterTest {
+    @get:Rule
+    val warmUpRule = WarmUpRule()
+
+    @Test
+    fun `present - initial state`() = runTest {
+        val fileContentReader = FakeFileContentReader().apply {
+            givenResult(Result.success(listOf("aLine")))
+        }
+        val presenter = createPresenter(fileContentReader = fileContentReader)
+        moleculeFlow(RecompositionMode.Immediate) {
+            presenter.present()
+        }.test {
+            val initialState = awaitItem()
+            assertThat(initialState.name).isEqualTo("aName")
+            assertThat(initialState.lines).isInstanceOf(AsyncData.Loading::class.java)
+            val loadedState = awaitItem()
+            val lines = (loadedState.lines as AsyncData.Success).data
+            assertThat(lines.size).isEqualTo(1)
+            assertThat(lines.first()).isEqualTo("aLine")
+        }
+    }
+
+    @Test
+    fun `present - share should not have any side effect`() = runTest {
+        val fileContentReader = FakeFileContentReader().apply {
+            givenResult(Result.success(listOf("aLine")))
+        }
+        val fileShare = FakeFileShare()
+        val fileSave = FakeFileSave()
+        val presenter = createPresenter(fileContentReader = fileContentReader, fileShare = fileShare, fileSave = fileSave)
+        moleculeFlow(RecompositionMode.Immediate) {
+            presenter.present()
+        }.test {
+            skipItems(1)
+            val initialState = awaitItem()
+            initialState.eventSink(ViewFileEvents.Share)
+            assertThat(fileShare.hasBeenCalled).isTrue()
+            assertThat(fileSave.hasBeenCalled).isFalse()
+        }
+    }
+
+    @Test
+    fun `present - with error loading file`() = runTest {
+        val fileContentReader = FakeFileContentReader().apply {
+            givenResult(Result.failure(AN_EXCEPTION))
+        }
+        val presenter = createPresenter(fileContentReader = fileContentReader)
+        moleculeFlow(RecompositionMode.Immediate) {
+            presenter.present()
+        }.test {
+            skipItems(1)
+            val errorState = awaitItem()
+            assertThat(errorState.lines).isInstanceOf(AsyncData.Failure::class.java)
+        }
+    }
+
+    @Test
+    fun `present - save should not have any side effect`() = runTest {
+        val fileContentReader = FakeFileContentReader().apply {
+            givenResult(Result.success(listOf("aLine")))
+        }
+        val fileShare = FakeFileShare()
+        val fileSave = FakeFileSave()
+        val presenter = createPresenter(fileContentReader = fileContentReader, fileShare = fileShare, fileSave = fileSave)
+        moleculeFlow(RecompositionMode.Immediate) {
+            presenter.present()
+        }.test {
+            skipItems(1)
+            val initialState = awaitItem()
+            initialState.eventSink(ViewFileEvents.SaveOnDisk)
+            assertThat(fileShare.hasBeenCalled).isFalse()
+            assertThat(fileSave.hasBeenCalled).isTrue()
+        }
+    }
+
+    private fun createPresenter(
+        path: String = "aPath",
+        name: String = "aName",
+        fileContentReader: FileContentReader = FakeFileContentReader(),
+        fileShare: FileShare = FakeFileShare(),
+        fileSave: FileSave = FakeFileSave(),
+    ) = ViewFilePresenter(
+        path = path,
+        name = name,
+        fileContentReader = fileContentReader,
+        fileShare = fileShare,
+        fileSave = fileSave,
+    )
+}
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 00000000000..c4a60303b84
--- /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<Item> = emptyList()
+
+    fun givenResult(result: List<Item>) {
+        this.result = result
+    }
+
+    override suspend fun getItems(path: String): List<Item> = 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 00000000000..209d76cb9de
--- /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,
+    )
+}
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_0,NEXUS_5,1.0,en].png
index 85063d3c6b6..1c8b28b5ad8 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_0,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_0,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:9ae7fef08fe8f5d08892406cc4c979cebdc8e7751dfda78bac85f4bb0956c245
-size 70071
+oid sha256:b80e48cadbb6e76fc6675eb0a2c49d6495be6a8145d649ad90c2bce10e8695a6
+size 72701
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_1,NEXUS_5,1.0,en].png
index e02ea695e02..d43f744d13d 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_1,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_1,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:3fc0cdabd97d39da8369f1945b9c3d7c9c05baa6da83c029c8ff99ddd8c8b300
-size 206672
+oid sha256:52caeed04914fee9ee74c28427b3a51215eca02104f544dd53dc280c3c1c5f98
+size 145307
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_2,NEXUS_5,1.0,en].png
index 0ed580ca734..db397c15a6c 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_2,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_2,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:e4e898a358de8246f7a835805994f9865e519a894b426f9a1234a998d13c54ab
-size 61356
+oid sha256:350ae21a44e27292bb1ea44fbad0e9cea8dc695997482c9fa3ad6cafa6330a4c
+size 63792
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_3,NEXUS_5,1.0,en].png
index 85063d3c6b6..1c8b28b5ad8 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_3,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Day-0_1_null_3,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:9ae7fef08fe8f5d08892406cc4c979cebdc8e7751dfda78bac85f4bb0956c245
-size 70071
+oid sha256:b80e48cadbb6e76fc6675eb0a2c49d6495be6a8145d649ad90c2bce10e8695a6
+size 72701
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_0,NEXUS_5,1.0,en].png
index a8be95376f6..6ae1a1f2ede 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_0,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_0,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:f421bfb415b006742fc889588b983f682b82b63d4c28c8b896e94709672ca671
-size 67100
+oid sha256:7696307abd3ca96ae3c036cbf3088c2be08b4e4aabb861b0c7472460ab795303
+size 69558
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_1,NEXUS_5,1.0,en].png
index c4f73feb5d4..542fc8d94cc 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_1,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_1,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:0c853ca824f30bb99ab2032e87d9f7ebf8ffb187aae769062381db6afce64a9a
-size 202452
+oid sha256:f7409716efc9323aafb52eeb498330029753ad9ec319afa1027d22feb238e589
+size 141187
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_2,NEXUS_5,1.0,en].png
index 2f9f29cafb7..58120c675c3 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_2,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_2,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:ca56f3993bc4713aa16e54bdcff24f96804f84dc8e9557247c56caf53fd14831
-size 56251
+oid sha256:98368df14dcc182d759ac104189ebf43aff4e1a0445b0af1d6909a614cd2102b
+size 58283
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_3,NEXUS_5,1.0,en].png
index a8be95376f6..6ae1a1f2ede 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_3,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.rageshake.impl.bugreport_BugReportView_null_BugReportView-Night-0_2_null_3,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:f421bfb415b006742fc889588b983f682b82b63d4c28c8b896e94709672ca671
-size 67100
+oid sha256:7696307abd3ca96ae3c036cbf3088c2be08b4e4aabb861b0c7472460ab795303
+size 69558
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_0,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..18e8edb58dc
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_0,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2e396772e4b3effda7d9238de4f02dbce8c705be334239e4607dd80043764bce
+size 10087
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_1,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..18e8edb58dc
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_1,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2e396772e4b3effda7d9238de4f02dbce8c705be334239e4607dd80043764bce
+size 10087
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_2,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..5ccd73a50a9
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_2,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3fdcaa9120195f28f41284d2a124df6739f4fb0967df6df24a38eb6cf43b6632
+size 10144
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_3,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..36edcb8b830
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_3,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b8678bfa52be0664119038250fa9b009f5bca7694c13bca346a87f78e1dd00a2
+size 10295
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_4,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..f5be6b54908
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Day-0_1_null_4,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:52ea44b4be9b858f64a886ded5427f8ef5c2149914e348166a2fbb333ffc41f8
+size 70418
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_0,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..1ded95367db
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_0,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3305677d678c28717a83e745b6c1bc71a2636ad65e09c5efc435c09cc1465dfc
+size 9577
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_1,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..1ded95367db
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_1,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3305677d678c28717a83e745b6c1bc71a2636ad65e09c5efc435c09cc1465dfc
+size 9577
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_2,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..cfb85f1f947
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_2,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:81e02f3d58a5cea85e9f7f63cab787f3d340fc4ff36ecf4ac5131d4d6d7e9f04
+size 9604
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_3,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..f632e7c8c61
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_3,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c9b2398f048d9f544a717a7a5f7e6c16f0584403748e76914f4aa2ab69baa13b
+size 9799
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_4,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..339e893381a
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.file_ViewFileView_null_ViewFileView-Night-0_2_null_4,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:304c5c94e3b408c744f0b6c86d35ac781f368e6e0d5742d5b2ff36ac1682538a
+size 67297
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.folder_ViewFolderView_null_ViewFolderView-Day-1_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.folder_ViewFolderView_null_ViewFolderView-Day-1_2_null_0,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..4e8dd75a9c1
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.folder_ViewFolderView_null_ViewFolderView-Day-1_2_null_0,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ea29d83cb5184a5490de6b3d4a15a41db7c7cb8431b6dfd0eff873b991a3a9c2
+size 9245
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.folder_ViewFolderView_null_ViewFolderView-Day-1_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.folder_ViewFolderView_null_ViewFolderView-Day-1_2_null_1,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..a01c1f03afe
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.folder_ViewFolderView_null_ViewFolderView-Day-1_2_null_1,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:843c33c966df9a1178fea5f319c3d5c313ab4d8f45166fda644650f9aa3c8550
+size 10949
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.folder_ViewFolderView_null_ViewFolderView-Night-1_3_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.folder_ViewFolderView_null_ViewFolderView-Night-1_3_null_0,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..7abe67d0501
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.folder_ViewFolderView_null_ViewFolderView-Night-1_3_null_0,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:27f7fbaba83b994a0baf3b002c95bc27aa6d6097b0af1cb566ccece35cc5f4c5
+size 9010
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.folder_ViewFolderView_null_ViewFolderView-Night-1_3_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.folder_ViewFolderView_null_ViewFolderView-Night-1_3_null_1,NEXUS_5,1.0,en].png
new file mode 100644
index 00000000000..ec410fbb750
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.viewfolder.impl.folder_ViewFolderView_null_ViewFolderView-Night-1_3_null_1,NEXUS_5,1.0,en].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:aa145b94e5cf20f651d965cfa932163759e3c0dd7f3b8589e53d43b25ff94102
+size 10566