M3: operations engine (copy/move/delete, progress, cancellation, conflicts) + foreground service
This commit is contained in:
@@ -39,6 +39,15 @@
|
||||
android:maxSdkVersion="29"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<!--
|
||||
Long file operations keep running while the app is backgrounded. `dataSync` is the
|
||||
foreground-service type that matches "user-initiated data processing".
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- Android 13+: shows the operation progress notification (optional, never required). -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".FileManagerApplication"
|
||||
android:allowBackup="true"
|
||||
@@ -61,6 +70,15 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
No copy logic lives here: it mirrors the engine's progress into an ongoing
|
||||
notification and keeps the process alive during a long operation.
|
||||
-->
|
||||
<service
|
||||
android:name=".service.FileOpService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
package de.softwareapp_hb.filemanager.domain
|
||||
|
||||
import de.softwareapp_hb.filemanager.core.ConflictPolicy
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import de.softwareapp_hb.filemanager.core.TransferAction
|
||||
import de.softwareapp_hb.filemanager.core.TransferItem
|
||||
import de.softwareapp_hb.filemanager.core.TransferPlanner
|
||||
import de.softwareapp_hb.filemanager.core.TransferSource
|
||||
import de.softwareapp_hb.filemanager.data.StorageSource
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Copy, move and delete, assembled from the [StorageSource] primitives.
|
||||
*
|
||||
* The engine owns everything that is easy to get wrong: walking a tree, keeping the
|
||||
* progress counter honest, honouring cancellation between entries, and reporting
|
||||
* per-item failures instead of aborting the whole batch because one file was
|
||||
* unreadable.
|
||||
*
|
||||
* Two deliberate behaviours:
|
||||
*
|
||||
* - **Cancellation is cooperative and checked between entries.** A single very large
|
||||
* file cannot be interrupted mid-stream; the operation stops before the *next* entry
|
||||
* and reports [OperationResult.cancelled]. It never swallows a
|
||||
* [CancellationException], so a cancelled scope still unwinds normally.
|
||||
* - **[ConflictPolicy.ASK] is not answered here.** Planning records which items clash;
|
||||
* deciding is the UI's job (see [planTransfer]). The engine skips `ASK` items, so a
|
||||
* caller that wants a prompt resolves the conflicts first and calls again with a
|
||||
* concrete policy.
|
||||
*
|
||||
* @param source the backend the items are read from.
|
||||
* @param destination where they go; the same backend unless copying across them.
|
||||
*/
|
||||
class FileOperations(
|
||||
private val source: StorageSource,
|
||||
private val destination: StorageSource = source,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Work out what would happen, without touching anything.
|
||||
*
|
||||
* With [ConflictPolicy.ASK] every item that would clash comes back as
|
||||
* [TransferAction.ASK], which is how the caller finds out a prompt is needed.
|
||||
*/
|
||||
suspend fun planTransfer(
|
||||
paths: List<StoragePath>,
|
||||
target: StoragePath,
|
||||
policy: ConflictPolicy,
|
||||
): List<TransferItem> {
|
||||
val entries = paths.map { path ->
|
||||
TransferSource(path, isDirectory = source.stat(path)?.isDirectory ?: false)
|
||||
}
|
||||
val alreadyThere = destination.list(target).mapTo(HashSet()) { it.name }
|
||||
return TransferPlanner.plan(entries, target, policy) { it.name in alreadyThere }
|
||||
}
|
||||
|
||||
suspend fun createDirectory(path: StoragePath): Boolean = source.createDirectory(path)
|
||||
|
||||
/**
|
||||
* Rename in place, falling back to a copy-and-delete when the backend cannot do it
|
||||
* cheaply (a cross-directory or cross-volume move).
|
||||
*/
|
||||
suspend fun rename(from: StoragePath, to: StoragePath): Boolean {
|
||||
if (source.rename(from, to)) return true
|
||||
if (destination.exists(to)) return false
|
||||
|
||||
val state = OperationState(OperationKind.MOVE, 0) {}
|
||||
transfer(from, to, move = true, state = state)
|
||||
return state.hasFailures.not()
|
||||
}
|
||||
|
||||
suspend fun copy(
|
||||
paths: List<StoragePath>,
|
||||
target: StoragePath,
|
||||
policy: ConflictPolicy = ConflictPolicy.KEEP_BOTH,
|
||||
onProgress: (OperationProgress) -> Unit = {},
|
||||
): OperationResult = transferAll(OperationKind.COPY, paths, target, policy, move = false, onProgress = onProgress)
|
||||
|
||||
suspend fun move(
|
||||
paths: List<StoragePath>,
|
||||
target: StoragePath,
|
||||
policy: ConflictPolicy = ConflictPolicy.KEEP_BOTH,
|
||||
onProgress: (OperationProgress) -> Unit = {},
|
||||
): OperationResult = transferAll(OperationKind.MOVE, paths, target, policy, move = true, onProgress = onProgress)
|
||||
|
||||
/**
|
||||
* Delete each of [paths]. The backends remove a folder tree in one call, so progress
|
||||
* here counts one unit per top-level item rather than per file.
|
||||
*/
|
||||
suspend fun delete(
|
||||
paths: List<StoragePath>,
|
||||
onProgress: (OperationProgress) -> Unit = {},
|
||||
): OperationResult {
|
||||
val state = OperationState(OperationKind.DELETE, paths.size, onProgress)
|
||||
for (path in paths) {
|
||||
if (!currentCoroutineContext().isActive) {
|
||||
state.cancel()
|
||||
break
|
||||
}
|
||||
state.starting(path)
|
||||
if (source.delete(path)) state.advance(path) else state.failure(path, "Could not delete")
|
||||
}
|
||||
if (!currentCoroutineContext().isActive) state.cancel()
|
||||
return state.result()
|
||||
}
|
||||
|
||||
private suspend fun transferAll(
|
||||
kind: OperationKind,
|
||||
paths: List<StoragePath>,
|
||||
target: StoragePath,
|
||||
policy: ConflictPolicy,
|
||||
move: Boolean,
|
||||
onProgress: (OperationProgress) -> Unit,
|
||||
): OperationResult {
|
||||
if (!destination.exists(target)) destination.createDirectory(target)
|
||||
|
||||
val items = planTransfer(paths, target, policy)
|
||||
|
||||
var total = 0
|
||||
for (item in items) {
|
||||
if (item.action == TransferAction.SKIP || item.action == TransferAction.ASK) continue
|
||||
total += countNodes(item.source.path)
|
||||
}
|
||||
|
||||
val state = OperationState(kind, total, onProgress)
|
||||
for (item in items) {
|
||||
if (!currentCoroutineContext().isActive) {
|
||||
state.cancel()
|
||||
break
|
||||
}
|
||||
when (item.action) {
|
||||
TransferAction.SKIP, TransferAction.ASK -> Unit
|
||||
|
||||
TransferAction.REPLACE -> {
|
||||
destination.delete(item.target)
|
||||
transfer(item.source.path, item.target, move, state)
|
||||
}
|
||||
|
||||
TransferAction.COPY, TransferAction.KEEP_BOTH ->
|
||||
transfer(item.source.path, item.target, move, state)
|
||||
}
|
||||
}
|
||||
|
||||
// The loop only notices cancellation before an item; one cancelled *by the last*
|
||||
// item would otherwise be reported as a success.
|
||||
if (!currentCoroutineContext().isActive) state.cancel()
|
||||
return state.result()
|
||||
}
|
||||
|
||||
/** Copy or move one entry, recursing into folders. */
|
||||
private suspend fun transfer(
|
||||
from: StoragePath,
|
||||
to: StoragePath,
|
||||
move: Boolean,
|
||||
state: OperationState,
|
||||
) {
|
||||
if (!currentCoroutineContext().isActive) return
|
||||
|
||||
val entry = source.stat(from)
|
||||
if (entry == null) {
|
||||
state.failure(from, "The item no longer exists")
|
||||
return
|
||||
}
|
||||
|
||||
// A rename is atomic and costs no data copy when both ends share a backend, so it
|
||||
// is always worth trying first and only falling back to a byte copy when it fails.
|
||||
if (move && source === destination && source.rename(from, to)) {
|
||||
state.advance(from, countNodes(to))
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
if (!destination.exists(to)) destination.createDirectory(to)
|
||||
for (child in source.list(from)) {
|
||||
if (!currentCoroutineContext().isActive) return
|
||||
transfer(child.path, to.child(child.name), move, state)
|
||||
}
|
||||
if (move && !state.hasFailed(from) && !source.delete(from)) {
|
||||
state.failure(from, "The source folder could not be removed")
|
||||
}
|
||||
} else {
|
||||
copyFile(from, to, state)
|
||||
// Never delete the source of a copy that failed — that would lose the file.
|
||||
if (move && !state.hasFailed(from) && !source.delete(from)) {
|
||||
state.failure(from, "The source file could not be removed")
|
||||
}
|
||||
}
|
||||
|
||||
state.advance(from)
|
||||
}
|
||||
|
||||
private suspend fun copyFile(from: StoragePath, to: StoragePath, state: OperationState) {
|
||||
state.starting(from)
|
||||
try {
|
||||
destination.createFile(to)
|
||||
source.openRead(from).use { input ->
|
||||
destination.openWrite(to).use { output ->
|
||||
input.copyTo(output, COPY_BUFFER_BYTES)
|
||||
}
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
state.failure(from, failure.message ?: "The file could not be copied")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every node below [path], so the progress bar has an honest denominator before the
|
||||
* first byte moves. This is a metadata-only walk, far cheaper than the copy itself.
|
||||
*/
|
||||
private suspend fun countNodes(path: StoragePath): Int {
|
||||
val entry = source.stat(path) ?: return 0
|
||||
if (!entry.isDirectory) return 1
|
||||
|
||||
var count = 1
|
||||
for (child in source.list(path)) count += countNodes(child.path)
|
||||
return count
|
||||
}
|
||||
|
||||
/** Mutable progress bookkeeping, deliberately kept out of the public surface. */
|
||||
private class OperationState(
|
||||
private val kind: OperationKind,
|
||||
private val total: Int,
|
||||
private val onProgress: (OperationProgress) -> Unit,
|
||||
) {
|
||||
var hasFailures: Boolean = false
|
||||
private set
|
||||
|
||||
private var cancelled = false
|
||||
private var completed = 0
|
||||
private var current: StoragePath? = null
|
||||
private val failures = mutableListOf<OperationFailure>()
|
||||
|
||||
fun starting(path: StoragePath) {
|
||||
current = path
|
||||
emit()
|
||||
}
|
||||
|
||||
fun advance(path: StoragePath) = advance(path, 1)
|
||||
|
||||
fun advance(path: StoragePath, by: Int) {
|
||||
completed += by
|
||||
current = path
|
||||
emit()
|
||||
}
|
||||
|
||||
fun failure(path: StoragePath, reason: String) {
|
||||
failures += OperationFailure(path, reason)
|
||||
hasFailures = true
|
||||
}
|
||||
|
||||
fun hasFailed(path: StoragePath): Boolean =
|
||||
failures.any { it.path == path || it.path.isDescendantOf(path) }
|
||||
|
||||
fun cancel() {
|
||||
cancelled = true
|
||||
}
|
||||
|
||||
fun result(): OperationResult =
|
||||
OperationResult(kind, completed, failures.toList(), cancelled)
|
||||
|
||||
private fun emit() = onProgress(OperationProgress(kind, completed, total, current))
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 64 KiB keeps the per-call overhead negligible on both backends. */
|
||||
private const val COPY_BUFFER_BYTES = 64 * 1024
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.softwareapp_hb.filemanager.domain
|
||||
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
|
||||
/** The kind of long-running operation a progress notification can describe. */
|
||||
enum class OperationKind { COPY, MOVE, DELETE }
|
||||
|
||||
/**
|
||||
* A snapshot of a running operation.
|
||||
*
|
||||
* [total] counts every node the operation will touch (files *and* folders, since the
|
||||
* unit of work is "advance past this entry"). A total of zero — an empty selection —
|
||||
* is reported as [indeterminate] rather than as a division by zero.
|
||||
*/
|
||||
data class OperationProgress(
|
||||
val kind: OperationKind,
|
||||
val completed: Int,
|
||||
val total: Int,
|
||||
val current: StoragePath? = null,
|
||||
) {
|
||||
val indeterminate: Boolean get() = total <= 0
|
||||
|
||||
val fraction: Float get() = if (indeterminate) 0f else (completed.toFloat() / total).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/** One item that could not be processed, with a reason worth showing the user. */
|
||||
data class OperationFailure(val path: StoragePath, val reason: String)
|
||||
|
||||
/**
|
||||
* The outcome of an operation.
|
||||
*
|
||||
* A cancelled operation is not a failure: it reports what it managed to do, so a partial
|
||||
* copy can be described honestly instead of being rolled back silently.
|
||||
*/
|
||||
data class OperationResult(
|
||||
val kind: OperationKind,
|
||||
val completed: Int,
|
||||
val failures: List<OperationFailure>,
|
||||
val cancelled: Boolean,
|
||||
) {
|
||||
val succeeded: Boolean get() = failures.isEmpty() && !cancelled
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.softwareapp_hb.filemanager.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import de.softwareapp_hb.filemanager.R
|
||||
import de.softwareapp_hb.filemanager.domain.OperationProgress
|
||||
import de.softwareapp_hb.filemanager.text.titleRes
|
||||
|
||||
/**
|
||||
* Builds the progress notification for a running file operation.
|
||||
*
|
||||
* Kept apart from the service so the wording and the channel can be tested without
|
||||
* starting anything.
|
||||
*/
|
||||
object FileOpNotifications {
|
||||
|
||||
const val CHANNEL_ID = "file-operations"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
|
||||
/**
|
||||
* Create the channel once.
|
||||
*
|
||||
* No API guard is needed: the app's `minSdk` is 26, which is the release that
|
||||
* introduced notification channels, so they always exist.
|
||||
*/
|
||||
fun ensureChannel(context: Context) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.notification_channel_name),
|
||||
// Low importance: progress should never make a sound or vibrate.
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = context.getString(R.string.notification_channel_description)
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun build(context: Context, progress: OperationProgress): Notification {
|
||||
val detail = if (progress.indeterminate) {
|
||||
context.getString(R.string.notification_preparing)
|
||||
} else {
|
||||
context.getString(R.string.notification_progress, progress.completed, progress.total)
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(progress.kind.titleRes()))
|
||||
.setContentText(detail)
|
||||
.setProgress(progress.total.coerceAtLeast(0), progress.completed, progress.indeterminate)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.softwareapp_hb.filemanager.service
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.softwareapp_hb.filemanager.domain.OperationKind
|
||||
import de.softwareapp_hb.filemanager.domain.OperationProgress
|
||||
|
||||
/**
|
||||
* Keeps a long copy, move or delete alive while the app is backgrounded, and mirrors the
|
||||
* engine's progress into an ongoing notification.
|
||||
*
|
||||
* It holds no state of its own: the operation runs in the caller's coroutine and pushes
|
||||
* updates here through [update], so the notification can never disagree with the work.
|
||||
* `dataSync` is the foreground-service type that matches "user-initiated data
|
||||
* processing", which is exactly what this is.
|
||||
*/
|
||||
class FileOpService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> onStartOperation(intent)
|
||||
ACTION_UPDATE -> onUpdateProgress(intent)
|
||||
ACTION_STOP -> onFinish()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* A foreground service must post its notification within a few seconds of starting,
|
||||
* so this reports an indeterminate progress until the first [update] arrives.
|
||||
*/
|
||||
private fun onStartOperation(intent: Intent) {
|
||||
FileOpNotifications.ensureChannel(this)
|
||||
val kind = intent.operationKind() ?: OperationKind.COPY
|
||||
startForeground(
|
||||
FileOpNotifications.NOTIFICATION_ID,
|
||||
FileOpNotifications.build(this, OperationProgress(kind, completed = 0, total = 0)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onUpdateProgress(intent: Intent) {
|
||||
val kind = intent.operationKind() ?: return
|
||||
val progress = OperationProgress(
|
||||
kind = kind,
|
||||
completed = intent.getIntExtra(EXTRA_COMPLETED, 0),
|
||||
total = intent.getIntExtra(EXTRA_TOTAL, 0),
|
||||
)
|
||||
getSystemService(NotificationManager::class.java)
|
||||
?.notify(FileOpNotifications.NOTIFICATION_ID, FileOpNotifications.build(this, progress))
|
||||
}
|
||||
|
||||
private fun onFinish() {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun Intent.operationKind(): OperationKind? =
|
||||
getStringExtra(EXTRA_KIND)?.let { name -> runCatching { OperationKind.valueOf(name) }.getOrNull() }
|
||||
|
||||
companion object {
|
||||
// Internal rather than private so the service can be driven directly from a test.
|
||||
internal const val ACTION_START = "de.softwareapp_hb.filemanager.action.OPERATION_START"
|
||||
internal const val ACTION_UPDATE = "de.softwareapp_hb.filemanager.action.OPERATION_UPDATE"
|
||||
internal const val ACTION_STOP = "de.softwareapp_hb.filemanager.action.OPERATION_STOP"
|
||||
|
||||
private const val EXTRA_KIND = "kind"
|
||||
private const val EXTRA_COMPLETED = "completed"
|
||||
private const val EXTRA_TOTAL = "total"
|
||||
|
||||
/** Bring the service up in the foreground before the first item is touched. */
|
||||
fun start(context: Context, kind: OperationKind) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
newIntent(context, ACTION_START).putExtra(EXTRA_KIND, kind.name),
|
||||
)
|
||||
}
|
||||
|
||||
/** The intent [start] sends, exposed so a test can drive the service directly. */
|
||||
internal fun startIntent(context: Context, kind: OperationKind): Intent =
|
||||
newIntent(context, ACTION_START).putExtra(EXTRA_KIND, kind.name)
|
||||
|
||||
/** Push a progress snapshot into the notification. */
|
||||
fun update(context: Context, progress: OperationProgress) {
|
||||
context.startService(
|
||||
newIntent(context, ACTION_UPDATE).apply {
|
||||
putExtra(EXTRA_KIND, progress.kind.name)
|
||||
putExtra(EXTRA_COMPLETED, progress.completed)
|
||||
putExtra(EXTRA_TOTAL, progress.total)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Tear the notification down once the operation has finished (or been cancelled). */
|
||||
fun stop(context: Context) {
|
||||
context.startService(newIntent(context, ACTION_STOP))
|
||||
}
|
||||
|
||||
private fun newIntent(context: Context, action: String) =
|
||||
Intent(context, FileOpService::class.java).setAction(action)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.softwareapp_hb.filemanager.text
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import de.softwareapp_hb.filemanager.R
|
||||
import de.softwareapp_hb.filemanager.domain.OperationKind
|
||||
|
||||
/**
|
||||
* Maps domain enums onto their resource ids.
|
||||
*
|
||||
* It lives outside both `ui` and `service` because the browser screen and the progress
|
||||
* notification must show the *same* wording — duplicating the mapping is how the two
|
||||
* drift apart.
|
||||
*/
|
||||
@StringRes
|
||||
fun OperationKind.titleRes(): Int = when (this) {
|
||||
OperationKind.COPY -> R.string.operation_copy_title
|
||||
OperationKind.MOVE -> R.string.operation_move_title
|
||||
OperationKind.DELETE -> R.string.operation_delete_title
|
||||
}
|
||||
@@ -13,4 +13,14 @@
|
||||
<!-- Browser empty state (M0 placeholder for the real file list). -->
|
||||
<string name="empty_state_title">Your files, without the clutter</string>
|
||||
<string name="empty_state_body">Ad-free, account-free and completely offline. Browsing is wired up next.</string>
|
||||
|
||||
<!-- Progress notification for long file operations -->
|
||||
<string name="operation_copy_title">Copying files</string>
|
||||
<string name="operation_move_title">Moving files</string>
|
||||
<string name="operation_delete_title">Deleting files</string>
|
||||
<string name="notification_channel_name">File operations</string>
|
||||
<string name="notification_channel_description">Shows progress while files are being copied, moved or deleted</string>
|
||||
<!-- Both numbers are counts of entries, so this stays a plain substitution. -->
|
||||
<string name="notification_progress">%1$d / %2$d</string>
|
||||
<string name="notification_preparing">Preparing…</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user