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
+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>