diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc91794..f2d547f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 830dbf9..f3bb844 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d131230..18b7064 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -39,6 +39,15 @@
android:maxSdkVersion="29"
tools:ignore="ScopedStorage" />
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/domain/FileOperations.kt b/app/src/main/java/de/softwareapp_hb/filemanager/domain/FileOperations.kt
new file mode 100644
index 0000000..6abfb06
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/domain/FileOperations.kt
@@ -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,
+ target: StoragePath,
+ policy: ConflictPolicy,
+ ): List {
+ 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,
+ 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,
+ 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,
+ 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,
+ 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()
+
+ 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
+ }
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/domain/OperationModels.kt b/app/src/main/java/de/softwareapp_hb/filemanager/domain/OperationModels.kt
new file mode 100644
index 0000000..e109a19
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/domain/OperationModels.kt
@@ -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,
+ val cancelled: Boolean,
+) {
+ val succeeded: Boolean get() = failures.isEmpty() && !cancelled
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/service/FileOpNotifications.kt b/app/src/main/java/de/softwareapp_hb/filemanager/service/FileOpNotifications.kt
new file mode 100644
index 0000000..efac9ce
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/service/FileOpNotifications.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/service/FileOpService.kt b/app/src/main/java/de/softwareapp_hb/filemanager/service/FileOpService.kt
new file mode 100644
index 0000000..5116766
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/service/FileOpService.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt b/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt
new file mode 100644
index 0000000..4aed1f4
--- /dev/null
+++ b/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt
@@ -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
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 79a616f..7ba4557 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -13,4 +13,14 @@
Your files, without the clutter
Ad-free, account-free and completely offline. Browsing is wired up next.
+
+
+ Copying files
+ Moving files
+ Deleting files
+ File operations
+ Shows progress while files are being copied, moved or deleted
+
+ %1$d / %2$d
+ Preparing…
diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/domain/FileOperationsTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/domain/FileOperationsTest.kt
new file mode 100644
index 0000000..f3fabad
--- /dev/null
+++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/FileOperationsTest.kt
@@ -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()
+ 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")))
+ }
+}
diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemoryStorageSource.kt b/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemoryStorageSource.kt
new file mode 100644
index 0000000..af951bd
--- /dev/null
+++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemoryStorageSource.kt
@@ -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 = listOf(
+ FileEntry(StoragePath.Root, isDirectory = true, sizeBytes = 0L, lastModifiedMillis = 0L),
+ ),
+) : StorageSource {
+
+ override val backendName: String = "memory"
+
+ private val entries = LinkedHashMap()
+ private val contents = LinkedHashMap()
+
+ /** 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()
+
+ 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 =
+ 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
+ }
+}
diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/service/FileOpServiceTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/service/FileOpServiceTest.kt
new file mode 100644
index 0000000..0fa5ab1
--- /dev/null
+++ b/app/src/test/java/de/softwareapp_hb/filemanager/service/FileOpServiceTest.kt
@@ -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(),
+ )
+ }
+}
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 1920a74..dda2cba 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -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 |