M4: browser UI (navigation, sort, selection, dialogs, permission prompts, platform delete)

This commit is contained in:
2026-10-14 16:07:08 +02:00
parent 993cac6e5b
commit 2c86f3dc67
28 changed files with 2564 additions and 52 deletions
+11
View File
@@ -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
+5 -5
View File
@@ -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
+2
View File
@@ -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)
}
+13
View File
@@ -79,6 +79,19 @@
android:name=".service.FileOpService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!--
Wraps a real file in a content:// URI so another app can open or receive it
without the app ever exposing a file:// URI.
-->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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()
}
}
@@ -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)
}
@@ -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<StoragePath>, 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()
}
@@ -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<VolumeInfo>
/** 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<VolumeInfo> = 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<String> = access.missingRuntimePermissions()
}
@@ -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
}
}
@@ -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)
}
@@ -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)
@@ -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) },
)
}
}
}
}
}
@@ -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)) }
}
},
)
}
@@ -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<StoragePath>,
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)
}
}
}
}
@@ -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,
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<FileEntry?>(null) }
var showNewFolder by remember { mutableStateOf(false) }
var renameTarget by remember { mutableStateOf<FileEntry?>(null) }
var confirmDelete by remember { mutableStateOf<List<StoragePath>?>(null) }
var searchOpen by remember { mutableStateOf(false) }
var menuOpen by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) {
Text(
text = stringResource(R.string.empty_state_title),
style = MaterialTheme.typography.titleLarge,
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,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.empty_state_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
},
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,
)
}
}
@@ -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() },
)
}
}
}
},
)
}
@@ -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<StoragePath>, 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<Any> = emptyList(),
/** Set when [textRes] names a `<plurals>` 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<StoragePath>,
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<RootItem> = emptyList(),
/** `null` means "showing the root list" rather than a directory. */
val path: StoragePath? = null,
val entries: List<FileEntry> = emptyList(),
val breadcrumbs: List<StoragePath> = emptyList(),
val query: String = "",
val sort: SortSpec = SortSpec(),
val showHidden: Boolean = false,
val viewMode: ViewMode = ViewMode.LIST,
val selection: Set<StoragePath> = 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<FileEntry> 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
}
@@ -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<BrowserUiState> = _state.asStateFlow()
/** The unfiltered listing of the current directory; [recompute] derives what is shown. */
private var listing: List<FileEntry> = 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<StoragePath> {
val volumeRoots = storage.volumes().mapTo(HashSet()) { it.root }
val crumbs = ArrayList<StoragePath>()
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<RootItem> {
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<StoragePath>, 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<StoragePath>,
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<StoragePath>) {
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<StoragePath>) {
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,
)
}
}
}
}
@@ -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)
}
}
@@ -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)
}
@@ -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))
}
}
}
@@ -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
},
)
}
}
},
)
}
}
}
+108 -4
View File
@@ -10,10 +10,6 @@
-->
<string name="app_name">File Manager</string>
<!-- Browser empty state (M0 placeholder for the real file list). -->
<string name="empty_state_title">Your files, without the clutter</string>
<string name="empty_state_body">Ad-free, account-free and completely offline. Browsing is wired up next.</string>
<!-- Progress notification for long file operations -->
<string name="operation_copy_title">Copying files</string>
<string name="operation_move_title">Moving files</string>
@@ -23,4 +19,112 @@
<!-- Both numbers are counts of entries, so this stays a plain substitution. -->
<string name="notification_progress">%1$d / %2$d</string>
<string name="notification_preparing">Preparing…</string>
<!-- ===== Browser: storage access prompts ===== -->
<string name="browser_all_files_title">Full file access needed</string>
<string name="browser_all_files_body">To browse every folder, grant this app “All files access” in Settings. Without it you can still browse a folder you pick yourself.</string>
<string name="browser_all_files_action">Grant full access</string>
<string name="browser_tree_title">Choose a folder</string>
<string name="browser_tree_body">Full access was declined, so pick a folder for the app to work in. The grant is remembered across restarts.</string>
<string name="browser_tree_action">Choose folder</string>
<string name="browser_tree_granted">Folder added</string>
<string name="browser_tree_rejected">The folder could not be remembered</string>
<!-- ===== Browser: navigation ===== -->
<string name="browser_roots_title">Storage</string>
<string name="browser_up">Up</string>
<string name="browser_internal_storage">Internal storage</string>
<string name="browser_empty_folder">This folder is empty</string>
<string name="browser_no_matches">Nothing matches “%1$s”</string>
<string name="browser_free_space">%1$s free</string>
<plurals name="browser_item_count">
<item quantity="one">%1$d item</item>
<item quantity="other">%1$d items</item>
</plurals>
<string name="browser_search_hint">Search this folder</string>
<string name="browser_search_clear">Clear search</string>
<!-- ===== Browser: actions ===== -->
<string name="browser_action_new_folder">New folder</string>
<string name="browser_action_sort">Sort</string>
<string name="browser_action_details">Details</string>
<string name="browser_action_copy">Copy</string>
<string name="browser_action_cut">Move</string>
<string name="browser_action_paste">Paste here</string>
<string name="browser_action_rename">Rename</string>
<string name="browser_action_delete">Delete</string>
<string name="browser_action_share">Share</string>
<string name="browser_action_select_all">Select all</string>
<string name="browser_action_clear_selection">Clear selection</string>
<string name="browser_action_more">More options</string>
<string name="browser_action_refresh">Refresh</string>
<string name="browser_action_cancel">Cancel</string>
<string name="browser_action_create">Create</string>
<string name="browser_action_rename_confirm">Rename</string>
<string name="browser_action_delete_confirm">Delete</string>
<string name="browser_action_show_hidden">Show hidden files</string>
<string name="browser_view_mode">View</string>
<string name="browser_action_view_list">List view</string>
<string name="browser_action_view_grid">Grid view</string>
<!-- ===== Browser: sort ===== -->
<string name="browser_sort_by">Sort by</string>
<string name="browser_sort_name">Name</string>
<string name="browser_sort_size">Size</string>
<string name="browser_sort_date">Date</string>
<string name="browser_sort_kind">Type</string>
<string name="browser_sort_ascending">Ascending</string>
<string name="browser_sort_descending">Descending</string>
<!-- ===== Browser: new folder / rename ===== -->
<string name="browser_new_folder_title">New folder</string>
<string name="browser_new_folder_default">New folder</string>
<string name="browser_rename_title">Rename</string>
<string name="browser_name_label">Name</string>
<!-- ===== Browser: details ===== -->
<string name="browser_details_title">Details</string>
<string name="browser_details_name">Name</string>
<string name="browser_details_location">Location</string>
<string name="browser_details_size">Size</string>
<string name="browser_details_kind">Type</string>
<string name="browser_details_modified">Modified</string>
<string name="browser_details_folder">Folder</string>
<!-- ===== Browser: delete + conflict ===== -->
<string name="browser_delete_title">Delete permanently?</string>
<plurals name="browser_delete_body">
<item quantity="one">%1$d item will be permanently deleted. This cannot be undone.</item>
<item quantity="other">%1$d items will be permanently deleted. This cannot be undone.</item>
</plurals>
<string name="conflict_title">Items already exist here</string>
<plurals name="conflict_body">
<item quantity="one">%1$d item already has that name. What should happen?</item>
<item quantity="other">%1$d items already have that name. What should happen?</item>
</plurals>
<string name="conflict_replace">Replace</string>
<string name="conflict_keep_both">Keep both</string>
<string name="conflict_skip">Skip</string>
<!-- ===== Browser: messages ===== -->
<string name="message_folder_created">Folder created</string>
<string name="message_renamed">Renamed</string>
<string name="message_name_in_use">That name is already in use</string>
<plurals name="message_copied">
<item quantity="one">Copied %1$d item</item>
<item quantity="other">Copied %1$d items</item>
</plurals>
<plurals name="message_moved">
<item quantity="one">Moved %1$d item</item>
<item quantity="other">Moved %1$d items</item>
</plurals>
<plurals name="message_deleted">
<item quantity="one">Deleted %1$d item</item>
<item quantity="other">Deleted %1$d items</item>
</plurals>
<plurals name="message_failed">
<item quantity="one">%1$d item could not be processed</item>
<item quantity="other">%1$d items could not be processed</item>
</plurals>
<string name="message_cancelled">Cancelled</string>
</resources>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Backing file for the FileProvider used by "open with" and "share".
A `root-path` entry is deliberate: the app already holds "All files access", so the
provider can hand any file it can see to another app through a `content://` URI instead
of the `file://` URI that Android 7 onwards refuses to expose (`FileUriExposedException`).
-->
<paths>
<root-path name="root" path="/" />
<external-path name="external" path="." />
</paths>
@@ -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<OperationKind>()
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 })
}
}
@@ -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<VolumeInfo> = 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<VolumeInfo>) {
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<VolumeInfo> = volumes
override fun activeSource(): StorageSource? = source
override fun treeLabel(): String? = treeLabel
override fun space(path: StoragePath): SpaceInfo? = space
}
+1 -1
View File
@@ -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 |
+1
View File
@@ -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" }