diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2d547f..1155b6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,17 @@ upload needs a **higher `versionCode`** than every previous upload.
### Added
+- **M4 — the browser UI.** The app is now usable end to end. `StorageLocation` (with a
+ real `StorageProvider` and a fake for tests) decides which backend is in effect;
+ `BrowserViewModel` owns navigation, sorting, hidden files, list/grid, search, selection
+ and the clipboard, and turns an `ASK` conflict into a prompt rather than a guess. The
+ screen offers roots and folder lists with breadcrumbs, a details sheet, new-folder /
+ rename / delete / conflict dialogs, and the "All files access" and SAF-folder prompts —
+ including an upgrade banner so declining full access never blocks browsing. Deletes go
+ through the platform's own confirmation when `MediaStore` can build one and fall back to
+ a direct delete otherwise; a `FileProvider` lets other apps open and receive files
+ without a `file://` URI ever leaving the process. 16 new JVM tests (115 total).
+
- **M3 — operations engine and foreground service.** `domain/FileOperations` turns the
backend primitives into copy, move, delete and rename: it walks folders, counts the
work up front so the progress bar has an honest denominator, checks for cancellation
diff --git a/README.md b/README.md
index f3bb844..2bb8350 100644
--- a/README.md
+++ b/README.md
@@ -12,11 +12,11 @@ no advertising or tracking SDKs.
## Status
-**M3 — operations engine: done.** Copy, move, delete and rename run on top of the
-storage backends with progress, cancellation and per-item failure reporting, and a
-`dataSync` foreground service keeps long operations alive behind a progress
-notification. Covered by 99 JVM unit tests (23 added here). See
-[docs/ROADMAP.md](docs/ROADMAP.md) for the full milestone plan.
+**M4 — browser UI: done.** The file manager is usable: browse volumes and folders, search,
+sort, multi-select, copy/cut/paste, rename, delete, create folders, view details, open and
+share — plus the "All files access" and SAF-folder permission flows. Covered by 115 JVM
+unit tests (16 added here). See [docs/ROADMAP.md](docs/ROADMAP.md) for the full milestone
+plan.
## Tech stack
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b7d0372..f32a683 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -183,4 +183,6 @@ dependencies {
testImplementation(libs.junit)
// Runs the real Android framework on the JVM, so the data layer is testable without a device.
testImplementation(libs.robolectric)
+ // Lets a ViewModel test drive `viewModelScope` on a deterministic dispatcher.
+ testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 18b7064..a576b22 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -79,6 +79,19 @@
android:name=".service.FileOpService"
android:exported="false"
android:foregroundServiceType="dataSync" />
+
+
+
+
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/FileManagerApplication.kt b/app/src/main/java/de/softwareapp_hb/filemanager/FileManagerApplication.kt
index a65aa98..e7ed445 100644
--- a/app/src/main/java/de/softwareapp_hb/filemanager/FileManagerApplication.kt
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/FileManagerApplication.kt
@@ -1,13 +1,37 @@
package de.softwareapp_hb.filemanager
import android.app.Application
+import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
+import de.softwareapp_hb.filemanager.data.MediaStoreDeleter
+import de.softwareapp_hb.filemanager.data.StorageProvider
+import de.softwareapp_hb.filemanager.domain.OperationNotifier
+import de.softwareapp_hb.filemanager.service.ServiceOperationNotifier
/**
- * Application entry point.
+ * Application entry point and the app's tiny object graph.
*
- * Deliberately lightweight: the app has no dependency-injection graph, no analytics
- * and no network access. From M2 this will own the browser/operations controller so
- * that navigation and long-running file operations survive activity recreation; the
- * storage layer and preference stores are opened lazily where they are used.
+ * There is no dependency-injection framework here on purpose: three long-lived
+ * collaborators are built once and handed to the one ViewModel that needs them. Nothing
+ * holds a reference to an Activity, so none of this leaks.
*/
-class FileManagerApplication : Application()
+class FileManagerApplication : Application() {
+
+ /** Where files live and which backend is in effect. */
+ lateinit var storage: StorageProvider
+ private set
+
+ /** Keeps the foreground service in step with a running operation. */
+ lateinit var notifier: OperationNotifier
+ private set
+
+ /** Builds the platform's own delete confirmation, when it can. */
+ lateinit var deleteRequests: DeleteRequestFactory
+ private set
+
+ override fun onCreate() {
+ super.onCreate()
+ storage = StorageProvider(this)
+ notifier = ServiceOperationNotifier(this)
+ deleteRequests = MediaStoreDeleter(this).factory()
+ }
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/Timestamps.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/Timestamps.kt
new file mode 100644
index 0000000..f34a2f2
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/Timestamps.kt
@@ -0,0 +1,20 @@
+package de.softwareapp_hb.filemanager.core
+
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+/**
+ * Timestamps for the details sheet.
+ *
+ * The pattern is deliberately fixed and unlocalised, and the zone is passed in, so the
+ * output is the same everywhere and can be asserted in a test.
+ */
+object Timestamps {
+
+ private val FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
+
+ /** `2026-10-14 15:04`, or `""` when the backend reported no timestamp. */
+ fun format(millis: Long, zone: ZoneId = ZoneId.systemDefault()): String =
+ if (millis <= 0L) "" else Instant.ofEpochMilli(millis).atZone(zone).format(FORMATTER)
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/MediaStoreDeleter.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/MediaStoreDeleter.kt
new file mode 100644
index 0000000..7dfb3c3
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/MediaStoreDeleter.kt
@@ -0,0 +1,73 @@
+package de.softwareapp_hb.filemanager.data
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Build
+import android.provider.MediaStore
+import androidx.activity.result.IntentSenderRequest
+import de.softwareapp_hb.filemanager.core.StoragePath
+
+/**
+ * Builds the platform's own delete confirmation.
+ *
+ * From Android 11 the supported way to remove shared media is to hand the user the system
+ * dialog and act on what it returns. That dialog can only be launched from an Activity, so
+ * this port *builds* the request and the screen launches it.
+ *
+ * Returning `null` means "the platform cannot do this for these files" — the caller then
+ * deletes directly through the storage backend, which is legitimate under "All files
+ * access".
+ */
+fun interface DeleteRequestFactory {
+ fun request(paths: List, trash: Boolean): IntentSenderRequest?
+}
+
+/**
+ * A best-effort [DeleteRequestFactory] backed by `MediaStore`.
+ *
+ * `MediaStore` only knows about *media* files, and resolving an arbitrary path to its
+ * row genuinely requires the (deprecated) `DATA` column, so this deliberately returns
+ * `null` the moment anything does not resolve. The direct-delete path is the norm; this
+ * only upgrades the experience for media the system already indexes.
+ */
+class MediaStoreDeleter(private val context: Context) {
+
+ val trashAvailable: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+
+ fun factory(): DeleteRequestFactory = DeleteRequestFactory { paths, trash ->
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || paths.isEmpty()) {
+ return@DeleteRequestFactory null
+ }
+
+ val uris = paths.map { uriFor(it) }
+ if (uris.any { it == null }) return@DeleteRequestFactory null
+
+ runCatching {
+ val resolver = context.contentResolver
+ val requested = if (trash) {
+ MediaStore.createTrashRequest(resolver, uris.filterNotNull(), true)
+ } else {
+ MediaStore.createDeleteRequest(resolver, uris.filterNotNull())
+ }
+ IntentSenderRequest.Builder(requested.intentSender).build()
+ }.getOrNull()
+ }
+
+ /**
+ * `InlinedApi` is expected: `VOLUME_EXTERNAL` is a compile-time `String` constant that
+ * is inlined, and the branch above is already guarded by the API check.
+ */
+ @SuppressLint("InlinedApi")
+ private fun uriFor(path: StoragePath): android.net.Uri? = runCatching {
+ val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
+ context.contentResolver.query(
+ collection,
+ arrayOf(MediaStore.MediaColumns._ID),
+ "${MediaStore.MediaColumns.DATA} = ?",
+ arrayOf(path.value),
+ null,
+ )?.use { cursor ->
+ if (cursor.moveToFirst()) collection.buildUpon().appendPath(cursor.getLong(0).toString()).build() else null
+ }
+ }.getOrNull()
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageLocation.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageLocation.kt
new file mode 100644
index 0000000..95e3397
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageLocation.kt
@@ -0,0 +1,87 @@
+package de.softwareapp_hb.filemanager.data
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import de.softwareapp_hb.filemanager.core.StoragePath
+
+/**
+ * Everything the browser needs to know about where files live and what it may touch.
+ *
+ * An interface rather than a concrete class so the ViewModel and its tests never need a
+ * `Context`: the real implementation reads the platform, a fake one answers from a map.
+ */
+interface StorageLocation {
+
+ /** Which backend is in effect right now. */
+ fun mode(): StorageAccessMode
+
+ /** `true` when the user still has to grant "All files access" in Settings. */
+ fun needsAllFilesAccess(): Boolean
+
+ /** The volumes to offer, primary first. */
+ fun volumes(): List
+
+ /** The backend for the current mode, or `null` when nothing is granted yet. */
+ fun activeSource(): StorageSource?
+
+ /** The granted tree's display name, or `null` in the all-files mode. */
+ fun treeLabel(): String?
+
+ fun space(path: StoragePath): SpaceInfo?
+}
+
+/**
+ * The real [StorageLocation]: reads the access mode, enumerates volumes and hands back the
+ * matching backend.
+ *
+ * When "All files access" is granted the app browses everything through [AllFilesSource].
+ * When it is declined it falls back to [SafSource] over a tree the user picked — and until
+ * such a tree exists the browser has no source at all, which the UI reports rather than
+ * showing an empty screen with no explanation.
+ */
+class StorageProvider(
+ private val context: Context,
+ private val access: StorageAccess = StorageAccess.forContext(context),
+ private val volumes: VolumeRepository = VolumeRepository.forContext(context),
+) : StorageLocation {
+
+ private var treeUri: Uri? = SafGrant.persistedTrees(context.contentResolver).firstOrNull()
+
+ override fun mode(): StorageAccessMode = access.mode()
+
+ override fun needsAllFilesAccess(): Boolean = access.needsAllFilesAccessRequest()
+
+ override fun volumes(): List = volumes.volumes()
+
+ override fun space(path: StoragePath): SpaceInfo? = volumes.space(path)
+
+ override fun activeSource(): StorageSource? = when (mode()) {
+ StorageAccessMode.ALL_FILES -> AllFilesSource(
+ roots = volumes.rootEntries(),
+ freeSpace = { raw -> volumes.space(StoragePath.of(raw))?.freeBytes },
+ )
+
+ StorageAccessMode.SAF_FALLBACK -> treeUri?.let { SafSource(context.contentResolver, it) }
+ }
+
+ /** `true` once a folder tree has been granted — the SAF fallback needs one to browse. */
+ fun hasTree(): Boolean = treeUri != null
+
+ /** Name of the granted tree, for the UI. */
+ override fun treeLabel(): String? = treeUri?.let { SafSource(context.contentResolver, it).treeLabel }
+
+ /**
+ * Remember the tree the user picked in the system picker and persist the grant, so the
+ * folder survives a restart instead of being forgotten with the process.
+ */
+ fun adoptTree(uri: Uri): Boolean {
+ val persisted = SafGrant.persist(context.contentResolver, uri)
+ treeUri = uri
+ return persisted
+ }
+
+ fun allFilesAccessIntent(): Intent = access.allFilesAccessIntent()
+
+ fun missingRuntimePermissions(): List = access.missingRuntimePermissions()
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/domain/OperationNotifier.kt b/app/src/main/java/de/softwareapp_hb/filemanager/domain/OperationNotifier.kt
new file mode 100644
index 0000000..f00c4e6
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/domain/OperationNotifier.kt
@@ -0,0 +1,25 @@
+package de.softwareapp_hb.filemanager.domain
+
+/**
+ * Keeps the foreground service in step with a running operation.
+ *
+ * A port rather than a direct call so the ViewModel never needs a `Context` (and its
+ * tests never need a device): the real implementation forwards to
+ * [de.softwareapp_hb.filemanager.service.FileOpService], and [None] is used where no
+ * notification is wanted.
+ */
+interface OperationNotifier {
+
+ fun start(kind: OperationKind)
+
+ fun update(progress: OperationProgress)
+
+ fun stop()
+
+ /** Does nothing — for tests and for headless work. */
+ object None : OperationNotifier {
+ override fun start(kind: OperationKind) = Unit
+ override fun update(progress: OperationProgress) = Unit
+ override fun stop() = Unit
+ }
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/service/ServiceOperationNotifier.kt b/app/src/main/java/de/softwareapp_hb/filemanager/service/ServiceOperationNotifier.kt
new file mode 100644
index 0000000..fed8119
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/service/ServiceOperationNotifier.kt
@@ -0,0 +1,19 @@
+package de.softwareapp_hb.filemanager.service
+
+import android.content.Context
+import de.softwareapp_hb.filemanager.domain.OperationKind
+import de.softwareapp_hb.filemanager.domain.OperationNotifier
+import de.softwareapp_hb.filemanager.domain.OperationProgress
+
+/**
+ * The production [OperationNotifier]: forwards to [FileOpService], which owns the
+ * foreground notification and keeps the process alive.
+ */
+class ServiceOperationNotifier(private val context: Context) : OperationNotifier {
+
+ override fun start(kind: OperationKind) = FileOpService.start(context, kind)
+
+ override fun update(progress: OperationProgress) = FileOpService.update(context, progress)
+
+ override fun stop() = FileOpService.stop(context)
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt
new file mode 100644
index 0000000..b11ba55
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt
@@ -0,0 +1,154 @@
+package de.softwareapp_hb.filemanager.ui.browser
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Android
+import androidx.compose.material.icons.filled.Article
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material.icons.filled.FolderZip
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material.icons.filled.InsertDriveFile
+import androidx.compose.material.icons.filled.Movie
+import androidx.compose.material.icons.filled.MusicNote
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import de.softwareapp_hb.filemanager.R
+import de.softwareapp_hb.filemanager.core.FileEntry
+import de.softwareapp_hb.filemanager.core.FileKind
+import de.softwareapp_hb.filemanager.core.FileSize
+
+/** The icon that stands for a file category. */
+fun iconFor(kind: FileKind): ImageVector = when (kind) {
+ FileKind.DIRECTORY -> Icons.Filled.Folder
+ FileKind.IMAGE -> Icons.Filled.Image
+ FileKind.VIDEO -> Icons.Filled.Movie
+ FileKind.AUDIO -> Icons.Filled.MusicNote
+ FileKind.DOCUMENT -> Icons.Filled.Description
+ FileKind.ARCHIVE -> Icons.Filled.FolderZip
+ FileKind.APK -> Icons.Filled.Android
+ FileKind.TEXT -> Icons.Filled.Article
+ FileKind.OTHER -> Icons.Filled.InsertDriveFile
+}
+
+/** One row of the list view. */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun EntryRow(
+ entry: FileEntry,
+ selected: Boolean,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+) {
+ ListItem(
+ modifier = modifier
+ .fillMaxWidth()
+ .combinedClickable(onClick = onClick, onLongClick = onLongClick),
+ colors = if (selected) {
+ ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
+ } else {
+ ListItemDefaults.colors()
+ },
+ leadingContent = {
+ Icon(
+ imageVector = iconFor(entry.kind),
+ contentDescription = null,
+ modifier = Modifier.size(28.dp),
+ tint = if (entry.isDirectory) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ },
+ headlineContent = { Text(text = entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ supportingContent = { Text(text = entry.subtitle(), style = MaterialTheme.typography.bodySmall) },
+ trailingContent = if (selected) {
+ { Icon(imageVector = Icons.Filled.Check, contentDescription = null) }
+ } else {
+ null
+ },
+ )
+}
+
+/** One cell of the grid view. */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun EntryGridCell(
+ entry: FileEntry,
+ selected: Boolean,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+) {
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .combinedClickable(onClick = onClick, onLongClick = onLongClick),
+ shape = RoundedCornerShape(12.dp),
+ color = if (selected) {
+ MaterialTheme.colorScheme.primaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ },
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = iconFor(entry.kind),
+ contentDescription = null,
+ modifier = Modifier.size(40.dp),
+ tint = if (entry.isDirectory) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = entry.name,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = entry.subtitle(),
+ style = MaterialTheme.typography.labelSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+/** The subtitle under a name: a folder says so, a file says how big it is. */
+@Composable
+private fun FileEntry.subtitle(): String =
+ if (isDirectory) stringResource(R.string.browser_details_folder) else FileSize.format(sizeBytes)
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt
new file mode 100644
index 0000000..6179158
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt
@@ -0,0 +1,151 @@
+package de.softwareapp_hb.filemanager.ui.browser
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.softwareapp_hb.filemanager.R
+import de.softwareapp_hb.filemanager.core.FileEntry
+import de.softwareapp_hb.filemanager.core.StoragePath
+import de.softwareapp_hb.filemanager.data.StorageAccessMode
+
+/** Everything below the app bar: the permission prompt, the roots, or a directory. */
+@Composable
+fun BrowserContent(
+ state: BrowserUiState,
+ contentPadding: PaddingValues,
+ crumbLabel: (StoragePath) -> String,
+ onOpenEntry: (FileEntry) -> Unit,
+ onLongPressEntry: (StoragePath) -> Unit,
+ onOpenRoot: (StoragePath) -> Unit,
+ onNavigate: (StoragePath) -> Unit,
+ onGrantAllFiles: () -> Unit,
+ onPickFolder: () -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(contentPadding),
+ ) {
+ when {
+ state.needsTreeGrant -> StorageAccessPrompt(
+ folderMode = true,
+ onGrantAllFiles = onGrantAllFiles,
+ onPickFolder = onPickFolder,
+ )
+
+ state.isRootView -> LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(items = state.roots, key = { it.path.value }) { root ->
+ RootRow(item = root, onClick = { onOpenRoot(root.path) })
+ }
+ }
+
+ else -> DirectoryView(
+ state = state,
+ crumbLabel = crumbLabel,
+ onOpenEntry = onOpenEntry,
+ onLongPressEntry = onLongPressEntry,
+ onNavigate = onNavigate,
+ onGrantAllFiles = onGrantAllFiles,
+ )
+ }
+
+ if (state.loading) {
+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter))
+ }
+ }
+}
+
+@Composable
+private fun DirectoryView(
+ state: BrowserUiState,
+ crumbLabel: (StoragePath) -> String,
+ onOpenEntry: (FileEntry) -> Unit,
+ onLongPressEntry: (StoragePath) -> Unit,
+ onNavigate: (StoragePath) -> Unit,
+ onGrantAllFiles: () -> Unit,
+) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ Breadcrumbs(crumbs = state.breadcrumbs, labelFor = crumbLabel, onNavigate = onNavigate)
+
+ // Full access is a better experience than the folder fallback, so offer the upgrade
+ // without ever blocking the browsing the user can already do.
+ if (state.needsAllFilesAccess && state.mode == StorageAccessMode.SAF_FALLBACK) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.browser_all_files_body),
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(onClick = onGrantAllFiles) {
+ Text(stringResource(R.string.browser_all_files_action))
+ }
+ }
+ }
+
+ if (state.entries.isEmpty()) {
+ EmptyState(
+ message = if (state.query.isBlank()) {
+ stringResource(R.string.browser_empty_folder)
+ } else {
+ stringResource(R.string.browser_no_matches, state.query)
+ },
+ modifier = Modifier.fillMaxSize(),
+ )
+ return@Column
+ }
+
+ when (state.viewMode) {
+ ViewMode.LIST -> LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(items = state.entries, key = { it.path.value }) { entry ->
+ EntryRow(
+ entry = entry,
+ selected = entry.path in state.selection,
+ onClick = { onOpenEntry(entry) },
+ onLongClick = { onLongPressEntry(entry.path) },
+ )
+ }
+ }
+
+ ViewMode.GRID -> LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 108.dp),
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(items = state.entries, key = { it.path.value }) { entry ->
+ EntryGridCell(
+ entry = entry,
+ selected = entry.path in state.selection,
+ onClick = { onOpenEntry(entry) },
+ onLongClick = { onLongPressEntry(entry.path) },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt
new file mode 100644
index 0000000..ca7fd4c
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt
@@ -0,0 +1,128 @@
+package de.softwareapp_hb.filemanager.ui.browser
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import de.softwareapp_hb.filemanager.R
+import de.softwareapp_hb.filemanager.core.ConflictPolicy
+
+/** A single-field dialog, used for "New folder" and "Rename". */
+@Composable
+fun NameDialog(
+ title: String,
+ confirmLabel: String,
+ initialValue: String = "",
+ onDismiss: () -> Unit,
+ onConfirm: (String) -> Unit,
+) {
+ var value by remember { mutableStateOf(initialValue) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = value,
+ onValueChange = { value = it },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ label = { Text(stringResource(R.string.browser_name_label)) },
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ imeAction = ImeAction.Done,
+ ),
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(value) },
+ enabled = value.isNotBlank(),
+ ) {
+ Text(confirmLabel)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.browser_action_cancel)) }
+ },
+ )
+}
+
+/** A yes/no dialog, used for confirming a permanent delete. */
+@Composable
+fun ConfirmDialog(
+ title: String,
+ body: String,
+ confirmLabel: String,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = { Text(body) },
+ confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.browser_action_cancel)) }
+ },
+ )
+}
+
+/**
+ * The prompt shown when a paste would overwrite something.
+ *
+ * The engine refuses to guess ([ConflictPolicy.ASK] is surfaced rather than resolved), so
+ * the choice is offered here and then applied to the whole batch.
+ */
+@Composable
+fun ConflictDialog(
+ prompt: ConflictPrompt,
+ onChoice: (ConflictPolicy) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.conflict_title)) },
+ text = {
+ Text(
+ androidx.compose.ui.res.pluralStringResource(
+ R.plurals.conflict_body,
+ prompt.conflicts,
+ prompt.conflicts,
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = { onChoice(ConflictPolicy.KEEP_BOTH) }) {
+ Text(stringResource(R.string.conflict_keep_both))
+ }
+ },
+ dismissButton = {
+ Column {
+ TextButton(onClick = { onChoice(ConflictPolicy.REPLACE) }) {
+ Text(stringResource(R.string.conflict_replace))
+ }
+ TextButton(onClick = { onChoice(ConflictPolicy.SKIP) }) {
+ Text(stringResource(R.string.conflict_skip))
+ }
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.browser_action_cancel)) }
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserRoots.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserRoots.kt
new file mode 100644
index 0000000..9f4237d
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserRoots.kt
@@ -0,0 +1,119 @@
+package de.softwareapp_hb.filemanager.ui.browser
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.SdCard
+import androidx.compose.material.icons.filled.Storage
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import de.softwareapp_hb.filemanager.R
+import de.softwareapp_hb.filemanager.core.FileSize
+import de.softwareapp_hb.filemanager.core.StoragePath
+
+/** A row on the root screen: a volume, with its free space when the platform reports it. */
+@Composable
+fun RootRow(item: RootItem, modifier: Modifier = Modifier, onClick: () -> Unit) {
+ val name = item.label ?: item.labelRes?.let { stringResource(it) }.orEmpty()
+ val removable = item.labelRes == null
+
+ ListItem(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ leadingContent = {
+ Icon(
+ imageVector = if (removable) Icons.Filled.SdCard else Icons.Filled.Storage,
+ contentDescription = null,
+ modifier = Modifier.size(28.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ headlineContent = { Text(name) },
+ supportingContent = item.freeBytes?.let { free ->
+ { Text(stringResource(R.string.browser_free_space, FileSize.format(free))) }
+ },
+ )
+}
+
+/** "12 items", pluralised properly. */
+@Composable
+fun itemCountLabel(count: Int): String =
+ pluralStringResource(R.plurals.browser_item_count, count, count)
+
+/** Shown when a folder is empty, or when a search filters everything out. */
+@Composable
+fun EmptyState(message: String, modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+/**
+ * The breadcrumb trail.
+ *
+ * A [LazyRow] rather than a wrapping `Row`: a deep path simply scrolls instead of
+ * starving the entries of horizontal space.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun Breadcrumbs(
+ crumbs: List,
+ labelFor: (StoragePath) -> String,
+ onNavigate: (StoragePath) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyRow(
+ modifier = modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start,
+ ) {
+ items(items = crumbs, key = { it.value }) { crumb ->
+ Text(
+ text = labelFor(crumb),
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .combinedClickable(onClick = { onNavigate(crumb) }, onLongClick = { onNavigate(crumb) })
+ .padding(vertical = 10.dp, horizontal = 2.dp),
+ color = if (crumb == crumbs.last()) {
+ MaterialTheme.colorScheme.onSurface
+ } else {
+ MaterialTheme.colorScheme.primary
+ },
+ )
+ if (crumb != crumbs.last()) {
+ Text(text = " / ", style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt
index f74b91d..93eb145 100644
--- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt
@@ -1,56 +1,274 @@
package de.softwareapp_hb.filemanager.ui.browser
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.ContentCut
+import androidx.compose.material.icons.filled.CreateNewFolder
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.SelectAll
+import androidx.compose.material.icons.filled.Sort
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
+import androidx.compose.runtime.DisposableEffect
+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 androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import de.softwareapp_hb.filemanager.FileManagerApplication
import de.softwareapp_hb.filemanager.R
+import de.softwareapp_hb.filemanager.core.ConflictPolicy
+import de.softwareapp_hb.filemanager.core.FileEntry
+import de.softwareapp_hb.filemanager.core.StoragePath
-/**
- * The browser screen.
- *
- * M0 scaffold: it establishes the Compose shell (edge-to-edge scaffold, themed top
- * bar, empty state) that the storage layer and file list will be wired into in M2/M4.
- */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun BrowserScreen(modifier: Modifier = Modifier) {
- Scaffold(
- modifier = modifier.fillMaxSize(),
- topBar = {
- TopAppBar(title = { Text(text = stringResource(R.string.app_name)) })
- },
- ) { innerPadding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding)
- .padding(horizontal = 32.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center,
- ) {
- Text(
- text = stringResource(R.string.empty_state_title),
- style = MaterialTheme.typography.titleLarge,
- )
- Spacer(Modifier.height(8.dp))
- Text(
- text = stringResource(R.string.empty_state_body),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
+fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewModel.Factory)) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val provider = remember(context) { (context.applicationContext as FileManagerApplication).storage }
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ var showSort by remember { mutableStateOf(false) }
+ var detailsFor by remember { mutableStateOf(null) }
+ var showNewFolder by remember { mutableStateOf(false) }
+ var renameTarget by remember { mutableStateOf(null) }
+ var confirmDelete by remember { mutableStateOf?>(null) }
+ var searchOpen by remember { mutableStateOf(false) }
+ var menuOpen by remember { mutableStateOf(false) }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions(),
+ ) {
+ if (provider.needsAllFilesAccess()) context.startActivity(provider.allFilesAccessIntent())
+ viewModel.refresh()
+ }
+
+ val treeLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenDocumentTree(),
+ ) { uri ->
+ if (uri == null) viewModel.refresh() else viewModel.onTreeAdopted(provider.adoptTree(uri))
+ }
+
+ val deleteLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.StartIntentSenderForResult(),
+ ) { viewModel.onDeleteRequestFinished() }
+
+ // Re-read the access mode whenever the screen comes back — the user may have just
+ // granted "All files access" in Settings.
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) viewModel.refresh()
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ LaunchedEffect(state.pendingDeleteRequest) {
+ state.pendingDeleteRequest?.let { deleteLauncher.launch(it) }
+ }
+
+ val message = state.message
+ val messageText = message?.let {
+ it.pluralQuantity?.let { quantity ->
+ pluralStringResource(it.textRes, quantity, *it.args.toTypedArray())
+ } ?: stringResource(it.textRes, *it.args.toTypedArray())
+ }
+ LaunchedEffect(message?.id) {
+ if (messageText != null) {
+ snackbarHostState.showSnackbar(messageText)
+ viewModel.consumeMessage()
}
}
+
+ val rootLabel = state.roots.firstOrNull()?.let { root ->
+ root.label ?: root.labelRes?.let { stringResource(it) }
+ } ?: stringResource(R.string.browser_internal_storage)
+ val crumbLabel: (StoragePath) -> String = { path ->
+ if (path.isRoot || state.roots.any { it.path == path }) rootLabel else path.name
+ }
+ val title = when {
+ state.isRootView -> stringResource(R.string.browser_roots_title)
+ state.path == null || state.path!!.isRoot || state.roots.any { it.path == state.path } -> rootLabel
+ else -> state.path!!.name
+ }
+
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ BrowserTopBar(
+ title = title,
+ canNavigateUp = !state.isRootView,
+ query = state.query,
+ searchOpen = searchOpen,
+ canPaste = state.clipboard?.isEmpty == false,
+ onUp = viewModel::navigateUp,
+ onQuery = viewModel::setQuery,
+ onSearchOpen = { searchOpen = it },
+ onSort = { showSort = true },
+ onPaste = viewModel::paste,
+ onRefresh = viewModel::refresh,
+ )
+ },
+ floatingActionButton = {
+ if (!state.isRootView && !state.isSelecting && !state.loading && !state.needsTreeGrant) {
+ FloatingActionButton(onClick = { showNewFolder = true }) {
+ Icon(
+ imageVector = Icons.Filled.CreateNewFolder,
+ contentDescription = stringResource(R.string.browser_action_new_folder),
+ )
+ }
+ }
+ },
+ bottomBar = {
+ if (state.isSelecting) {
+ SelectionBar(
+ state = state,
+ onCopy = { viewModel.copyToClipboard(state.selection.toList(), cut = false) },
+ onCut = { viewModel.copyToClipboard(state.selection.toList(), cut = true) },
+ onDelete = { confirmDelete = state.selection.toList() },
+ onDetails = { detailsFor = state.selectedEntries.firstOrNull() },
+ onRename = { renameTarget = state.selectedEntries.singleOrNull() },
+ onShare = {
+ state.selectedEntries.singleOrNull()?.let { entry ->
+ shareIntent(context, entry)?.let { intent ->
+ runCatching { context.startActivity(Intent.createChooser(intent, null)) }
+ }
+ }
+ },
+ onSelectAll = viewModel::selectAll,
+ onClear = viewModel::clearSelection,
+ )
+ }
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ ) { innerPadding ->
+ BrowserContent(
+ state = state,
+ contentPadding = innerPadding,
+ crumbLabel = crumbLabel,
+ onOpenEntry = viewModel::open,
+ onLongPressEntry = viewModel::toggleSelection,
+ onOpenRoot = viewModel::openDirectory,
+ onNavigate = viewModel::openDirectory,
+ onGrantAllFiles = {
+ val missing = provider.missingRuntimePermissions()
+ if (missing.isEmpty()) {
+ context.startActivity(provider.allFilesAccessIntent())
+ } else {
+ permissionLauncher.launch(missing.toTypedArray())
+ }
+ },
+ onPickFolder = { treeLauncher.launch(null) },
+ )
+ }
+
+ if (showSort) {
+ SortSheet(
+ sort = state.sort,
+ showHidden = state.showHidden,
+ viewMode = state.viewMode,
+ onSortKey = viewModel::setSort,
+ onToggleDirection = viewModel::toggleSortDirection,
+ onToggleHidden = viewModel::toggleShowHidden,
+ onViewMode = viewModel::setViewMode,
+ onDismiss = { showSort = false },
+ )
+ }
+
+ detailsFor?.let { entry ->
+ DetailsSheet(entry = entry, onDismiss = { detailsFor = null })
+ }
+
+ if (showNewFolder) {
+ NameDialog(
+ title = stringResource(R.string.browser_new_folder_title),
+ confirmLabel = stringResource(R.string.browser_action_create),
+ initialValue = stringResource(R.string.browser_new_folder_default),
+ onDismiss = { showNewFolder = false },
+ onConfirm = { name ->
+ showNewFolder = false
+ viewModel.createFolder(name)
+ },
+ )
+ }
+
+ renameTarget?.let { entry ->
+ NameDialog(
+ title = stringResource(R.string.browser_rename_title),
+ confirmLabel = stringResource(R.string.browser_action_rename_confirm),
+ initialValue = entry.name,
+ onDismiss = { renameTarget = null },
+ onConfirm = { name ->
+ renameTarget = null
+ viewModel.rename(entry.path, name)
+ },
+ )
+ }
+
+ confirmDelete?.let { paths ->
+ ConfirmDialog(
+ title = stringResource(R.string.browser_delete_title),
+ body = pluralStringResource(R.plurals.browser_delete_body, paths.size, paths.size),
+ confirmLabel = stringResource(R.string.browser_action_delete_confirm),
+ onDismiss = { confirmDelete = null },
+ onConfirm = {
+ confirmDelete = null
+ viewModel.delete(paths)
+ },
+ )
+ }
+
+ state.conflict?.let { prompt ->
+ ConflictDialog(
+ prompt = prompt,
+ onChoice = { policy -> viewModel.resolveConflict(policy) },
+ onDismiss = viewModel::dismissConflict,
+ )
+ }
}
+
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt
new file mode 100644
index 0000000..c317eb4
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt
@@ -0,0 +1,115 @@
+package de.softwareapp_hb.filemanager.ui.browser
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.ContentPaste
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Sort
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import de.softwareapp_hb.filemanager.R
+
+/** The browser's app bar: title, up, and the search / sort / overflow actions. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BrowserTopBar(
+ title: String,
+ canNavigateUp: Boolean,
+ query: String,
+ searchOpen: Boolean,
+ canPaste: Boolean,
+ onUp: () -> Unit,
+ onQuery: (String) -> Unit,
+ onSearchOpen: (Boolean) -> Unit,
+ onSort: () -> Unit,
+ onPaste: () -> Unit,
+ onRefresh: () -> Unit,
+) {
+ var menuOpen by remember { mutableStateOf(false) }
+
+ TopAppBar(
+ title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ navigationIcon = {
+ if (canNavigateUp) {
+ IconButton(onClick = onUp) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.browser_up),
+ )
+ }
+ }
+ },
+ actions = {
+ if (searchOpen) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQuery,
+ placeholder = { Text(stringResource(R.string.browser_search_hint)) },
+ singleLine = true,
+ modifier = Modifier.width(200.dp),
+ )
+ IconButton(onClick = { onSearchOpen(false); onQuery("") }) {
+ Icon(
+ imageVector = Icons.Filled.Close,
+ contentDescription = stringResource(R.string.browser_search_clear),
+ )
+ }
+ } else {
+ IconButton(onClick = { onSearchOpen(true) }) {
+ Icon(
+ imageVector = Icons.Filled.Search,
+ contentDescription = stringResource(R.string.browser_search_hint),
+ )
+ }
+ IconButton(onClick = onSort) {
+ Icon(
+ imageVector = Icons.Filled.Sort,
+ contentDescription = stringResource(R.string.browser_action_sort),
+ )
+ }
+ Box {
+ IconButton(onClick = { menuOpen = true }) {
+ Icon(
+ imageVector = Icons.Filled.MoreVert,
+ contentDescription = stringResource(R.string.browser_action_more),
+ )
+ }
+ DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
+ if (canPaste) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.browser_action_paste)) },
+ leadingIcon = { Icon(Icons.Filled.ContentPaste, contentDescription = null) },
+ onClick = { menuOpen = false; onPaste() },
+ )
+ }
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.browser_action_refresh)) },
+ leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) },
+ onClick = { menuOpen = false; onRefresh() },
+ )
+ }
+ }
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt
new file mode 100644
index 0000000..9d6bdeb
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt
@@ -0,0 +1,89 @@
+package de.softwareapp_hb.filemanager.ui.browser
+
+import androidx.activity.result.IntentSenderRequest
+import androidx.annotation.AnyRes
+import androidx.annotation.StringRes
+import de.softwareapp_hb.filemanager.core.ConflictPolicy
+import de.softwareapp_hb.filemanager.core.FileEntry
+import de.softwareapp_hb.filemanager.core.SortSpec
+import de.softwareapp_hb.filemanager.core.StoragePath
+import de.softwareapp_hb.filemanager.data.StorageAccessMode
+
+enum class ViewMode { LIST, GRID }
+
+/** What the user has cut or copied, waiting to be pasted somewhere. */
+data class Clipboard(val paths: List, val cut: Boolean) {
+ val isEmpty: Boolean get() = paths.isEmpty()
+}
+
+/** A one-shot message for a snackbar. [id] makes repeats of the same text distinguishable. */
+data class UiMessage(
+ /** A `@StringRes` or `@PluralsRes` value; see [pluralQuantity]. */
+ @AnyRes val textRes: Int,
+ val args: List = emptyList(),
+ /** Set when [textRes] names a `` resource; the UI resolves it accordingly. */
+ val pluralQuantity: Int? = null,
+ val id: Long = System.nanoTime(),
+)
+
+/**
+ * A paste that hit existing names, waiting for the user to choose how to resolve it.
+ *
+ * The engine deliberately does not answer conflicts ([ConflictPolicy.ASK] is surfaced
+ * rather than guessed), so this is where that decision lives.
+ */
+data class ConflictPrompt(
+ val paths: List,
+ val target: StoragePath,
+ val cut: Boolean,
+ val conflicts: Int,
+)
+
+/** A browsable storage root as the UI shows it, with a label it can localise. */
+data class RootItem(
+ val path: StoragePath,
+ /** A localised name to use, or `null` when [label] from the provider is enough. */
+ @StringRes val labelRes: Int? = null,
+ val label: String? = null,
+ val freeBytes: Long? = null,
+)
+
+/**
+ * One immutable description of everything the browser shows.
+ *
+ * A single state object rather than a scatter of fields keeps the screen a pure function
+ * of it, and makes every case (roots view, empty folder, no permission) expressible.
+ */
+data class BrowserUiState(
+ val mode: StorageAccessMode = StorageAccessMode.ALL_FILES,
+ val needsAllFilesAccess: Boolean = false,
+ val needsTreeGrant: Boolean = false,
+ val roots: List = emptyList(),
+ /** `null` means "showing the root list" rather than a directory. */
+ val path: StoragePath? = null,
+ val entries: List = emptyList(),
+ val breadcrumbs: List = emptyList(),
+ val query: String = "",
+ val sort: SortSpec = SortSpec(),
+ val showHidden: Boolean = false,
+ val viewMode: ViewMode = ViewMode.LIST,
+ val selection: Set = emptySet(),
+ val clipboard: Clipboard? = null,
+ val loading: Boolean = false,
+ val freeBytes: Long? = null,
+ val message: UiMessage? = null,
+ /** Set once when a file should be handed to the system "open with" chooser. */
+ val pendingOpen: FileEntry? = null,
+ /** Set when the system should confirm a delete; the screen launches it and clears it. */
+ val pendingDeleteRequest: IntentSenderRequest? = null,
+ val conflict: ConflictPrompt? = null,
+) {
+ val isRootView: Boolean get() = path == null
+
+ val isSelecting: Boolean get() = selection.isNotEmpty()
+
+ val selectedEntries: List get() = entries.filter { it.path in selection }
+
+ /** `true` when there is nothing to browse because no permission has been granted. */
+ val needsPermission: Boolean get() = needsAllFilesAccess || needsTreeGrant
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt
new file mode 100644
index 0000000..29c0048
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt
@@ -0,0 +1,444 @@
+package de.softwareapp_hb.filemanager.ui.browser
+
+import androidx.annotation.PluralsRes
+import androidx.annotation.StringRes
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import de.softwareapp_hb.filemanager.FileManagerApplication
+import de.softwareapp_hb.filemanager.R
+import de.softwareapp_hb.filemanager.core.ConflictPolicy
+import de.softwareapp_hb.filemanager.core.FileEntry
+import de.softwareapp_hb.filemanager.core.NameMatcher
+import de.softwareapp_hb.filemanager.core.SortDirection
+import de.softwareapp_hb.filemanager.core.SortKey
+import de.softwareapp_hb.filemanager.core.SortSpec
+import de.softwareapp_hb.filemanager.core.StoragePath
+import de.softwareapp_hb.filemanager.core.TransferAction
+import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
+import de.softwareapp_hb.filemanager.data.StorageAccessMode
+import de.softwareapp_hb.filemanager.data.StorageLocation
+import de.softwareapp_hb.filemanager.domain.FileOperations
+import de.softwareapp_hb.filemanager.domain.OperationKind
+import de.softwareapp_hb.filemanager.domain.OperationNotifier
+import de.softwareapp_hb.filemanager.domain.OperationResult
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+/**
+ * Drives the browser screen.
+ *
+ * It owns navigation and the listing, and delegates every byte-moving decision to
+ * [FileOperations]. Three things it deliberately does *not* do: it never holds a
+ * `Context` (the [notifier] and [deleteRequests] ports stand in for the service and the
+ * platform dialog), it never guesses at a conflict (`ASK` becomes
+ * [BrowserUiState.conflict] for the user to answer), and it never shows raw storage paths
+ * as labels — the root list is built from `VolumeInfo` so "Internal storage" can be
+ * localised.
+ */
+class BrowserViewModel(
+ private val storage: StorageLocation,
+ private val notifier: OperationNotifier = OperationNotifier.None,
+ private val deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null },
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(BrowserUiState())
+ val state: StateFlow = _state.asStateFlow()
+
+ /** The unfiltered listing of the current directory; [recompute] derives what is shown. */
+ private var listing: List = emptyList()
+
+ init {
+ refresh()
+ }
+
+ // ---------------------------------------------------------------- browsing
+
+ /** Re-read the access mode and reload whatever the user was looking at. */
+ fun refresh() {
+ val mode = storage.mode()
+ val source = storage.activeSource()
+
+ _state.update {
+ it.copy(
+ mode = mode,
+ needsAllFilesAccess = storage.needsAllFilesAccess(),
+ needsTreeGrant = mode == StorageAccessMode.SAF_FALLBACK && source == null,
+ roots = rootsOf(),
+ )
+ }
+
+ if (source == null) {
+ listing = emptyList()
+ recompute()
+ return
+ }
+
+ val path = _state.value.path
+ if (path == null) loadRoots() else load(path)
+ }
+
+ fun openDirectory(path: StoragePath) = load(path)
+
+ fun navigateUp() {
+ val path = _state.value.path ?: return
+ val parent = path.parent
+ val isVolumeRoot = storage.volumes().any { it.root == path }
+ if (isVolumeRoot || parent == null || parent.isRoot) loadRoots() else load(parent)
+ }
+
+ private fun loadRoots() {
+ viewModelScope.launch {
+ _state.update { it.copy(loading = true) }
+ listing = emptyList()
+ _state.update {
+ it.copy(
+ path = null,
+ breadcrumbs = emptyList(),
+ selection = emptySet(),
+ query = "",
+ freeBytes = null,
+ loading = false,
+ )
+ }
+ recompute()
+ }
+ }
+
+ private fun load(path: StoragePath) {
+ val source = storage.activeSource() ?: return
+ viewModelScope.launch {
+ _state.update { it.copy(loading = true) }
+ listing = runCatching { source.list(path) }.getOrDefault(emptyList())
+ _state.update {
+ it.copy(
+ path = path,
+ breadcrumbs = breadcrumbsOf(path),
+ selection = emptySet(),
+ freeBytes = storage.space(path)?.freeBytes,
+ loading = false,
+ )
+ }
+ recompute()
+ }
+ }
+
+ private fun recompute() {
+ val current = _state.value
+ val shown = listing
+ .filter { current.showHidden || !it.isHidden }
+ .filter { NameMatcher.matches(it.name, current.query) }
+ _state.update { it.copy(entries = current.sort.sort(shown)) }
+ }
+
+ /**
+ * The trail from the browsable root down to [path].
+ *
+ * It stops at a volume root rather than at the filesystem root: the user is browsing
+ * `/storage/emulated/0`, not the `/storage` and `/` above it, and `navigateUp` agrees.
+ */
+ private fun breadcrumbsOf(path: StoragePath): List {
+ val volumeRoots = storage.volumes().mapTo(HashSet()) { it.root }
+ val crumbs = ArrayList()
+ var current: StoragePath? = path
+ while (current != null) {
+ crumbs += current
+ if (current in volumeRoots) break
+ current = current.parent
+ }
+ return crumbs.reversed()
+ }
+
+ /**
+ * The root list. A granted tree is a single root; otherwise the device's volumes are
+ * shown, with the label the platform supplies for removable media and a localised
+ * name for internal storage.
+ */
+ private fun rootsOf(): List {
+ storage.treeLabel()?.let { label ->
+ return listOf(RootItem(path = StoragePath.Root, label = label))
+ }
+ return storage.volumes().map { volume ->
+ RootItem(
+ path = volume.root,
+ labelRes = if (volume.label == null) R.string.browser_internal_storage else null,
+ label = volume.label,
+ freeBytes = storage.space(volume.root)?.freeBytes,
+ )
+ }
+ }
+
+ // ------------------------------------------------------- view preferences
+
+ fun setQuery(query: String) {
+ _state.update { it.copy(query = query) }
+ recompute()
+ }
+
+ fun setSort(key: SortKey) {
+ _state.update { it.copy(sort = it.sort.copy(key = key)) }
+ recompute()
+ }
+
+ fun toggleSortDirection() {
+ _state.update {
+ val flipped = if (it.sort.direction == SortDirection.ASCENDING) {
+ SortDirection.DESCENDING
+ } else {
+ SortDirection.ASCENDING
+ }
+ it.copy(sort = it.sort.copy(direction = flipped))
+ }
+ recompute()
+ }
+
+ fun toggleShowHidden() {
+ _state.update { it.copy(showHidden = !it.showHidden) }
+ recompute()
+ }
+
+ fun setViewMode(viewMode: ViewMode) {
+ _state.update { it.copy(viewMode = viewMode) }
+ }
+
+ /** Called after the system folder picker returns, with whether the grant was kept. */
+ fun onTreeAdopted(persisted: Boolean) {
+ message(if (persisted) R.string.browser_tree_granted else R.string.browser_tree_rejected)
+ refresh()
+ }
+
+ // ---------------------------------------------------------------- selection
+
+ fun toggleSelection(path: StoragePath) {
+ _state.update {
+ it.copy(selection = if (path in it.selection) it.selection - path else it.selection + path)
+ }
+ }
+
+ fun selectAll() {
+ _state.update { it.copy(selection = it.entries.mapTo(mutableSetOf()) { entry -> entry.path }) }
+ }
+
+ fun clearSelection() {
+ _state.update { it.copy(selection = emptySet()) }
+ }
+
+ // ------------------------------------------------------------------ opening
+
+ /** Open an entry: folders navigate, files are handed to the system chooser. */
+ fun open(entry: FileEntry) {
+ if (entry.isDirectory) {
+ openDirectory(entry.path)
+ } else {
+ _state.update { it.copy(pendingOpen = entry) }
+ }
+ }
+
+ fun consumeOpen() {
+ _state.update { it.copy(pendingOpen = null) }
+ }
+
+ // ---------------------------------------------------------------- clipboard
+
+ fun copyToClipboard(paths: List, cut: Boolean) {
+ _state.update { it.copy(clipboard = Clipboard(paths, cut), selection = emptySet()) }
+ }
+
+ /**
+ * Paste the clipboard into the current directory.
+ *
+ * A clash is not resolved here: the plan is checked first and, if anything would be
+ * overwritten, the user is asked through [BrowserUiState.conflict].
+ */
+ fun paste() {
+ val clipboard = _state.value.clipboard ?: return
+ val target = _state.value.path ?: return
+ val source = storage.activeSource() ?: return
+
+ viewModelScope.launch {
+ val conflicts = runCatching {
+ FileOperations(source)
+ .planTransfer(clipboard.paths, target, ConflictPolicy.ASK)
+ .count { it.action == TransferAction.ASK }
+ }.getOrDefault(0)
+
+ if (conflicts > 0) {
+ _state.update {
+ it.copy(conflict = ConflictPrompt(clipboard.paths, target, clipboard.cut, conflicts))
+ }
+ } else {
+ transfer(clipboard.paths, target, clipboard.cut, ConflictPolicy.KEEP_BOTH)
+ }
+ }
+ }
+
+ fun resolveConflict(policy: ConflictPolicy) {
+ val prompt = _state.value.conflict ?: return
+ _state.update { it.copy(conflict = null) }
+ viewModelScope.launch { transfer(prompt.paths, prompt.target, prompt.cut, policy) }
+ }
+
+ fun dismissConflict() {
+ _state.update { it.copy(conflict = null) }
+ }
+
+ private suspend fun transfer(
+ paths: List,
+ target: StoragePath,
+ cut: Boolean,
+ policy: ConflictPolicy,
+ ) {
+ val source = storage.activeSource() ?: return
+ val operations = FileOperations(source)
+ val kind = if (cut) OperationKind.MOVE else OperationKind.COPY
+
+ _state.update { it.copy(loading = true) }
+ notifier.start(kind)
+ val result: OperationResult = try {
+ if (cut) {
+ operations.move(paths, target, policy) { notifier.update(it) }
+ } else {
+ operations.copy(paths, target, policy) { notifier.update(it) }
+ }
+ } catch (cancellation: CancellationException) {
+ throw cancellation
+ } finally {
+ notifier.stop()
+ }
+
+ _state.update { it.copy(loading = false, clipboard = if (cut) null else it.clipboard) }
+ if (result.succeeded) {
+ messagePlural(if (cut) R.plurals.message_moved else R.plurals.message_copied, result.completed)
+ } else {
+ report(result)
+ }
+
+ val here = _state.value.path
+ if (here == null) refresh() else load(here)
+ }
+
+ // ---------------------------------------------------------------- mutations
+
+ fun createFolder(name: String) {
+ val target = _state.value.path ?: return
+ val source = storage.activeSource() ?: return
+ val cleanName = name.trim()
+ if (cleanName.isEmpty()) return
+
+ viewModelScope.launch {
+ val created = runCatching { source.createDirectory(target.child(cleanName)) }.getOrDefault(false)
+ message(if (created) R.string.message_folder_created else R.string.message_name_in_use)
+ load(target)
+ }
+ }
+
+ fun rename(path: StoragePath, newName: String) {
+ val source = storage.activeSource() ?: return
+ val parent = path.parent ?: return
+ val cleanName = newName.trim()
+ if (cleanName.isEmpty() || cleanName == path.name) return
+
+ viewModelScope.launch {
+ val renamed = runCatching { FileOperations(source).rename(path, parent.child(cleanName)) }
+ .getOrDefault(false)
+ message(if (renamed) R.string.message_renamed else R.string.message_name_in_use)
+ _state.value.path?.let { load(it) }
+ }
+ }
+
+ /**
+ * Delete [paths].
+ *
+ * The platform's own confirmation is used whenever it can be built, because on
+ * Android 11+ that is the only supported way to remove shared media — and it deletes
+ * as a consequence of the user approving, so the app does not have to. Everything
+ * else is deleted directly, which is legitimate under "All files access".
+ */
+ fun delete(paths: List) {
+ if (paths.isEmpty()) return
+
+ deleteRequests.request(paths, trash = true)?.let { request ->
+ _state.update { it.copy(pendingDeleteRequest = request) }
+ return
+ }
+ viewModelScope.launch { deleteDirectly(paths) }
+ }
+
+ /** Clear the request once the screen has launched it. */
+ fun consumeDeleteRequest() {
+ _state.update { it.copy(pendingDeleteRequest = null) }
+ }
+
+ /** Called when the system dialog returns, whether the user confirmed or not. */
+ fun onDeleteRequestFinished() {
+ consumeDeleteRequest()
+ val here = _state.value.path
+ if (here == null) refresh() else load(here)
+ }
+
+ private suspend fun deleteDirectly(paths: List) {
+ val source = storage.activeSource() ?: return
+ val operations = FileOperations(source)
+
+ _state.update { it.copy(loading = true, selection = emptySet()) }
+ notifier.start(OperationKind.DELETE)
+ val result = try {
+ operations.delete(paths) { notifier.update(it) }
+ } catch (cancellation: CancellationException) {
+ throw cancellation
+ } finally {
+ notifier.stop()
+ }
+ _state.update { it.copy(loading = false) }
+
+ if (result.succeeded) {
+ messagePlural(R.plurals.message_deleted, result.completed)
+ } else {
+ report(result)
+ }
+
+ val here = _state.value.path
+ if (here == null) refresh() else load(here)
+ }
+
+ // ----------------------------------------------------------------- messages
+
+ fun consumeMessage() {
+ _state.update { it.copy(message = null) }
+ }
+
+ private fun report(result: OperationResult) {
+ when {
+ result.cancelled -> message(R.string.message_cancelled)
+ result.failures.isNotEmpty() -> messagePlural(R.plurals.message_failed, result.failures.size)
+ }
+ }
+
+ private fun message(@StringRes textRes: Int, vararg args: Any) {
+ _state.update { it.copy(message = UiMessage(textRes, args.toList())) }
+ }
+
+ private fun messagePlural(@PluralsRes textRes: Int, count: Int) {
+ _state.update { it.copy(message = UiMessage(textRes, listOf(count), pluralQuantity = count)) }
+ }
+
+ companion object {
+ val Factory: ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
+ as FileManagerApplication
+ BrowserViewModel(
+ storage = app.storage,
+ notifier = app.notifier,
+ deleteRequests = app.deleteRequests,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt
new file mode 100644
index 0000000..d43f3a7
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt
@@ -0,0 +1,65 @@
+package de.softwareapp_hb.filemanager.ui.browser
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.softwareapp_hb.filemanager.R
+import de.softwareapp_hb.filemanager.core.FileEntry
+import de.softwareapp_hb.filemanager.core.FileSize
+import de.softwareapp_hb.filemanager.core.Timestamps
+
+/** Name, location, size, type and modified date for one entry. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DetailsSheet(entry: FileEntry, onDismiss: () -> Unit) {
+ ModalBottomSheet(onDismissRequest = onDismiss) {
+ Column(modifier = Modifier.padding(bottom = 32.dp)) {
+ Text(
+ text = stringResource(R.string.browser_details_title),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
+ )
+ DetailLine(stringResource(R.string.browser_details_name), entry.name)
+ DetailLine(stringResource(R.string.browser_details_location), entry.path.parent?.value ?: "/")
+ DetailLine(
+ label = stringResource(R.string.browser_details_size),
+ value = if (entry.isDirectory) {
+ stringResource(R.string.browser_details_folder)
+ } else {
+ FileSize.format(entry.sizeBytes)
+ },
+ )
+ DetailLine(stringResource(R.string.browser_details_kind), entry.kind.name.lowercase())
+ Timestamps.format(entry.lastModifiedMillis).takeIf { it.isNotEmpty() }?.let { modified ->
+ DetailLine(stringResource(R.string.browser_details_modified), modified)
+ }
+ }
+ }
+}
+
+@Composable
+private fun DetailLine(label: String, value: String) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 6.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(text = value, style = MaterialTheme.typography.bodyMedium)
+ }
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/EntryIntents.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/EntryIntents.kt
new file mode 100644
index 0000000..f7db06f
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/EntryIntents.kt
@@ -0,0 +1,35 @@
+package de.softwareapp_hb.filemanager.ui.browser
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.core.content.FileProvider
+import de.softwareapp_hb.filemanager.core.FileEntry
+import de.softwareapp_hb.filemanager.core.MimeTypes
+import java.io.File
+
+/**
+ * Wraps an entry's path in a `content://` URI another app may read.
+ *
+ * `null` when the path is not a real file — which is every item reached through the SAF
+ * fallback, whose paths are relative to the granted tree rather than filesystem paths.
+ * Opening and sharing are therefore all-files-mode features for now.
+ */
+fun contentUriFor(context: Context, entry: FileEntry): Uri? = runCatching {
+ FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", File(entry.path.value))
+}.getOrNull()
+
+/** The "open with" chooser intent, or `null` when the entry cannot be handed out. */
+fun viewIntent(context: Context, entry: FileEntry): Intent? = contentUriFor(context, entry)?.let { uri ->
+ Intent(Intent.ACTION_VIEW)
+ .setDataAndType(uri, MimeTypes.forEntry(entry))
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+}
+
+/** The "share" intent, or `null` when the entry cannot be handed out. */
+fun shareIntent(context: Context, entry: FileEntry): Intent? = contentUriFor(context, entry)?.let { uri ->
+ Intent(Intent.ACTION_SEND)
+ .setType(MimeTypes.forEntry(entry))
+ .putExtra(Intent.EXTRA_STREAM, uri)
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt
new file mode 100644
index 0000000..94f771b
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt
@@ -0,0 +1,116 @@
+package de.softwareapp_hb.filemanager.ui.browser
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.ContentCut
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.SelectAll
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.BottomAppBar
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.softwareapp_hb.filemanager.R
+
+/** Shown when the app has nothing it is allowed to browse yet. */
+@Composable
+fun StorageAccessPrompt(
+ folderMode: Boolean,
+ onGrantAllFiles: () -> Unit,
+ onPickFolder: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = stringResource(
+ if (folderMode) R.string.browser_tree_title else R.string.browser_all_files_title,
+ ),
+ style = MaterialTheme.typography.titleLarge,
+ )
+ Spacer(Modifier.padding(4.dp))
+ Text(
+ text = stringResource(
+ if (folderMode) R.string.browser_tree_body else R.string.browser_all_files_body,
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.padding(8.dp))
+ Button(onClick = onGrantAllFiles) {
+ Text(stringResource(R.string.browser_all_files_action))
+ }
+ TextButton(onClick = onPickFolder) {
+ Text(stringResource(R.string.browser_tree_action))
+ }
+ }
+}
+
+/** The contextual action bar shown while entries are selected. */
+@Composable
+fun SelectionBar(
+ state: BrowserUiState,
+ onCopy: () -> Unit,
+ onCut: () -> Unit,
+ onDelete: () -> Unit,
+ onDetails: () -> Unit,
+ onRename: () -> Unit,
+ onShare: () -> Unit,
+ onSelectAll: () -> Unit,
+ onClear: () -> Unit,
+) {
+ BottomAppBar {
+ Text(
+ text = itemCountLabel(state.selection.size),
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ Spacer(Modifier.weight(1f))
+ IconButton(onClick = onCopy) {
+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.browser_action_copy))
+ }
+ IconButton(onClick = onCut) {
+ Icon(Icons.Filled.ContentCut, contentDescription = stringResource(R.string.browser_action_cut))
+ }
+ if (state.selectedEntries.size == 1) {
+ IconButton(onClick = onRename) {
+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.browser_action_rename))
+ }
+ IconButton(onClick = onDetails) {
+ Icon(Icons.Filled.Info, contentDescription = stringResource(R.string.browser_action_details))
+ }
+ IconButton(onClick = onShare) {
+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.browser_action_share))
+ }
+ }
+ IconButton(onClick = onDelete) {
+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.browser_action_delete))
+ }
+ IconButton(onClick = onSelectAll) {
+ Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.browser_action_select_all))
+ }
+ IconButton(onClick = onClear) {
+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.browser_action_clear_selection))
+ }
+ }
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SortSheet.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SortSheet.kt
new file mode 100644
index 0000000..c32a573
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SortSheet.kt
@@ -0,0 +1,128 @@
+package de.softwareapp_hb.filemanager.ui.browser
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.GridView
+import androidx.compose.material.icons.filled.Sort
+import androidx.compose.material.icons.filled.ViewList
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.softwareapp_hb.filemanager.R
+import de.softwareapp_hb.filemanager.core.SortDirection
+import de.softwareapp_hb.filemanager.core.SortKey
+import de.softwareapp_hb.filemanager.core.SortSpec
+
+private fun sortKeyLabel(key: SortKey): Int = when (key) {
+ SortKey.NAME -> R.string.browser_sort_name
+ SortKey.SIZE -> R.string.browser_sort_size
+ SortKey.DATE -> R.string.browser_sort_date
+ SortKey.KIND -> R.string.browser_sort_kind
+}
+
+/** Sort key, direction, hidden files and list/grid — all in one sheet. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SortSheet(
+ sort: SortSpec,
+ showHidden: Boolean,
+ viewMode: ViewMode,
+ onSortKey: (SortKey) -> Unit,
+ onToggleDirection: () -> Unit,
+ onToggleHidden: () -> Unit,
+ onViewMode: (ViewMode) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ ModalBottomSheet(onDismissRequest = onDismiss) {
+ Column(modifier = Modifier.padding(bottom = 24.dp)) {
+ Text(
+ text = stringResource(R.string.browser_sort_by),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
+ )
+
+ SortKey.entries.forEach { key ->
+ ListItem(
+ modifier = Modifier.clickable { onSortKey(key) },
+ headlineContent = { Text(stringResource(sortKeyLabel(key))) },
+ leadingContent = {
+ RadioButton(selected = sort.key == key, onClick = { onSortKey(key) })
+ },
+ trailingContent = if (sort.key == key) {
+ {
+ IconButton(onClick = onToggleDirection) {
+ Icon(
+ imageVector = Icons.Filled.Sort,
+ contentDescription = stringResource(
+ if (sort.direction == SortDirection.ASCENDING) {
+ R.string.browser_sort_ascending
+ } else {
+ R.string.browser_sort_descending
+ },
+ ),
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ } else {
+ null
+ },
+ )
+ }
+
+ HorizontalDivider()
+
+ ListItem(
+ modifier = Modifier.clickable { onToggleHidden() },
+ headlineContent = { Text(stringResource(R.string.browser_action_show_hidden)) },
+ trailingContent = { Switch(checked = showHidden, onCheckedChange = { onToggleHidden() }) },
+ )
+
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.browser_view_mode)) },
+ trailingContent = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = { onViewMode(ViewMode.LIST) }) {
+ Icon(
+ imageVector = Icons.Filled.ViewList,
+ contentDescription = stringResource(R.string.browser_action_view_list),
+ tint = if (viewMode == ViewMode.LIST) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ }
+ IconButton(onClick = { onViewMode(ViewMode.GRID) }) {
+ Icon(
+ imageVector = Icons.Filled.GridView,
+ contentDescription = stringResource(R.string.browser_action_view_grid),
+ tint = if (viewMode == ViewMode.GRID) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ }
+ }
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7ba4557..5403f3c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -10,10 +10,6 @@
-->
File Manager
-
- Your files, without the clutter
- Ad-free, account-free and completely offline. Browsing is wired up next.
-
Copying files
Moving files
@@ -23,4 +19,112 @@
%1$d / %2$d
Preparing…
+
+
+ Full file access needed
+ To browse every folder, grant this app “All files access” in Settings. Without it you can still browse a folder you pick yourself.
+ Grant full access
+ Choose a folder
+ Full access was declined, so pick a folder for the app to work in. The grant is remembered across restarts.
+ Choose folder
+ Folder added
+ The folder could not be remembered
+
+
+ Storage
+ Up
+ Internal storage
+ This folder is empty
+ Nothing matches “%1$s”
+ %1$s free
+
+ - %1$d item
+ - %1$d items
+
+ Search this folder
+ Clear search
+
+
+ New folder
+ Sort
+ Details
+ Copy
+ Move
+ Paste here
+ Rename
+ Delete
+ Share
+ Select all
+ Clear selection
+ More options
+ Refresh
+ Cancel
+ Create
+ Rename
+ Delete
+ Show hidden files
+ View
+ List view
+ Grid view
+
+
+ Sort by
+ Name
+ Size
+ Date
+ Type
+ Ascending
+ Descending
+
+
+ New folder
+ New folder
+ Rename
+ Name
+
+
+ Details
+ Name
+ Location
+ Size
+ Type
+ Modified
+ Folder
+
+
+ Delete permanently?
+
+ - %1$d item will be permanently deleted. This cannot be undone.
+ - %1$d items will be permanently deleted. This cannot be undone.
+
+ Items already exist here
+
+ - %1$d item already has that name. What should happen?
+ - %1$d items already have that name. What should happen?
+
+ Replace
+ Keep both
+ Skip
+
+
+ Folder created
+ Renamed
+ That name is already in use
+
+ - Copied %1$d item
+ - Copied %1$d items
+
+
+ - Moved %1$d item
+ - Moved %1$d items
+
+
+ - Deleted %1$d item
+ - Deleted %1$d items
+
+
+ - %1$d item could not be processed
+ - %1$d items could not be processed
+
+ Cancelled
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..a2627d3
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt
new file mode 100644
index 0000000..72dfd37
--- /dev/null
+++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt
@@ -0,0 +1,302 @@
+package de.softwareapp_hb.filemanager.ui.browser
+
+import android.app.PendingIntent
+import android.content.Intent
+import androidx.activity.result.IntentSenderRequest
+import de.softwareapp_hb.filemanager.core.ConflictPolicy
+import de.softwareapp_hb.filemanager.core.FileEntry
+import de.softwareapp_hb.filemanager.core.SortKey
+import de.softwareapp_hb.filemanager.core.StoragePath
+import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
+import de.softwareapp_hb.filemanager.data.StorageAccessMode
+import de.softwareapp_hb.filemanager.data.VolumeInfo
+import de.softwareapp_hb.filemanager.domain.OperationKind
+import de.softwareapp_hb.filemanager.domain.OperationNotifier
+import de.softwareapp_hb.filemanager.domain.OperationProgress
+import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource
+import de.softwareapp_hb.filemanager.ui.browser.testutil.FakeStorageLocation
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class BrowserViewModelTest {
+
+ private val root = StoragePath.of("/storage/emulated/0")
+ private val downloads = root.child("Download")
+ private val documents = root.child("Documents")
+
+ private lateinit var storage: InMemoryStorageSource
+ private lateinit var location: FakeStorageLocation
+
+ private val recordedNotifications = mutableListOf()
+
+ private val recordingNotifier = object : OperationNotifier {
+ override fun start(kind: OperationKind) {
+ recordedNotifications += kind
+ }
+
+ override fun update(progress: OperationProgress) = Unit
+
+ override fun stop() = Unit
+ }
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ recordedNotifications.clear()
+
+ storage = InMemoryStorageSource(
+ roots = listOf(FileEntry(root, isDirectory = true, sizeBytes = 0L, lastModifiedMillis = 0L)),
+ ).apply {
+ addDirectory(downloads)
+ addDirectory(documents)
+ addFile(root.child("top.txt"), "top")
+ addFile(downloads.child("a.txt"), "first")
+ addFile(downloads.child("b.txt"), "second-longer")
+ addFile(downloads.child(".hidden"), "secret")
+ }
+
+ location = FakeStorageLocation(
+ source = storage,
+ volumes = listOf(VolumeInfo(root, label = null, isRemovable = false, isPrimary = true)),
+ )
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun viewModel(
+ deleteRequests: DeleteRequestFactory = DeleteRequestFactory { _, _ -> null },
+ ) = BrowserViewModel(location, recordingNotifier, deleteRequests)
+
+ @Test
+ fun `starts on the root list`() {
+ val state = viewModel().state.value
+ assertTrue(state.isRootView)
+ assertEquals(listOf(root), state.roots.map { it.path })
+ assertFalse(state.needsTreeGrant)
+ }
+
+ @Test
+ fun `opening a folder lists it with directories first and natural order`() {
+ val model = viewModel()
+ model.openDirectory(downloads)
+
+ val state = model.state.value
+ assertEquals(downloads, state.path)
+ assertEquals(listOf("a.txt", "b.txt"), state.entries.map { it.name })
+ assertEquals(listOf(root, downloads), state.breadcrumbs)
+ }
+
+ @Test
+ fun `hidden files are filtered out until asked for`() {
+ val model = viewModel()
+ model.openDirectory(downloads)
+ assertFalse(model.state.value.entries.any { it.name == ".hidden" })
+
+ model.toggleShowHidden()
+ assertTrue(model.state.value.entries.any { it.name == ".hidden" })
+ }
+
+ @Test
+ fun `search narrows the listing`() {
+ val model = viewModel()
+ model.openDirectory(downloads)
+ model.setQuery("b.txt")
+ assertEquals(listOf("b.txt"), model.state.value.entries.map { it.name })
+ }
+
+ @Test
+ fun `sorting by size reorders the listing`() {
+ val model = viewModel()
+ model.openDirectory(downloads)
+ model.setSort(SortKey.SIZE)
+
+ // "second-longer" is bigger than "first".
+ assertEquals(listOf("a.txt", "b.txt"), model.state.value.entries.map { it.name })
+
+ model.toggleSortDirection()
+ assertEquals(listOf("b.txt", "a.txt"), model.state.value.entries.map { it.name })
+ }
+
+ @Test
+ fun `navigating up goes to the parent then to the roots`() {
+ val model = viewModel()
+ model.openDirectory(downloads)
+ model.navigateUp()
+ assertEquals(root, model.state.value.path)
+
+ model.navigateUp()
+ assertTrue(model.state.value.isRootView)
+ }
+
+ @Test
+ fun `selection survives toggling and clears on navigation`() {
+ val model = viewModel()
+ model.openDirectory(downloads)
+
+ model.toggleSelection(downloads.child("a.txt"))
+ assertTrue(model.state.value.isSelecting)
+ assertEquals(listOf("a.txt"), model.state.value.selectedEntries.map { it.name })
+
+ model.toggleSelection(downloads.child("a.txt"))
+ assertFalse(model.state.value.isSelecting)
+
+ model.toggleSelection(downloads.child("a.txt"))
+ model.navigateUp()
+ assertFalse(model.state.value.isSelecting)
+ }
+
+ @Test
+ fun `createFolder makes a directory in the current folder`() {
+ val model = viewModel()
+ model.openDirectory(downloads)
+ model.createFolder(" New folder ")
+
+ assertTrue(storage.contains(downloads.child("New folder")))
+ assertTrue(model.state.value.entries.any { it.name == "New folder" })
+ }
+
+ @Test
+ fun `rename moves the entry to the new name`() {
+ val model = viewModel()
+ model.openDirectory(downloads)
+ model.rename(downloads.child("a.txt"), "renamed.txt")
+
+ assertFalse(storage.contains(downloads.child("a.txt")))
+ assertTrue(storage.contains(downloads.child("renamed.txt")))
+ }
+
+ @Test
+ fun `delete removes the selected entries directly when the platform has no dialog`() {
+ val model = viewModel()
+ model.openDirectory(downloads)
+ model.delete(listOf(downloads.child("a.txt")))
+
+ assertFalse(storage.contains(downloads.child("a.txt")))
+ assertEquals(listOf(OperationKind.DELETE), recordedNotifications)
+ assertFalse(model.state.value.loading)
+ }
+
+ @Test
+ fun `delete prefers the platform dialog and leaves the files alone until it returns`() {
+ val context = RuntimeEnvironment.getApplication()
+ val request = IntentSenderRequest.Builder(
+ PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_IMMUTABLE).intentSender,
+ ).build()
+
+ val model = viewModel(deleteRequests = DeleteRequestFactory { _, trash ->
+ assertTrue("the trash route should be offered first", trash)
+ request
+ })
+ model.openDirectory(downloads)
+ model.delete(listOf(downloads.child("a.txt")))
+
+ // The system owns the deletion once it has been asked, so nothing is removed here.
+ assertTrue(storage.contains(downloads.child("a.txt")))
+ assertEquals(request, model.state.value.pendingDeleteRequest)
+ assertTrue(recordedNotifications.isEmpty())
+ }
+
+ @Test
+ fun `copy then paste duplicates into the current folder`() {
+ val model = viewModel()
+ model.openDirectory(root)
+ model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false)
+ assertNotNull(model.state.value.clipboard)
+
+ model.openDirectory(documents)
+ model.paste()
+
+ assertTrue(storage.contains(documents.child("a.txt")))
+ assertTrue(storage.contains(downloads.child("a.txt")))
+ assertEquals(listOf(OperationKind.COPY), recordedNotifications)
+ assertNotNull(model.state.value.clipboard)
+ }
+
+ @Test
+ fun `cut then paste moves and clears the clipboard`() {
+ val model = viewModel()
+ model.openDirectory(root)
+ model.copyToClipboard(listOf(downloads.child("a.txt")), cut = true)
+
+ model.openDirectory(documents)
+ model.paste()
+
+ assertTrue(storage.contains(documents.child("a.txt")))
+ assertFalse(storage.contains(downloads.child("a.txt")))
+ assertEquals(listOf(OperationKind.MOVE), recordedNotifications)
+ assertNull(model.state.value.clipboard)
+ }
+
+ @Test
+ fun `a pasted name that already exists asks before overwriting`() {
+ val model = viewModel()
+ storage.addFile(documents.child("a.txt"), "already here")
+
+ model.openDirectory(root)
+ model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false)
+ model.openDirectory(documents)
+ model.paste()
+
+ val prompt = model.state.value.conflict
+ assertNotNull(prompt)
+ assertEquals(1, prompt!!.conflicts)
+ assertEquals("already here", storage.contentOf(documents.child("a.txt")))
+
+ model.resolveConflict(ConflictPolicy.REPLACE)
+ assertEquals("first", storage.contentOf(documents.child("a.txt")))
+ assertNull(model.state.value.conflict)
+ }
+
+ @Test
+ fun `without any grant the browser asks for a folder`() {
+ location.setSource(null)
+ location.setMode(StorageAccessMode.SAF_FALLBACK, needsAllFiles = true)
+
+ val state = viewModel().state.value
+ assertTrue(state.needsTreeGrant)
+ assertTrue(state.needsPermission)
+ assertTrue(state.entries.isEmpty())
+ }
+
+ @Test
+ fun `a granted tree becomes the only root`() {
+ val treeRoot = StoragePath.Root
+ val treeSource = InMemoryStorageSource(
+ roots = listOf(FileEntry(treeRoot, isDirectory = true, sizeBytes = 0L, lastModifiedMillis = 0L)),
+ ).apply { addFile(treeRoot.child("in-tree.txt"), "x") }
+
+ location.setSource(treeSource)
+ location.setMode(StorageAccessMode.SAF_FALLBACK, needsAllFiles = true)
+ location.setTreeLabel("Download")
+
+ val model = viewModel()
+ val state = model.state.value
+ assertFalse(state.needsTreeGrant)
+ assertEquals(listOf(treeRoot), state.roots.map { it.path })
+ assertEquals("Download", state.roots.single().label)
+
+ model.openDirectory(treeRoot)
+ assertEquals(listOf("in-tree.txt"), model.state.value.entries.map { it.name })
+ }
+}
diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/testutil/FakeStorageLocation.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/testutil/FakeStorageLocation.kt
new file mode 100644
index 0000000..c466e5b
--- /dev/null
+++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/testutil/FakeStorageLocation.kt
@@ -0,0 +1,57 @@
+package de.softwareapp_hb.filemanager.ui.browser.testutil
+
+import de.softwareapp_hb.filemanager.core.StoragePath
+import de.softwareapp_hb.filemanager.data.SpaceInfo
+import de.softwareapp_hb.filemanager.data.StorageAccessMode
+import de.softwareapp_hb.filemanager.data.StorageLocation
+import de.softwareapp_hb.filemanager.data.StorageSource
+import de.softwareapp_hb.filemanager.data.VolumeInfo
+
+/**
+ * A [StorageLocation] with no platform behind it.
+ *
+ * It lets the ViewModel be driven through every state — all-files mode, the SAF fallback
+ * with and without a granted tree, and no source at all — without a device or a provider.
+ */
+class FakeStorageLocation(
+ private var source: StorageSource? = null,
+ private var volumes: List = emptyList(),
+ private var treeLabel: String? = null,
+ private var mode: StorageAccessMode = StorageAccessMode.ALL_FILES,
+ private var needsAllFiles: Boolean = false,
+ private var space: SpaceInfo? = null,
+) : StorageLocation {
+
+ fun setSource(source: StorageSource?) {
+ this.source = source
+ }
+
+ fun setMode(mode: StorageAccessMode, needsAllFiles: Boolean) {
+ this.mode = mode
+ this.needsAllFiles = needsAllFiles
+ }
+
+ fun setVolumes(volumes: List) {
+ this.volumes = volumes
+ }
+
+ fun setTreeLabel(label: String?) {
+ treeLabel = label
+ }
+
+ fun setSpace(space: SpaceInfo?) {
+ this.space = space
+ }
+
+ override fun mode(): StorageAccessMode = mode
+
+ override fun needsAllFilesAccess(): Boolean = needsAllFiles
+
+ override fun volumes(): List = volumes
+
+ override fun activeSource(): StorageSource? = source
+
+ override fun treeLabel(): String? = treeLabel
+
+ override fun space(path: StoragePath): SpaceInfo? = space
+}
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index dda2cba..287bfd5 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -13,7 +13,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
| M1 | Pure-Kotlin core + JVM tests | ✅ | `core/` is **Android-free**: `StoragePath` (normalised absolute paths that cannot escape the root), `FileEntry` + `FileKind` classification, `MimeTypes`, `NaturalOrder` + `SortSpec` (directories first; `file2` before `file10`), `FileSize`, `NameMatcher` (substring + `*`/`?` glob), `NameAllocator` ("name (1)") and `TransferPlanner` (conflict policies, move-into-self). **44 JVM tests** across 10 classes, all green |
| M2 | Storage layer | ✅ | `StorageSource` with two backends — `AllFilesSource` (`java.io.File` under all-files access) and `SafSource` (`DocumentsContract` over a persisted tree URI), the latter split so the tree↔path mapping (`SafDocumentIds`) is exhaustively testable; `SafGrant` for the picker and its persistence; `VolumeRepository` (`StorageManager`/`StorageVolume` + `StatFs`); `StorageAccess` + `StorageAccessPolicy` for the all-files/SAF decision. **76 JVM tests** total (32 added here) |
| M3 | Operations engine + service | ✅ | `domain/FileOperations` composes the backend primitives into copy/move/delete/rename with recursive walking, an honest progress total, cooperative cancellation between entries and per-item failure reporting; conflicts are decided through the M1 `TransferPlanner` (`ASK` is surfaced for the UI to resolve). `service/FileOpService` is a `dataSync` foreground service mirroring progress into a notification (`FileOpNotifications` + `text/TextRes`). **99 JVM tests** total (23 added here) |
-| M4 | Compose UI | ⏳ | browser list/grid, breadcrumb + up navigation, sort/filter sheet, multi-select action bar, details sheet, permission + all-files-access prompts, conflict-resolution prompt feeding `FileOperations`, **delete via the system trash prompt** (`MediaStore.createTrashRequest`/`createDeleteRequest`, which needs an Activity to launch the returned `PendingIntent`), theme |
+| M4 | Compose UI | ✅ | `BrowserViewModel` + `BrowserUiState` over `StorageLocation`; browser screen with list/grid, breadcrumbs, in-folder search, a sort/hidden-files/view sheet, multi-select action bar, details sheet, and new-folder/rename/delete/conflict dialogs; a roots view and the "All files access" + SAF-folder prompts; `data/MediaStoreDeleter` routes a delete through the platform's own dialog where it can and falls back to a direct delete; a `FileProvider` backs "open with" and share. **115 JVM tests** total (16 added here) |
| M5 | Analysis, archives, viewers | ⏳ | disk-usage analysis, large-file finder, ZIP/TAR/GZ via Commons Compress, image viewer, text editor |
| M6 | Release config & Play readiness | ⏳ | R8/minify, optional signing, `.aab`; [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) (incl. the **Permissions Declaration Form**), [PRIVACY.md](../PRIVACY.md), README |
| M7 | On-device validation & hardening | ⏳ | AVD + real-device checks, per-operation error boundaries, edge-case fixtures |
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 527f4bf..9017282 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -54,6 +54,7 @@ commons-compress = { group = "org.apache.commons", name = "commons-compress", ve
junit = { group = "junit", name = "junit", version.ref = "junit" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }