M4: browser UI (navigation, sort, selection, dialogs, permission prompts, platform delete)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
+57
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user