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 |