M3: operations engine (copy/move/delete, progress, cancellation, conflicts) + foreground service
This commit is contained in:
@@ -12,6 +12,20 @@ upload needs a **higher `versionCode`** than every previous upload.
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
between entries (never swallowing `CancellationException`), and reports per-item
|
||||
failures instead of abandoning a batch because one file was unreadable. Conflicts are
|
||||
resolved through the M1 `TransferPlanner` — `ASK` is deliberately surfaced to the
|
||||
caller so the UI can prompt. A move tries the backend's atomic rename first and only
|
||||
falls back to copy-then-delete when the backend cannot do it (the SAF path across
|
||||
directories); the source is never deleted when its copy failed. `service/FileOpService`
|
||||
is a `dataSync` foreground service that mirrors the engine's progress into an ongoing
|
||||
notification, so a long operation survives the app being backgrounded. 23 new JVM
|
||||
tests (99 total), including Robolectric coverage of the notification and the service
|
||||
entering the foreground.
|
||||
|
||||
- **M2 — storage layer.** The two storage backends behind one interface. `AllFilesSource`
|
||||
is `java.io.File` over every mounted volume (the "All files access" path);
|
||||
`SafSource` is the Storage Access Framework fallback, reading a persisted tree URI
|
||||
|
||||
@@ -12,10 +12,10 @@ no advertising or tracking SDKs.
|
||||
|
||||
## Status
|
||||
|
||||
**M2 — storage layer: done.** Two backends behind one interface — `AllFilesSource`
|
||||
(`java.io.File` under all-files access) and the `SafSource` fallback — plus volume/space
|
||||
discovery and the access-mode state machine. Covered by 76 JVM unit tests (32 added
|
||||
here), including a Robolectric run of the SAF backend. See
|
||||
**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.
|
||||
|
||||
## Tech stack
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
android:maxSdkVersion="29"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<!--
|
||||
Long file operations keep running while the app is backgrounded. `dataSync` is the
|
||||
foreground-service type that matches "user-initiated data processing".
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- Android 13+: shows the operation progress notification (optional, never required). -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".FileManagerApplication"
|
||||
android:allowBackup="true"
|
||||
@@ -61,6 +70,15 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
No copy logic lives here: it mirrors the engine's progress into an ongoing
|
||||
notification and keeps the process alive during a long operation.
|
||||
-->
|
||||
<service
|
||||
android:name=".service.FileOpService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
package de.softwareapp_hb.filemanager.domain
|
||||
|
||||
import de.softwareapp_hb.filemanager.core.ConflictPolicy
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import de.softwareapp_hb.filemanager.core.TransferAction
|
||||
import de.softwareapp_hb.filemanager.core.TransferItem
|
||||
import de.softwareapp_hb.filemanager.core.TransferPlanner
|
||||
import de.softwareapp_hb.filemanager.core.TransferSource
|
||||
import de.softwareapp_hb.filemanager.data.StorageSource
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Copy, move and delete, assembled from the [StorageSource] primitives.
|
||||
*
|
||||
* The engine owns everything that is easy to get wrong: walking a tree, keeping the
|
||||
* progress counter honest, honouring cancellation between entries, and reporting
|
||||
* per-item failures instead of aborting the whole batch because one file was
|
||||
* unreadable.
|
||||
*
|
||||
* Two deliberate behaviours:
|
||||
*
|
||||
* - **Cancellation is cooperative and checked between entries.** A single very large
|
||||
* file cannot be interrupted mid-stream; the operation stops before the *next* entry
|
||||
* and reports [OperationResult.cancelled]. It never swallows a
|
||||
* [CancellationException], so a cancelled scope still unwinds normally.
|
||||
* - **[ConflictPolicy.ASK] is not answered here.** Planning records which items clash;
|
||||
* deciding is the UI's job (see [planTransfer]). The engine skips `ASK` items, so a
|
||||
* caller that wants a prompt resolves the conflicts first and calls again with a
|
||||
* concrete policy.
|
||||
*
|
||||
* @param source the backend the items are read from.
|
||||
* @param destination where they go; the same backend unless copying across them.
|
||||
*/
|
||||
class FileOperations(
|
||||
private val source: StorageSource,
|
||||
private val destination: StorageSource = source,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Work out what would happen, without touching anything.
|
||||
*
|
||||
* With [ConflictPolicy.ASK] every item that would clash comes back as
|
||||
* [TransferAction.ASK], which is how the caller finds out a prompt is needed.
|
||||
*/
|
||||
suspend fun planTransfer(
|
||||
paths: List<StoragePath>,
|
||||
target: StoragePath,
|
||||
policy: ConflictPolicy,
|
||||
): List<TransferItem> {
|
||||
val entries = paths.map { path ->
|
||||
TransferSource(path, isDirectory = source.stat(path)?.isDirectory ?: false)
|
||||
}
|
||||
val alreadyThere = destination.list(target).mapTo(HashSet()) { it.name }
|
||||
return TransferPlanner.plan(entries, target, policy) { it.name in alreadyThere }
|
||||
}
|
||||
|
||||
suspend fun createDirectory(path: StoragePath): Boolean = source.createDirectory(path)
|
||||
|
||||
/**
|
||||
* Rename in place, falling back to a copy-and-delete when the backend cannot do it
|
||||
* cheaply (a cross-directory or cross-volume move).
|
||||
*/
|
||||
suspend fun rename(from: StoragePath, to: StoragePath): Boolean {
|
||||
if (source.rename(from, to)) return true
|
||||
if (destination.exists(to)) return false
|
||||
|
||||
val state = OperationState(OperationKind.MOVE, 0) {}
|
||||
transfer(from, to, move = true, state = state)
|
||||
return state.hasFailures.not()
|
||||
}
|
||||
|
||||
suspend fun copy(
|
||||
paths: List<StoragePath>,
|
||||
target: StoragePath,
|
||||
policy: ConflictPolicy = ConflictPolicy.KEEP_BOTH,
|
||||
onProgress: (OperationProgress) -> Unit = {},
|
||||
): OperationResult = transferAll(OperationKind.COPY, paths, target, policy, move = false, onProgress = onProgress)
|
||||
|
||||
suspend fun move(
|
||||
paths: List<StoragePath>,
|
||||
target: StoragePath,
|
||||
policy: ConflictPolicy = ConflictPolicy.KEEP_BOTH,
|
||||
onProgress: (OperationProgress) -> Unit = {},
|
||||
): OperationResult = transferAll(OperationKind.MOVE, paths, target, policy, move = true, onProgress = onProgress)
|
||||
|
||||
/**
|
||||
* Delete each of [paths]. The backends remove a folder tree in one call, so progress
|
||||
* here counts one unit per top-level item rather than per file.
|
||||
*/
|
||||
suspend fun delete(
|
||||
paths: List<StoragePath>,
|
||||
onProgress: (OperationProgress) -> Unit = {},
|
||||
): OperationResult {
|
||||
val state = OperationState(OperationKind.DELETE, paths.size, onProgress)
|
||||
for (path in paths) {
|
||||
if (!currentCoroutineContext().isActive) {
|
||||
state.cancel()
|
||||
break
|
||||
}
|
||||
state.starting(path)
|
||||
if (source.delete(path)) state.advance(path) else state.failure(path, "Could not delete")
|
||||
}
|
||||
if (!currentCoroutineContext().isActive) state.cancel()
|
||||
return state.result()
|
||||
}
|
||||
|
||||
private suspend fun transferAll(
|
||||
kind: OperationKind,
|
||||
paths: List<StoragePath>,
|
||||
target: StoragePath,
|
||||
policy: ConflictPolicy,
|
||||
move: Boolean,
|
||||
onProgress: (OperationProgress) -> Unit,
|
||||
): OperationResult {
|
||||
if (!destination.exists(target)) destination.createDirectory(target)
|
||||
|
||||
val items = planTransfer(paths, target, policy)
|
||||
|
||||
var total = 0
|
||||
for (item in items) {
|
||||
if (item.action == TransferAction.SKIP || item.action == TransferAction.ASK) continue
|
||||
total += countNodes(item.source.path)
|
||||
}
|
||||
|
||||
val state = OperationState(kind, total, onProgress)
|
||||
for (item in items) {
|
||||
if (!currentCoroutineContext().isActive) {
|
||||
state.cancel()
|
||||
break
|
||||
}
|
||||
when (item.action) {
|
||||
TransferAction.SKIP, TransferAction.ASK -> Unit
|
||||
|
||||
TransferAction.REPLACE -> {
|
||||
destination.delete(item.target)
|
||||
transfer(item.source.path, item.target, move, state)
|
||||
}
|
||||
|
||||
TransferAction.COPY, TransferAction.KEEP_BOTH ->
|
||||
transfer(item.source.path, item.target, move, state)
|
||||
}
|
||||
}
|
||||
|
||||
// The loop only notices cancellation before an item; one cancelled *by the last*
|
||||
// item would otherwise be reported as a success.
|
||||
if (!currentCoroutineContext().isActive) state.cancel()
|
||||
return state.result()
|
||||
}
|
||||
|
||||
/** Copy or move one entry, recursing into folders. */
|
||||
private suspend fun transfer(
|
||||
from: StoragePath,
|
||||
to: StoragePath,
|
||||
move: Boolean,
|
||||
state: OperationState,
|
||||
) {
|
||||
if (!currentCoroutineContext().isActive) return
|
||||
|
||||
val entry = source.stat(from)
|
||||
if (entry == null) {
|
||||
state.failure(from, "The item no longer exists")
|
||||
return
|
||||
}
|
||||
|
||||
// A rename is atomic and costs no data copy when both ends share a backend, so it
|
||||
// is always worth trying first and only falling back to a byte copy when it fails.
|
||||
if (move && source === destination && source.rename(from, to)) {
|
||||
state.advance(from, countNodes(to))
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
if (!destination.exists(to)) destination.createDirectory(to)
|
||||
for (child in source.list(from)) {
|
||||
if (!currentCoroutineContext().isActive) return
|
||||
transfer(child.path, to.child(child.name), move, state)
|
||||
}
|
||||
if (move && !state.hasFailed(from) && !source.delete(from)) {
|
||||
state.failure(from, "The source folder could not be removed")
|
||||
}
|
||||
} else {
|
||||
copyFile(from, to, state)
|
||||
// Never delete the source of a copy that failed — that would lose the file.
|
||||
if (move && !state.hasFailed(from) && !source.delete(from)) {
|
||||
state.failure(from, "The source file could not be removed")
|
||||
}
|
||||
}
|
||||
|
||||
state.advance(from)
|
||||
}
|
||||
|
||||
private suspend fun copyFile(from: StoragePath, to: StoragePath, state: OperationState) {
|
||||
state.starting(from)
|
||||
try {
|
||||
destination.createFile(to)
|
||||
source.openRead(from).use { input ->
|
||||
destination.openWrite(to).use { output ->
|
||||
input.copyTo(output, COPY_BUFFER_BYTES)
|
||||
}
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
state.failure(from, failure.message ?: "The file could not be copied")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every node below [path], so the progress bar has an honest denominator before the
|
||||
* first byte moves. This is a metadata-only walk, far cheaper than the copy itself.
|
||||
*/
|
||||
private suspend fun countNodes(path: StoragePath): Int {
|
||||
val entry = source.stat(path) ?: return 0
|
||||
if (!entry.isDirectory) return 1
|
||||
|
||||
var count = 1
|
||||
for (child in source.list(path)) count += countNodes(child.path)
|
||||
return count
|
||||
}
|
||||
|
||||
/** Mutable progress bookkeeping, deliberately kept out of the public surface. */
|
||||
private class OperationState(
|
||||
private val kind: OperationKind,
|
||||
private val total: Int,
|
||||
private val onProgress: (OperationProgress) -> Unit,
|
||||
) {
|
||||
var hasFailures: Boolean = false
|
||||
private set
|
||||
|
||||
private var cancelled = false
|
||||
private var completed = 0
|
||||
private var current: StoragePath? = null
|
||||
private val failures = mutableListOf<OperationFailure>()
|
||||
|
||||
fun starting(path: StoragePath) {
|
||||
current = path
|
||||
emit()
|
||||
}
|
||||
|
||||
fun advance(path: StoragePath) = advance(path, 1)
|
||||
|
||||
fun advance(path: StoragePath, by: Int) {
|
||||
completed += by
|
||||
current = path
|
||||
emit()
|
||||
}
|
||||
|
||||
fun failure(path: StoragePath, reason: String) {
|
||||
failures += OperationFailure(path, reason)
|
||||
hasFailures = true
|
||||
}
|
||||
|
||||
fun hasFailed(path: StoragePath): Boolean =
|
||||
failures.any { it.path == path || it.path.isDescendantOf(path) }
|
||||
|
||||
fun cancel() {
|
||||
cancelled = true
|
||||
}
|
||||
|
||||
fun result(): OperationResult =
|
||||
OperationResult(kind, completed, failures.toList(), cancelled)
|
||||
|
||||
private fun emit() = onProgress(OperationProgress(kind, completed, total, current))
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 64 KiB keeps the per-call overhead negligible on both backends. */
|
||||
private const val COPY_BUFFER_BYTES = 64 * 1024
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.softwareapp_hb.filemanager.domain
|
||||
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
|
||||
/** The kind of long-running operation a progress notification can describe. */
|
||||
enum class OperationKind { COPY, MOVE, DELETE }
|
||||
|
||||
/**
|
||||
* A snapshot of a running operation.
|
||||
*
|
||||
* [total] counts every node the operation will touch (files *and* folders, since the
|
||||
* unit of work is "advance past this entry"). A total of zero — an empty selection —
|
||||
* is reported as [indeterminate] rather than as a division by zero.
|
||||
*/
|
||||
data class OperationProgress(
|
||||
val kind: OperationKind,
|
||||
val completed: Int,
|
||||
val total: Int,
|
||||
val current: StoragePath? = null,
|
||||
) {
|
||||
val indeterminate: Boolean get() = total <= 0
|
||||
|
||||
val fraction: Float get() = if (indeterminate) 0f else (completed.toFloat() / total).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/** One item that could not be processed, with a reason worth showing the user. */
|
||||
data class OperationFailure(val path: StoragePath, val reason: String)
|
||||
|
||||
/**
|
||||
* The outcome of an operation.
|
||||
*
|
||||
* A cancelled operation is not a failure: it reports what it managed to do, so a partial
|
||||
* copy can be described honestly instead of being rolled back silently.
|
||||
*/
|
||||
data class OperationResult(
|
||||
val kind: OperationKind,
|
||||
val completed: Int,
|
||||
val failures: List<OperationFailure>,
|
||||
val cancelled: Boolean,
|
||||
) {
|
||||
val succeeded: Boolean get() = failures.isEmpty() && !cancelled
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.softwareapp_hb.filemanager.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import de.softwareapp_hb.filemanager.R
|
||||
import de.softwareapp_hb.filemanager.domain.OperationProgress
|
||||
import de.softwareapp_hb.filemanager.text.titleRes
|
||||
|
||||
/**
|
||||
* Builds the progress notification for a running file operation.
|
||||
*
|
||||
* Kept apart from the service so the wording and the channel can be tested without
|
||||
* starting anything.
|
||||
*/
|
||||
object FileOpNotifications {
|
||||
|
||||
const val CHANNEL_ID = "file-operations"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
|
||||
/**
|
||||
* Create the channel once.
|
||||
*
|
||||
* No API guard is needed: the app's `minSdk` is 26, which is the release that
|
||||
* introduced notification channels, so they always exist.
|
||||
*/
|
||||
fun ensureChannel(context: Context) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.notification_channel_name),
|
||||
// Low importance: progress should never make a sound or vibrate.
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = context.getString(R.string.notification_channel_description)
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun build(context: Context, progress: OperationProgress): Notification {
|
||||
val detail = if (progress.indeterminate) {
|
||||
context.getString(R.string.notification_preparing)
|
||||
} else {
|
||||
context.getString(R.string.notification_progress, progress.completed, progress.total)
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(progress.kind.titleRes()))
|
||||
.setContentText(detail)
|
||||
.setProgress(progress.total.coerceAtLeast(0), progress.completed, progress.indeterminate)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.softwareapp_hb.filemanager.service
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.softwareapp_hb.filemanager.domain.OperationKind
|
||||
import de.softwareapp_hb.filemanager.domain.OperationProgress
|
||||
|
||||
/**
|
||||
* Keeps a long copy, move or delete alive while the app is backgrounded, and mirrors the
|
||||
* engine's progress into an ongoing notification.
|
||||
*
|
||||
* It holds no state of its own: the operation runs in the caller's coroutine and pushes
|
||||
* updates here through [update], so the notification can never disagree with the work.
|
||||
* `dataSync` is the foreground-service type that matches "user-initiated data
|
||||
* processing", which is exactly what this is.
|
||||
*/
|
||||
class FileOpService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> onStartOperation(intent)
|
||||
ACTION_UPDATE -> onUpdateProgress(intent)
|
||||
ACTION_STOP -> onFinish()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* A foreground service must post its notification within a few seconds of starting,
|
||||
* so this reports an indeterminate progress until the first [update] arrives.
|
||||
*/
|
||||
private fun onStartOperation(intent: Intent) {
|
||||
FileOpNotifications.ensureChannel(this)
|
||||
val kind = intent.operationKind() ?: OperationKind.COPY
|
||||
startForeground(
|
||||
FileOpNotifications.NOTIFICATION_ID,
|
||||
FileOpNotifications.build(this, OperationProgress(kind, completed = 0, total = 0)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onUpdateProgress(intent: Intent) {
|
||||
val kind = intent.operationKind() ?: return
|
||||
val progress = OperationProgress(
|
||||
kind = kind,
|
||||
completed = intent.getIntExtra(EXTRA_COMPLETED, 0),
|
||||
total = intent.getIntExtra(EXTRA_TOTAL, 0),
|
||||
)
|
||||
getSystemService(NotificationManager::class.java)
|
||||
?.notify(FileOpNotifications.NOTIFICATION_ID, FileOpNotifications.build(this, progress))
|
||||
}
|
||||
|
||||
private fun onFinish() {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun Intent.operationKind(): OperationKind? =
|
||||
getStringExtra(EXTRA_KIND)?.let { name -> runCatching { OperationKind.valueOf(name) }.getOrNull() }
|
||||
|
||||
companion object {
|
||||
// Internal rather than private so the service can be driven directly from a test.
|
||||
internal const val ACTION_START = "de.softwareapp_hb.filemanager.action.OPERATION_START"
|
||||
internal const val ACTION_UPDATE = "de.softwareapp_hb.filemanager.action.OPERATION_UPDATE"
|
||||
internal const val ACTION_STOP = "de.softwareapp_hb.filemanager.action.OPERATION_STOP"
|
||||
|
||||
private const val EXTRA_KIND = "kind"
|
||||
private const val EXTRA_COMPLETED = "completed"
|
||||
private const val EXTRA_TOTAL = "total"
|
||||
|
||||
/** Bring the service up in the foreground before the first item is touched. */
|
||||
fun start(context: Context, kind: OperationKind) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
newIntent(context, ACTION_START).putExtra(EXTRA_KIND, kind.name),
|
||||
)
|
||||
}
|
||||
|
||||
/** The intent [start] sends, exposed so a test can drive the service directly. */
|
||||
internal fun startIntent(context: Context, kind: OperationKind): Intent =
|
||||
newIntent(context, ACTION_START).putExtra(EXTRA_KIND, kind.name)
|
||||
|
||||
/** Push a progress snapshot into the notification. */
|
||||
fun update(context: Context, progress: OperationProgress) {
|
||||
context.startService(
|
||||
newIntent(context, ACTION_UPDATE).apply {
|
||||
putExtra(EXTRA_KIND, progress.kind.name)
|
||||
putExtra(EXTRA_COMPLETED, progress.completed)
|
||||
putExtra(EXTRA_TOTAL, progress.total)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Tear the notification down once the operation has finished (or been cancelled). */
|
||||
fun stop(context: Context) {
|
||||
context.startService(newIntent(context, ACTION_STOP))
|
||||
}
|
||||
|
||||
private fun newIntent(context: Context, action: String) =
|
||||
Intent(context, FileOpService::class.java).setAction(action)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.softwareapp_hb.filemanager.text
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import de.softwareapp_hb.filemanager.R
|
||||
import de.softwareapp_hb.filemanager.domain.OperationKind
|
||||
|
||||
/**
|
||||
* Maps domain enums onto their resource ids.
|
||||
*
|
||||
* It lives outside both `ui` and `service` because the browser screen and the progress
|
||||
* notification must show the *same* wording — duplicating the mapping is how the two
|
||||
* drift apart.
|
||||
*/
|
||||
@StringRes
|
||||
fun OperationKind.titleRes(): Int = when (this) {
|
||||
OperationKind.COPY -> R.string.operation_copy_title
|
||||
OperationKind.MOVE -> R.string.operation_move_title
|
||||
OperationKind.DELETE -> R.string.operation_delete_title
|
||||
}
|
||||
@@ -13,4 +13,14 @@
|
||||
<!-- 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>
|
||||
<string name="operation_delete_title">Deleting files</string>
|
||||
<string name="notification_channel_name">File operations</string>
|
||||
<string name="notification_channel_description">Shows progress while files are being copied, moved or deleted</string>
|
||||
<!-- 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>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package de.softwareapp_hb.filemanager.domain
|
||||
|
||||
import de.softwareapp_hb.filemanager.core.ConflictPolicy
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import de.softwareapp_hb.filemanager.core.TransferAction
|
||||
import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FileOperationsTest {
|
||||
|
||||
private val src = StoragePath.of("/src")
|
||||
private val dst = StoragePath.of("/dst")
|
||||
|
||||
private fun storage() = InMemoryStorageSource().apply {
|
||||
addDirectory(src)
|
||||
addDirectory(dst)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copy brings a file across with its content`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.addFile(src.child("a.txt"), "hello")
|
||||
|
||||
val result = FileOperations(storage).copy(listOf(src.child("a.txt")), dst)
|
||||
|
||||
assertTrue(result.succeeded)
|
||||
assertEquals("hello", storage.contentOf(dst.child("a.txt")))
|
||||
assertEquals("hello", storage.contentOf(src.child("a.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copy recurses into folders`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.addDirectory(src.child("nested"))
|
||||
storage.addFile(src.child("nested/deep.txt"), "deep")
|
||||
|
||||
val result = FileOperations(storage).copy(listOf(src.child("nested")), dst)
|
||||
|
||||
assertTrue(result.succeeded)
|
||||
assertEquals("deep", storage.contentOf(dst.child("nested/deep.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copy creates the target folder when it does not exist`() = runBlocking {
|
||||
val storage = InMemoryStorageSource().apply {
|
||||
addDirectory(src)
|
||||
addFile(src.child("a.txt"), "x")
|
||||
}
|
||||
val fresh = StoragePath.of("/fresh")
|
||||
|
||||
val result = FileOperations(storage).copy(listOf(src.child("a.txt")), fresh)
|
||||
|
||||
assertTrue(result.succeeded)
|
||||
assertEquals("x", storage.contentOf(fresh.child("a.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copy reports progress that reaches the total`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.addFile(src.child("a.txt"), "a")
|
||||
storage.addFile(src.child("b.txt"), "b")
|
||||
|
||||
val seen = mutableListOf<OperationProgress>()
|
||||
FileOperations(storage).copy(listOf(src.child("a.txt"), src.child("b.txt")), dst) { seen += it }
|
||||
|
||||
assertTrue(seen.isNotEmpty())
|
||||
val last = seen.last()
|
||||
assertEquals(OperationKind.COPY, last.kind)
|
||||
assertEquals(2, last.total)
|
||||
assertEquals(2, last.completed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keep both renames the incoming copy`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.addFile(src.child("a.txt"), "new")
|
||||
storage.addFile(dst.child("a.txt"), "old")
|
||||
|
||||
val result = FileOperations(storage).copy(listOf(src.child("a.txt")), dst, ConflictPolicy.KEEP_BOTH)
|
||||
|
||||
assertTrue(result.succeeded)
|
||||
assertEquals("old", storage.contentOf(dst.child("a.txt")))
|
||||
assertEquals("new", storage.contentOf(dst.child("a (1).txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skip leaves the existing file untouched`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.addFile(src.child("a.txt"), "new")
|
||||
storage.addFile(dst.child("a.txt"), "old")
|
||||
|
||||
val result = FileOperations(storage).copy(listOf(src.child("a.txt")), dst, ConflictPolicy.SKIP)
|
||||
|
||||
assertTrue(result.succeeded)
|
||||
assertEquals("old", storage.contentOf(dst.child("a.txt")))
|
||||
assertFalse(storage.contains(dst.child("a (1).txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replace overwrites the existing file`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.addFile(src.child("a.txt"), "new")
|
||||
storage.addFile(dst.child("a.txt"), "old")
|
||||
|
||||
val result = FileOperations(storage).copy(listOf(src.child("a.txt")), dst, ConflictPolicy.REPLACE)
|
||||
|
||||
assertTrue(result.succeeded)
|
||||
assertEquals("new", storage.contentOf(dst.child("a.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ask is surfaced to the caller and skipped by the engine`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.addFile(src.child("a.txt"), "new")
|
||||
storage.addFile(dst.child("a.txt"), "old")
|
||||
val operations = FileOperations(storage)
|
||||
|
||||
val plan = operations.planTransfer(listOf(src.child("a.txt")), dst, ConflictPolicy.ASK)
|
||||
assertEquals(TransferAction.ASK, plan.single().action)
|
||||
|
||||
operations.copy(listOf(src.child("a.txt")), dst, ConflictPolicy.ASK)
|
||||
assertEquals("old", storage.contentOf(dst.child("a.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `move renames within the backend`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.addFile(src.child("a.txt"), "data")
|
||||
|
||||
val result = FileOperations(storage).move(listOf(src.child("a.txt")), dst)
|
||||
|
||||
assertTrue(result.succeeded)
|
||||
assertEquals("data", storage.contentOf(dst.child("a.txt")))
|
||||
assertFalse(storage.contains(src.child("a.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `move falls back to copy and delete when rename is unavailable`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.renameSupported = false
|
||||
storage.addDirectory(src.child("nested"))
|
||||
storage.addFile(src.child("nested/a.txt"), "data")
|
||||
|
||||
val result = FileOperations(storage).move(listOf(src.child("nested")), dst)
|
||||
|
||||
assertTrue(result.succeeded)
|
||||
assertEquals("data", storage.contentOf(dst.child("nested/a.txt")))
|
||||
assertFalse(storage.contains(src.child("nested")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `move keeps the source when the copy failed`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.renameSupported = false
|
||||
storage.addFile(src.child("bad.txt"), "x")
|
||||
storage.unreadable += src.child("bad.txt")
|
||||
|
||||
val result = FileOperations(storage).move(listOf(src.child("bad.txt")), dst)
|
||||
|
||||
assertFalse(result.succeeded)
|
||||
assertTrue(storage.contains(src.child("bad.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failing file is reported and the batch continues`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.addFile(src.child("bad.txt"), "x")
|
||||
storage.addFile(src.child("good.txt"), "y")
|
||||
storage.unreadable += src.child("bad.txt")
|
||||
|
||||
val result = FileOperations(storage).copy(listOf(src.child("bad.txt"), src.child("good.txt")), dst)
|
||||
|
||||
assertEquals(1, result.failures.size)
|
||||
assertEquals(src.child("bad.txt"), result.failures.single().path)
|
||||
assertEquals("y", storage.contentOf(dst.child("good.txt")))
|
||||
assertFalse(result.succeeded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an item that vanished is reported`() = runBlocking {
|
||||
val storage = storage()
|
||||
val result = FileOperations(storage).copy(listOf(src.child("ghost.txt")), dst)
|
||||
assertEquals(1, result.failures.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancellation stops between entries and reports partial work`() = runBlocking {
|
||||
val storage = storage()
|
||||
repeat(4) { storage.addFile(src.child("f$it.txt"), "data$it") }
|
||||
|
||||
var outcome: OperationResult? = null
|
||||
lateinit var job: Job
|
||||
job = launch {
|
||||
outcome = FileOperations(storage).copy(
|
||||
paths = listOf(src.child("f0.txt"), src.child("f1.txt"), src.child("f2.txt"), src.child("f3.txt")),
|
||||
target = dst,
|
||||
) { progress ->
|
||||
if (progress.completed >= 1) job.cancel()
|
||||
}
|
||||
}
|
||||
job.join()
|
||||
|
||||
val finished = requireNotNull(outcome)
|
||||
assertTrue(finished.cancelled)
|
||||
assertEquals(1, finished.completed)
|
||||
assertEquals(0, finished.failures.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete removes files and folders`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.addDirectory(src.child("nested"))
|
||||
storage.addFile(src.child("nested/a.txt"), "a")
|
||||
storage.addFile(src.child("b.txt"), "b")
|
||||
|
||||
val result = FileOperations(storage).delete(listOf(src.child("nested"), src.child("b.txt")))
|
||||
|
||||
assertTrue(result.succeeded)
|
||||
assertFalse(storage.contains(src.child("nested")))
|
||||
assertFalse(storage.contains(src.child("b.txt")))
|
||||
assertEquals(2, result.completed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete reports an item that is already gone`() = runBlocking {
|
||||
val storage = storage()
|
||||
val result = FileOperations(storage).delete(listOf(src.child("ghost.txt")))
|
||||
assertFalse(result.succeeded)
|
||||
assertEquals(1, result.failures.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename falls back to a copy and delete`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.renameSupported = false
|
||||
storage.addFile(src.child("a.txt"), "data")
|
||||
|
||||
val moved = FileOperations(storage).rename(src.child("a.txt"), dst.child("renamed.txt"))
|
||||
|
||||
assertTrue(moved)
|
||||
assertEquals("data", storage.contentOf(dst.child("renamed.txt")))
|
||||
assertFalse(storage.contains(src.child("a.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename refuses to clobber an existing target`() = runBlocking {
|
||||
val storage = storage()
|
||||
storage.renameSupported = false
|
||||
storage.addFile(src.child("a.txt"), "new")
|
||||
storage.addFile(dst.child("a.txt"), "old")
|
||||
|
||||
assertFalse(FileOperations(storage).rename(src.child("a.txt"), dst.child("a.txt")))
|
||||
assertEquals("old", storage.contentOf(dst.child("a.txt")))
|
||||
assertEquals("new", storage.contentOf(src.child("a.txt")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createDirectory makes the folder`() = runBlocking {
|
||||
val storage = storage()
|
||||
assertTrue(FileOperations(storage).createDirectory(src.child("new")))
|
||||
assertTrue(storage.contains(src.child("new")))
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package de.softwareapp_hb.filemanager.domain.testutil
|
||||
|
||||
import de.softwareapp_hb.filemanager.core.FileEntry
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import de.softwareapp_hb.filemanager.data.StorageException
|
||||
import de.softwareapp_hb.filemanager.data.StorageSource
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* A complete [StorageSource] backed by maps, for exercising the operations engine
|
||||
* without a filesystem.
|
||||
*
|
||||
* It implements the same contract as the real backends — including recursive delete and
|
||||
* subtree rename — and adds two switches ([renameSupported], [unreadable]) for the
|
||||
* behaviours the engine has to survive: a backend that cannot rename (the SAF fallback
|
||||
* across directories) and a file that cannot be read.
|
||||
*/
|
||||
class InMemoryStorageSource(
|
||||
override val roots: List<FileEntry> = listOf(
|
||||
FileEntry(StoragePath.Root, isDirectory = true, sizeBytes = 0L, lastModifiedMillis = 0L),
|
||||
),
|
||||
) : StorageSource {
|
||||
|
||||
override val backendName: String = "memory"
|
||||
|
||||
private val entries = LinkedHashMap<StoragePath, FileEntry>()
|
||||
private val contents = LinkedHashMap<StoragePath, ByteArray>()
|
||||
|
||||
/** When `false`, [rename] refuses, the way the SAF backend does across directories. */
|
||||
var renameSupported: Boolean = true
|
||||
|
||||
/** Paths whose [openRead] throws, to exercise the per-item failure path. */
|
||||
val unreadable = mutableSetOf<StoragePath>()
|
||||
|
||||
fun addDirectory(path: StoragePath) {
|
||||
entries[path] = FileEntry(path, isDirectory = true, sizeBytes = 0L, lastModifiedMillis = 0L)
|
||||
}
|
||||
|
||||
fun addFile(path: StoragePath, content: String) {
|
||||
val bytes = content.toByteArray()
|
||||
entries[path] = FileEntry(path, isDirectory = false, sizeBytes = bytes.size.toLong(), lastModifiedMillis = 0L)
|
||||
contents[path] = bytes
|
||||
}
|
||||
|
||||
fun contentOf(path: StoragePath): String? = contents[path]?.decodeToString()
|
||||
|
||||
fun contains(path: StoragePath): Boolean = entries.containsKey(path)
|
||||
|
||||
override suspend fun list(directory: StoragePath): List<FileEntry> =
|
||||
entries.values.filter { it.path.parent == directory }
|
||||
|
||||
override suspend fun stat(path: StoragePath): FileEntry? = entries[path]
|
||||
|
||||
override suspend fun exists(path: StoragePath): Boolean = entries.containsKey(path)
|
||||
|
||||
override suspend fun createDirectory(path: StoragePath): Boolean {
|
||||
entries[path]?.let { return it.isDirectory }
|
||||
addDirectory(path)
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun createFile(path: StoragePath): Boolean {
|
||||
entries[path] = FileEntry(path, isDirectory = false, sizeBytes = 0L, lastModifiedMillis = 0L)
|
||||
contents[path] = ByteArray(0)
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun delete(path: StoragePath): Boolean {
|
||||
if (!entries.containsKey(path)) return false
|
||||
for (target in entries.keys.filter { it == path || it.isDescendantOf(path) }) {
|
||||
entries.remove(target)
|
||||
contents.remove(target)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun rename(from: StoragePath, to: StoragePath): Boolean {
|
||||
if (!renameSupported) return false
|
||||
if (!entries.containsKey(from) || entries.containsKey(to)) return false
|
||||
|
||||
for (old in entries.keys.filter { it == from || it.isDescendantOf(from) }) {
|
||||
val entry = entries.remove(old) ?: continue
|
||||
val relocated = StoragePath.of(to.value + old.value.removePrefix(from.value))
|
||||
entries[relocated] = entry.copy(path = relocated)
|
||||
contents.remove(old)?.let { contents[relocated] = it }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun openRead(path: StoragePath): InputStream {
|
||||
if (path in unreadable) throw StorageException("unreadable: $path")
|
||||
val bytes = contents[path] ?: throw StorageException("no such file: $path")
|
||||
return ByteArrayInputStream(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes are committed on [OutputStream.close], which is how the engine uses it
|
||||
* (`openWrite(...).use { ... }`), so a partial write never looks like a complete one.
|
||||
*/
|
||||
override suspend fun openWrite(path: StoragePath, append: Boolean): OutputStream =
|
||||
object : ByteArrayOutputStream() {
|
||||
override fun close() {
|
||||
super.close()
|
||||
val existing = if (append) contents[path] ?: ByteArray(0) else ByteArray(0)
|
||||
val bytes = existing + toByteArray()
|
||||
contents[path] = bytes
|
||||
entries[path] = FileEntry(
|
||||
path = path,
|
||||
isDirectory = false,
|
||||
sizeBytes = bytes.size.toLong(),
|
||||
lastModifiedMillis = 0L,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun freeSpaceBytes(path: StoragePath): Long = KNOWN_FREE_BYTES
|
||||
|
||||
companion object {
|
||||
const val KNOWN_FREE_BYTES = 1024L * 1024L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.softwareapp_hb.filemanager.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import de.softwareapp_hb.filemanager.R
|
||||
import de.softwareapp_hb.filemanager.domain.OperationKind
|
||||
import de.softwareapp_hb.filemanager.domain.OperationProgress
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class FileOpServiceTest {
|
||||
|
||||
private val context get() = RuntimeEnvironment.getApplication()
|
||||
private val manager get() = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
@Test
|
||||
fun `the channel is created once and reused`() {
|
||||
FileOpNotifications.ensureChannel(context)
|
||||
FileOpNotifications.ensureChannel(context)
|
||||
|
||||
val channel = manager.getNotificationChannel(FileOpNotifications.CHANNEL_ID)
|
||||
assertNotNull(channel)
|
||||
assertEquals(NotificationManager.IMPORTANCE_LOW, channel!!.importance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an indeterminate operation says it is preparing`() {
|
||||
val notification = FileOpNotifications.build(context, OperationProgress(OperationKind.COPY, 0, 0))
|
||||
|
||||
assertEquals(
|
||||
context.getString(R.string.operation_copy_title),
|
||||
notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString(),
|
||||
)
|
||||
assertEquals(
|
||||
context.getString(R.string.notification_preparing),
|
||||
notification.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a determinate operation shows the counter`() {
|
||||
val notification = FileOpNotifications.build(context, OperationProgress(OperationKind.MOVE, 3, 10))
|
||||
assertEquals("3 / 10", notification.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `starting the service puts it in the foreground with that title`() {
|
||||
val service = Robolectric.buildService(
|
||||
FileOpService::class.java,
|
||||
FileOpService.startIntent(context, OperationKind.DELETE),
|
||||
).create().startCommand(0, 1).get()
|
||||
|
||||
val notification = shadowOf(service).lastForegroundNotification
|
||||
assertNotNull(notification)
|
||||
assertEquals(
|
||||
context.getString(R.string.operation_delete_title),
|
||||
notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -12,8 +12,8 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
|
||||
| M0 | Toolchain & scaffold | ✅ | Gradle 9.3.1 + AGP 9.1.0 wrapper, version catalog, app module, Compose theme, debug APK builds, installs and runs on the `phone` AVD: package `de.softwareapp_hb.filemanager.debug`, `versionCode 1`, `minSdk 26`/`targetSdk 36`, **no `INTERNET` permission**, `lintDebug` clean (one expected `UnusedResources` for the not-yet-used notification icon) |
|
||||
| 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 | ⏳ | create/rename/copy/move/delete, recursive copy with progress + cancellation, conflict resolution, trash via MediaStore, foreground service + progress notification |
|
||||
| M4 | Compose UI | ⏳ | browser list/grid, breadcrumb + up navigation, sort/filter sheet, multi-select action bar, details sheet, permission prompts, theme |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user