M3: operations engine (copy/move/delete, progress, cancellation, conflicts) + foreground service

This commit is contained in:
2026-10-14 15:40:37 +02:00
parent 0863b37d70
commit 993cac6e5b
13 changed files with 1014 additions and 6 deletions
+14
View File
@@ -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
+4 -4
View File
@@ -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
+18
View File
@@ -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
}
+10
View File
@@ -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")))
}
}
@@ -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
View File
@@ -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 |