From 2c86f3dc67132a882e89458874ac399f8ce65935 Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Wed, 14 Oct 2026 16:07:08 +0200 Subject: [PATCH] M4: browser UI (navigation, sort, selection, dialogs, permission prompts, platform delete) --- CHANGELOG.md | 11 + README.md | 10 +- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 13 + .../filemanager/FileManagerApplication.kt | 36 +- .../filemanager/core/Timestamps.kt | 20 + .../filemanager/data/MediaStoreDeleter.kt | 73 +++ .../filemanager/data/StorageLocation.kt | 87 ++++ .../filemanager/domain/OperationNotifier.kt | 25 + .../service/ServiceOperationNotifier.kt | 19 + .../ui/browser/BrowserComponents.kt | 154 ++++++ .../filemanager/ui/browser/BrowserContent.kt | 151 ++++++ .../filemanager/ui/browser/BrowserDialogs.kt | 128 +++++ .../filemanager/ui/browser/BrowserRoots.kt | 119 +++++ .../filemanager/ui/browser/BrowserScreen.kt | 290 ++++++++++-- .../filemanager/ui/browser/BrowserTopBar.kt | 115 +++++ .../filemanager/ui/browser/BrowserUiState.kt | 89 ++++ .../ui/browser/BrowserViewModel.kt | 444 ++++++++++++++++++ .../filemanager/ui/browser/DetailsSheet.kt | 65 +++ .../filemanager/ui/browser/EntryIntents.kt | 35 ++ .../filemanager/ui/browser/SelectionBar.kt | 116 +++++ .../filemanager/ui/browser/SortSheet.kt | 128 +++++ app/src/main/res/values/strings.xml | 112 ++++- app/src/main/res/xml/file_paths.xml | 12 + .../ui/browser/BrowserViewModelTest.kt | 302 ++++++++++++ .../browser/testutil/FakeStorageLocation.kt | 57 +++ docs/ROADMAP.md | 2 +- gradle/libs.versions.toml | 1 + 28 files changed, 2564 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/core/Timestamps.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/MediaStoreDeleter.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/data/StorageLocation.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/domain/OperationNotifier.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/service/ServiceOperationNotifier.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserRoots.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/EntryIntents.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt create mode 100644 app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SortSheet.kt create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt create mode 100644 app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/testutil/FakeStorageLocation.kt 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" }