From 8ccd5f9e55a27836c9334ee0ce74c9c3419d6d6b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:02:22 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Drive:=20Auftr=C3=A4ge=20im=20Ladekreis=20w?= =?UTF-8?q?ie=20Uploads,=20Ende=20sicher=20erkannt,=20Pfad=20springt=20in?= =?UTF-8?q?=20den=20Ordner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fortschritt von Verschieben, Kopieren, Löschen und Wiederherstellen wie beim Upload: die Karte oben entfällt, der Ladekreis ersetzt den Plus-Knopf (Gesamtfortschritt mit Uploads), ein Tipp öffnet das Uploads-Sheet mit den Aufträgen, ihren Dateien mit Balken und Abbrechen (Drive 1.12.1 op.files; ältere Server: ein Balken je Auftrag) - DriveOps: ein beendeter Auftrag läuft nie wieder (spätes Ereignis oder Antwort), Dateien aus Ereignis und Abfrage zusammengeführt; ein von Drive 1.12.0 mit 5 von 5 Dateien hängengelassener Auftrag wird nach 10 s Stillstand beendet (Abbrechen auf dem Server, Rückgängig bleibt) und als fertig gezeigt - Pfad über einem Ordner: launchSingleTop ließ eine Ordnerseite über einer Ordnerseite dieselbe bleiben (nur neu geladen); jetzt zurück zur Seite mit dem Ordner (auch der Drive-Tab) oder eine neue Seite - Tests: DriveOpsTest, Hilfen in DriveBulk, JSON, Pfad Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../com/gatecontrol/android/drive/DriveApi.kt | 5 +- .../gatecontrol/android/drive/DriveBulk.kt | 75 ++++++++++ .../gatecontrol/android/drive/DriveJson.kt | 22 +++ .../gatecontrol/android/drive/DriveLogic.kt | 10 ++ .../gatecontrol/android/drive/DriveModels.kt | 21 +++ .../com/gatecontrol/android/drive/DriveOps.kt | 75 ++++++++-- .../android/navigation/AppNavigation.kt | 29 +++- .../android/ui/drive/DriveBulkUi.kt | 124 ++++++++++++++--- .../android/ui/drive/DriveComponents.kt | 63 ++++++--- .../android/ui/drive/DriveFolderScreen.kt | 18 ++- .../android/ui/drive/DriveHomeScreen.kt | 5 + .../android/ui/drive/DriveMoreScreens.kt | 12 +- .../android/ui/drive/DrivePhotosUi.kt | 14 +- .../android/ui/drive/DriveVaultScreen.kt | 9 +- app/src/main/res/values-de/strings_drive7.xml | 14 ++ app/src/main/res/values/strings_drive7.xml | 14 ++ .../android/drive/DriveBulkTest.kt | 60 ++++++++ .../android/drive/DriveJsonBulkTest.kt | 24 ++++ .../android/drive/DriveLogicTest.kt | 11 ++ .../gatecontrol/android/drive/DriveOpsTest.kt | 128 ++++++++++++++++++ 20 files changed, 664 insertions(+), 69 deletions(-) create mode 100644 app/src/test/java/com/gatecontrol/android/drive/DriveOpsTest.kt diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt index 34417b66..89875657 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt @@ -446,10 +446,11 @@ class DriveApi @Inject constructor( return post("ops", DriveJson.opBody(kind, ids, target, onConflict), DriveJson::opAnswer) } - suspend fun op(id: String): DResult = get("ops/" + (DriveJson.opId(id) ?: return bad()), parse = DriveJson::opAnswer) + /** The job [id] with its items one by one (Drive 1.12.1; an older server leaves them out). */ + suspend fun op(id: String): DResult = get("ops/" + (DriveJson.opId(id) ?: return bad()), mapOf("files" to "1"), DriveJson::opAnswer) /** My jobs still running (after the app was closed, they go on on the server). */ - suspend fun runningOps(): DResult> = get("ops", mapOf("state" to "running"), DriveJson::ops) + suspend fun runningOps(): DResult> = get("ops", mapOf("state" to "running", "files" to "1"), DriveJson::ops) /** Stops a job: what is done stays done. */ suspend fun cancelOp(id: String): DResult = del("ops/" + (DriveJson.opId(id) ?: return bad()), DriveJson::opAnswer) diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveBulk.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveBulk.kt index 35c57d8a..64cb2eca 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveBulk.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveBulk.kt @@ -276,6 +276,16 @@ internal object DriveBulk { const val ON_RENAME = "rename" const val ON_SKIP = "skip" + /** Items of a job the app keeps (the server sends up to 500 at a time). */ + const val FILES_KEPT = 500 + + /** Items of a job the sheet shows at a time, and how many done ones before the one at work. */ + const val FILES_SHOWN = 100 + const val FILES_BEFORE = 5 + + /** A job with all its files done that does not change for this long has ended (see [stalledDone]). */ + const val STALL_MS = 10_000L + /** One line of the question: what is there, what comes; [same] the content is the same already. */ data class ConflictRow( val name: String, @@ -366,6 +376,71 @@ internal object DriveBulk { else -> 0f } + /** How far one item of a job is (0…1); null while it runs without a measure (an indeterminate bar). */ + fun fileProgress(f: DOpFile): Float? = when (f.state) { + "done", "skipped", "failed" -> 1f + "running" -> f.bytesDone?.takeIf { f.size > 0 }?.let { (it.toFloat() / f.size).coerceIn(0f, 1f) } + else -> 0f + } + + /** + * The items of a job the sheet shows: at most [max], from a few before the first not done yet (the ones at work and + * waiting next; what failed stays in the snackbar's count). + */ + fun visibleFiles(files: List, max: Int = FILES_SHOWN): List { + if (files.size <= max) return files + val at = files.indexOfFirst { it.state == "running" || it.state == "waiting" }.takeIf { it >= 0 } ?: files.size + val from = (at - FILES_BEFORE).coerceIn(0, files.size - max) + return files.subList(from, from + max) + } + + /** + * A job's items as the app keeps them: [new] (the server's window — the live event carries fewer than `GET`) over + * [old], by their place in the job; at most [max], the first done ones go first. Null [new]: [old] stays. + */ + fun mergeFiles(old: List?, new: List?, max: Int = FILES_KEPT): List? { + if (new == null) return old + if (old.isNullOrEmpty()) return new.sortedBy { it.seq }.takeLast(max) + val all = (old.associateBy { it.seq } + new.associateBy { it.seq }).values.sortedBy { it.seq } + if (all.size <= max) return all + var drop = all.size - max + val out = ArrayList(max) + for (f in all) { + if (drop > 0 && f.state == "done") { drop--; continue } + out += f + } + return out.takeLast(max) + } + + /** + * A job that did all its files but still runs after [unchangedMs] without a change: Drive 1.12.0 left a job so when + * the time of a request ran out during its last item (it never ended there). The app stops it — what is done stays, + * "Rückgängig" works — and shows it as done. + */ + fun stalledDone(op: DOp, unchangedMs: Long): Boolean = + op.running && op.total > 0 && op.done >= op.total && op.transfersLeft == 0 && unchangedMs >= STALL_MS + + /** + * The round button's ring over uploads ([uploads]: sent and total bytes each; total ≤ 0: not known yet) and jobs: + * each weighted by its bytes (at least 1); null when none of them has a measure yet (an indeterminate ring). + */ + fun ring(uploads: List>, jobs: List): Float? { + var weight = 0.0 + var done = 0.0 + for ((sent, total) in uploads) { + if (total <= 0) continue + weight += total + done += sent.coerceIn(0, total) + } + for (op in jobs) { + if (op.bytesTotal <= 0 && op.total <= 0) continue + val w = op.bytesTotal.coerceAtLeast(1).toDouble() + weight += w + done += w * progress(op) + } + return if (weight <= 0.0) null else (done / weight).toFloat().coerceIn(0f, 1f) + } + /** Whether "Rückgängig" still works for [op] at [nowMs] (a finished move, copy or trash; a stopped one too). */ fun canUndo(op: DOp, nowMs: Long): Boolean { if (op.kind !in setOf("move", "copy", "trash") || (op.state != "done" && op.state != "cancelled")) return false diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt index 4f3f133e..e48bbe26 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt @@ -966,6 +966,28 @@ internal object DriveJson { transfer = o.str("transfer")?.takeIf { it == "client" || it == "server" }, transfersLeft = o.int("transfersLeft")?.coerceAtLeast(0) ?: 0, error = o.str("error")?.take(40), + files = o.arr("files")?.mapNotNull { opFile(it as? JsonObject) }?.take(OP_FILES_MAX), + filesMore = o.int("filesMore")?.coerceAtLeast(0) ?: 0, + ) + } + + private val OP_FILE_STATES = setOf("waiting", "running", "done", "skipped", "failed") + const val OP_FILES_MAX = 1000 + + /** An item of a job (`{ seq, nodeId, name, size, isDir, state, reason?, bytesDone?, encrypted? }`, Drive 1.12.1). */ + private fun opFile(o: JsonObject?): DOpFile? { + if (o == null) return null + val size = o.long("size")?.coerceAtLeast(0) ?: 0L + return DOpFile( + seq = o.int("seq")?.takeIf { it > 0 } ?: return null, + nodeId = o.long("nodeId")?.takeIf { it > 0 }, + name = o.str("name")?.take(255)?.ifEmpty { null }, + size = size, + isDir = o.bool("isDir") ?: false, + state = o.str("state")?.takeIf { it in OP_FILE_STATES } ?: return null, + reason = o.str("reason")?.take(40), + bytesDone = o.long("bytesDone")?.coerceIn(0, size.coerceAtLeast(0)), + encrypted = o.bool("encrypted") ?: false, ) } diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt index 92b6b69d..c24d901d 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt @@ -167,6 +167,16 @@ internal object DriveLogic { /** At most one live reload of a folder this often. */ const val LIVE_GAP_MS = 2_000L + /** + * A tap on the path of a folder's page: how many pages to go back to the one that shows folder [id] already (the + * nearest; 0: the page on top shows it), or null to open it as a new page. [shown]: the folder of each page on the + * back stack, the top last (null: a page that shows no folder). + */ + fun crumbPops(shown: List, id: Long): Int? { + val i = shown.lastIndexOf(id) + return if (i < 0) null else shown.size - 1 - i + } + private val SCAN_EXT = Regex("\\.(pdf|jpe?g)$", RegexOption.IGNORE_CASE) // ─── when a photo was taken (Drive 1.7, feature `takenAt`) ────── diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt index e5e54248..d540fb63 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt @@ -523,9 +523,30 @@ data class DOp( val transfersLeft: Int = 0, /** Why it failed: `abandoned`, `quota`, `device_locked`, `no_seat`, `internal`. */ val error: String? = null, + /** Its items one by one (Drive 1.12.1, `?files=1` and the live event; null: the server does not say). */ + val files: List? = null, + /** Items not in [files]. */ + val filesMore: Int = 0, ) { val running: Boolean get() = state == "running" } +/** + * One item of a job (Drive 1.12.1): a chosen file or folder (a copy: each file), its [state] `waiting` | `running` | + * `done` | `skipped` | `failed` ([reason] for the last two), and the bytes done — null while it runs without a measure. + * [seq]: its place in the job. A vault's item has no [name] ([encrypted]). + */ +data class DOpFile( + val seq: Int, + val nodeId: Long?, + val name: String?, + val size: Long, + val isDir: Boolean, + val state: String, + val reason: String? = null, + val bytesDone: Long? = null, + val encrypted: Boolean = false, +) + /** `POST zip`: where the ZIP of a selection can be fetched for a short time, its file name. */ data class DZip(val url: String?, val path: String?, val mediaId: String?, val name: String, val expiresAt: String?) diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveOps.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveOps.kt index 095fbe1c..4f43f4b6 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveOps.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveOps.kt @@ -29,10 +29,20 @@ import javax.inject.Singleton * it shows the same way. New items at a target are marked "neu hier" for a while. */ @Singleton -class DriveOps @Inject constructor( +class DriveOps internal constructor( private val api: DriveApi, private val live: DriveLive, + private val scope: CoroutineScope, + /** Milliseconds since boot (tests: the virtual time). */ + private val clock: () -> Long, ) { + @Inject constructor(api: DriveApi, live: DriveLive) : this( + api, + live, + CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler), + { SystemClock.elapsedRealtime() }, + ) + /** * A job followed here: the server's state ([op]) and what the app knows besides — the first item's name ([title]) * and how many there were ([count]), the target's name ([target]). [local]: the app runs it itself. @@ -45,12 +55,10 @@ class DriveOps @Inject constructor( val local: Boolean = false, ) - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler) - private val _jobs = MutableStateFlow>(emptyMap()) val jobs: StateFlow> = _jobs.asStateFlow() - /** The jobs still running, the oldest first (a card each on top of the folder). */ + /** The jobs still running, the oldest first (the ring of the round button, the uploads sheet). */ val running: StateFlow> = _jobs.map { m -> m.values.filter { it.op.running } } .stateIn(scope, SharingStarted.Eagerly, emptyList()) @@ -63,7 +71,7 @@ class DriveOps @Inject constructor( init { scope.launch { live.ops.collect { op -> - lastEvent[op.id] = SystemClock.elapsedRealtime() + lastEvent[op.id] = clock() update(op) } } @@ -72,7 +80,8 @@ class DriveOps @Inject constructor( private fun update(op: DOp) { _jobs.update { m -> val known = m[op.id] - m + (op.id to (known?.copy(op = op) ?: Job(op, op.label, op.items.takeIf { it > 0 } ?: op.total, op.targetName ?: DriveBulk.lastPart(op.targetPath)))) + val next = merged(known?.op, op) ?: return@update m + m + (op.id to (known?.copy(op = next) ?: Job(next, next.label, next.items.takeIf { it > 0 } ?: next.total, next.targetName ?: DriveBulk.lastPart(next.targetPath)))) } } @@ -80,23 +89,63 @@ class DriveOps @Inject constructor( suspend fun start(kind: String, ids: List, target: Long?, onConflict: String?, title: String?, targetName: String?): DResult { val r = api.startOp(kind, ids, target, onConflict) if (r is DResult.Ok) { - val op = r.value - _jobs.update { it + (op.id to Job(op, title ?: op.label, op.items.takeIf { n -> n > 0 } ?: ids.size, targetName ?: op.targetName ?: DriveBulk.lastPart(op.targetPath))) } - if (op.running) follow(op.id) + val answer = r.value + _jobs.update { m -> + // the live event may have told the end before the answer came + val op = merged(m[answer.id]?.op, answer) ?: m[answer.id]?.op ?: answer + m + (op.id to Job(op, title ?: op.label, op.items.takeIf { n -> n > 0 } ?: ids.size, targetName ?: op.targetName ?: DriveBulk.lastPart(op.targetPath))) + } + if (_jobs.value[answer.id]?.op?.running == true) follow(answer.id) } return r } - /** Asks for the job's state while it runs and no event came for a while. */ + /** + * [next] over what is known of a job ([known]): an ended job never runs again nor ends otherwise (an event or an answer + * that comes late); the items are merged (the event carries fewer). Null: [next] is older and changes nothing. + */ + private fun merged(known: DOp?, next: DOp): DOp? { + if (known == null) return next + if (!known.running && next.running) return null + return next.copy( + // the end the app saw first stays (a job Drive 1.12.0 left running ends here as done, its event says cancelled) + state = if (known.running) next.state else known.state, + files = DriveBulk.mergeFiles(known.files, next.files), + filesMore = if (next.files == null) known.filesMore else next.filesMore, + ) + } + + /** + * Asks for the job's state while it runs and no event came for a while. A job with all its files done that does not + * change for a while has ended: Drive 1.12.0 could leave one so ([DriveBulk.stalledDone]) — it is stopped on the + * server (what is done stays, "Rückgängig" works) and shown as done. + */ private fun follow(id: String) { if (!following.add(id)) return scope.launch { try { + var seen: DOp? = null + var changedAt = clock() while (true) { delay(POLL_MS) val job = _jobs.value[id] ?: break if (!job.op.running) break - if (SystemClock.elapsedRealtime() - (lastEvent[id] ?: 0L) < POLL_MS) continue + val now = clock() + if (seen == null || seen.done != job.op.done || seen.bytesDone != job.op.bytesDone || seen.total != job.op.total) { + seen = job.op + changedAt = now + } + if (!job.local && DriveBulk.stalledDone(job.op, now - changedAt)) { + when (val r = api.cancelOp(id)) { + is DResult.Ok -> { + val op = r.value + update(if (op.state == "cancelled" || op.state == "done") op.copy(state = "done") else op) + } + else -> changedAt = now + } + continue + } + if (now - (lastEvent[id] ?: Long.MIN_VALUE / 2) < POLL_MS) continue when (val r = api.op(id)) { is DResult.Ok -> update(r.value) // gone on the server (restarted without it): it ended @@ -131,7 +180,7 @@ class DriveOps @Inject constructor( val r = api.undoOp(id) if (r is DResult.Ok) { val was = _jobs.value[id] - _jobs.update { it + (r.value.id to Job(r.value, was?.title, was?.count ?: r.value.total, was?.target)) } + _jobs.update { m -> m + (r.value.id to Job(merged(m[r.value.id]?.op, r.value) ?: m.getValue(r.value.id).op, was?.title, was?.count ?: r.value.total, was?.target)) } if (r.value.running) follow(r.value.id) } return r @@ -203,7 +252,7 @@ class DriveOps @Inject constructor( } } - private companion object { + internal companion object { const val POLL_MS = 2_000L const val FRESH_MS = 90_000L } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt index 6ca62271..c5a07678 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -1484,6 +1484,9 @@ private fun NavGraphBuilder.vaultRoutes(navController: NavHostController, naviga } } +/** The folder the Drive tab's Files page shows (kept in the tab's back stack entry). */ +private const val DRIVE_TAB_SHOWN = "drive_shown" + /** * Drive (plugin gatecontrol-drive): the tab, a folder, the trash and the search, each a route of its own so the tab * bar shows on the tab only and back works as everywhere else. @@ -1491,14 +1494,34 @@ private fun NavGraphBuilder.vaultRoutes(navController: NavHostController, naviga private fun NavGraphBuilder.driveRoutes(navController: NavHostController, navigateToTab: (String) -> Unit) { val back: () -> Unit = { if (!navController.popBackStack()) navigateToTab(Screen.Drive.route) } val go: (String) -> Unit = { route -> navController.navigate(route) { launchSingleTop = true } } - val folder: (Long) -> Unit = { go(Screen.DriveFolder.create(it)) } + /** The folder shown by each page on the back stack, the top last (null: no folder page). */ + val shownFolders: () -> List = { + navController.currentBackStack.value.map { e -> + when (e.destination.route) { + Screen.DriveFolder.route -> e.arguments?.getString(Screen.DriveFolder.ARG_ID)?.toLongOrNull() + Screen.Drive.route -> e.savedStateHandle.get(DRIVE_TAB_SHOWN) + else -> null + } + } + } + // A folder is a page of its own. Not "launchSingleTop": a folder page on top of a folder page is the same + // destination, the navigation would only give the page on top the new id (its view model keeps showing its folder). + val folder: (Long) -> Unit = { id -> + if (shownFolders().lastOrNull() != id) navController.navigate(Screen.DriveFolder.create(id)) + } + // The path above a folder: back to the page that shows that folder already (the Drive tab too), else a new page. + val crumb: (Long) -> Unit = { id -> + val pops = com.gatecontrol.android.drive.DriveLogic.crumbPops(shownFolders(), id) + if (pops == null) navController.navigate(Screen.DriveFolder.create(id)) else repeat(pops) { navController.popBackStack() } + } val file: (Long) -> Unit = { go(Screen.DriveFile.create(it)) } val vault: (String, Long) -> Unit = { v, f -> navController.navigate(Screen.DriveVault.create(v, f)) } - composable(Screen.Drive.route) { + composable(Screen.Drive.route) { entry -> GcContentWidth { com.gatecontrol.android.ui.drive.DriveHomeScreen( com.gatecontrol.android.ui.drive.DriveNav( folder = folder, + shown = { id -> entry.savedStateHandle[DRIVE_TAB_SHOWN] = id }, search = { go(Screen.DriveSearch.route) }, trash = { go(Screen.DriveTrash.route) }, storage = { go(Screen.DriveStorage.route) }, @@ -1513,7 +1536,7 @@ private fun NavGraphBuilder.driveRoutes(navController: NavHostController, naviga } } composable(Screen.DriveFolder.route, arguments = listOf(navArgument(Screen.DriveFolder.ARG_ID) { type = NavType.StringType })) { - GcContentWidth { com.gatecontrol.android.ui.drive.DriveFolderScreen(onBack = back, onFolder = folder, onFile = file, onVault = vault) } + GcContentWidth { com.gatecontrol.android.ui.drive.DriveFolderScreen(onBack = back, onFolder = folder, onFile = file, onVault = vault, onCrumb = crumb) } } composable(Screen.DriveFile.route, arguments = listOf(navArgument(Screen.DriveFile.ARG_ID) { type = NavType.StringType })) { GcContentWidth { com.gatecontrol.android.ui.drive.DriveFileScreen(onBack = back, onFolder = folder) } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBulkUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBulkUi.kt index 354dfbe2..20a6a101 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBulkUi.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveBulkUi.kt @@ -75,6 +75,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.gatecontrol.android.R import com.gatecontrol.android.drive.DFolder +import com.gatecontrol.android.drive.DOpFile import com.gatecontrol.android.drive.DNode import com.gatecontrol.android.drive.DResult import com.gatecontrol.android.drive.DriveBulk @@ -89,6 +90,7 @@ import com.gatecontrol.android.ui.components.GcIconSquare import com.gatecontrol.android.ui.components.GcIcons import com.gatecontrol.android.ui.components.GcOutlineButton import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcListRow import com.gatecontrol.android.ui.components.GcRowDivider import com.gatecontrol.android.ui.components.GcSectionLabel import com.gatecontrol.android.ui.components.GcTextField @@ -716,15 +718,34 @@ private fun AskChoice(title: String, description: String, selected: Boolean, ena // ─── jobs: progress, snackbar ─────────────────────────────────────── -/** The running jobs as cards on top: "Kopiere „X“ nach Y", how far, a bar, "Abbrechen". */ +/** + * The round button of a page without uploads (the photos, the search, a vault): a plus ([onAdd], if any) or, while + * jobs run, their ring; a tap on it opens the sheet with the jobs and their items. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun JobsFab(jobs: List, onCancel: (String) -> Unit, modifier: Modifier = Modifier, onAdd: (() -> Unit)? = null) { + var open by remember { mutableStateOf(false) } + DriveFab(uploads = emptyList(), onAdd = onAdd, onOpenUploads = { open = true }, onAllDone = {}, modifier = modifier, jobs = jobs) + if (open && jobs.isNotEmpty()) { + ModalBottomSheet(onDismissRequest = { open = false }) { + UploadsSheet(emptyList(), onConflict = {}, onClear = {}, jobs = jobs, onCancelJob = onCancel) + } + } +} + +/** What the app does with the selection itself ("Senden", a ZIP): a card on top while it runs, with "Abbrechen". */ @Composable -internal fun OpCards(jobs: List, busy: DriveBulkActions.Busy?, onCancel: (String) -> Unit, onCancelBusy: () -> Unit) { - jobs.forEach { j -> OpCard(j, onCancel = { onCancel(j.op.id) }) } - busy?.let { b -> BusyCard(b, onCancelBusy) } +internal fun BulkBusyCard(busy: DriveBulkActions.Busy?, onCancel: () -> Unit) { + busy?.let { b -> BusyCard(b, onCancel) } } +/** + * A running job in the uploads sheet: "Verschiebe „X“ nach Y", how far, "Abbrechen"; below, its items one by one with + * their bars like the uploads (Drive 1.12.1) — without them (an older server, a job of this app) one bar for the job. + */ @Composable -private fun OpCard(job: DriveOps.Job, onCancel: () -> Unit) { +internal fun JobRows(job: DriveOps.Job, onCancel: () -> Unit) { val x = GateControlTheme.extraColors val op = job.op val what = if (job.count == 1 && job.title != null) stringResource(R.string.drive_quoted, job.title) else pluralStringResource(R.plurals.drive_items, job.count, job.count) @@ -742,28 +763,89 @@ private fun OpCard(job: DriveOps.Job, onCancel: () -> Unit) { if (total > 0) add(stringResource(if (job.local) R.string.drive_op_items else R.string.drive_op_files, op.done, total)) if (op.bytesTotal > 0) add(stringResource(R.string.drive_op_bytes, DriveLogic.size(op.bytesDone), DriveLogic.size(op.bytesTotal))) }.joinToString(" · ") - val p = DriveBulk.progress(op) - val running = stringResource(R.string.drive_op_running) - GcCard(modifier = Modifier.semantics { contentDescription = running }, verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - GcIconSquare(size = 36.dp, background = x.blueBg) { - Icon(if (op.kind == "copy") GcIcons.Copy else if (op.kind == "move") GcIcons.FolderMove else GcIcons.Trash, contentDescription = null, tint = x.blue, modifier = Modifier.size(18.dp)) - } - Column(Modifier.weight(1f)) { + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Row(Modifier.fillMaxWidth().padding(start = 20.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Icon(jobIcon(op.kind), contentDescription = null, tint = x.blue, modifier = Modifier.size(20.dp)) + Column(Modifier.weight(1f).semantics(mergeDescendants = true) {}) { Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) - if (counts.isNotEmpty()) Text(counts, style = MaterialTheme.typography.bodySmall, color = x.muted) + Text( + if (counts.isNotEmpty()) counts else stringResource(R.string.drive_op_running), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + ) } TextButton(onClick = onCancel, modifier = Modifier.heightIn(min = 44.dp)) { Text(stringResource(R.string.drive_cancel), color = x.accentText, fontWeight = FontWeight.SemiBold) } } - val label = stringResource(R.string.drive_op_progress) - LinearProgressIndicator( - progress = { p }, - color = x.blue, - trackColor = x.panel2, - modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).semantics { contentDescription = label }, + Text( + stringResource(if (job.local) R.string.drive_op_local else R.string.drive_op_server), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + modifier = Modifier.padding(start = 52.dp, end = 20.dp, bottom = 4.dp), ) - Text(stringResource(if (job.local) R.string.drive_op_local else R.string.drive_op_server), style = MaterialTheme.typography.bodySmall, color = x.muted) + val files = op.files + if (files.isNullOrEmpty()) { + val label = stringResource(R.string.drive_op_progress) + LinearProgressIndicator( + progress = { DriveBulk.progress(op) }, + color = MaterialTheme.colorScheme.primary, + trackColor = x.panel2, + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 6.dp).height(4.dp).clip(RoundedCornerShape(2.dp)).semantics { contentDescription = label }, + ) + } else { + val shown = DriveBulk.visibleFiles(files) + shown.forEach { f -> JobFileRow(f) } + val more = op.filesMore + files.size - shown.size + if (more > 0) { + Text( + pluralStringResource(R.plurals.drive_op_files_more, more, more), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + ) + } + } + GcRowDivider(Modifier.padding(top = 6.dp)) + } +} + +/** One item of a job: its name, its state, its bar (without a measure while it runs on the server). */ +@Composable +private fun JobFileRow(f: DOpFile) { + val x = GateControlTheme.extraColors + val line = when (f.state) { + "waiting" -> stringResource(R.string.drive_upload_waiting) + "running" -> f.bytesDone?.takeIf { f.size > 0 }?.let { "${DriveLogic.size(it)} / ${DriveLogic.size(f.size)}" } ?: stringResource(R.string.drive_op_file_running) + "done" -> stringResource(R.string.drive_upload_done) + "skipped" -> if (f.reason == "cancelled") stringResource(R.string.drive_op_file_cancelled) else stringResource(R.string.drive_upload_skipped) + else -> stringResource(R.string.drive_upload_failed, bulkReason(f.reason ?: "error")) } + val failed = f.state == "failed" + Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + GcListRow( + title = f.name ?: stringResource(if (f.isDir) R.string.drive_op_folder_encrypted else R.string.drive_op_file_encrypted), + description = line, + titleColor = MaterialTheme.colorScheme.onSurface, + ) + val p = DriveBulk.fileProgress(f) + val color = when { + failed -> MaterialTheme.colorScheme.error + f.state == "skipped" -> x.muted + else -> MaterialTheme.colorScheme.primary + } + val bar = Modifier.fillMaxWidth().padding(horizontal = 20.dp).height(4.dp).clip(RoundedCornerShape(2.dp)) + if (p == null) { + LinearProgressIndicator(modifier = bar, color = color, trackColor = x.panel2) + } else { + LinearProgressIndicator(progress = { p }, modifier = bar, color = color, trackColor = x.panel2) + } + } +} + +/** A job's reason (`into_itself`, `locked`, a Drive code …) as words. */ +@Composable +private fun bulkReason(reason: String): String { + val context = LocalContext.current + return reasonText(context, reason) } @Composable diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt index 6d455f89..c3b8b657 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt @@ -74,7 +74,10 @@ import androidx.core.content.FileProvider import com.gatecontrol.android.R import com.gatecontrol.android.drive.DNode import com.gatecontrol.android.drive.DResult +import com.gatecontrol.android.drive.DriveBulk import com.gatecontrol.android.drive.DriveLogic +import com.gatecontrol.android.drive.DriveOps +import androidx.compose.ui.res.pluralStringResource import com.gatecontrol.android.drive.DriveTransfers import com.gatecontrol.android.ui.components.GcCard import com.gatecontrol.android.ui.components.GcIconSquare @@ -405,10 +408,10 @@ internal fun DriveLoading() { } /** - * The round button bottom right: a plus (new folder, upload, … – [onAdd]) while nothing uploads; while uploads run, - * a ring with the progress of all of them (yellow on a taken name, red with failures) that opens [UploadsSheet]. - * Finished uploads are cleared by themselves, then the plus comes back. Nothing shows when the folder is read only - * and nothing uploads. + * The round button bottom right: a plus (new folder, upload, … – [onAdd]) while nothing uploads and no job runs; while + * uploads or jobs on several items ([jobs], Drive 1.12) run, a ring with the progress of all of them together (yellow + * on a taken name, red with failed uploads) that opens [UploadsSheet]. Finished uploads are cleared by themselves, an + * ended job leaves by itself, then the plus comes back. Nothing shows when the folder is read only and nothing runs. */ @Composable internal fun DriveFab( @@ -417,15 +420,17 @@ internal fun DriveFab( onOpenUploads: () -> Unit, onAllDone: () -> Unit, modifier: Modifier = Modifier, + jobs: List = emptyList(), ) { - val active = uploads.any { it.state == DriveTransfers.State.QUEUED || it.state == DriveTransfers.State.RUNNING || it.state == DriveTransfers.State.CONFLICT } + val uploading = uploads.any { it.state == DriveTransfers.State.QUEUED || it.state == DriveTransfers.State.RUNNING || it.state == DriveTransfers.State.CONFLICT } val failed = uploads.any { it.state == DriveTransfers.State.FAILED } - LaunchedEffect(active, failed, uploads.size) { - if (!active && !failed && uploads.isNotEmpty()) { + LaunchedEffect(uploading, failed, uploads.size) { + if (!uploading && !failed && uploads.isNotEmpty()) { delay(1_200) onAllDone() } } + val active = uploading || jobs.isNotEmpty() val x = GateControlTheme.extraColors if (!active && !failed) { if (onAdd == null) return @@ -444,15 +449,22 @@ internal fun DriveFab( } return } - val total = uploads.sumOf { it.total.coerceAtLeast(0) } - val sent = uploads.sumOf { u -> if (u.state == DriveTransfers.State.RUNNING || u.state == DriveTransfers.State.QUEUED) u.sent else u.total } - val progress = if (total > 0) (sent.toFloat() / total).coerceIn(0f, 1f) else 0f + val sending = uploads.map { u -> (if (u.state == DriveTransfers.State.RUNNING || u.state == DriveTransfers.State.QUEUED) u.sent else u.total) to u.total } + val progress = DriveBulk.ring(sending, jobs.map { it.op }) val color = when { uploads.any { it.state == DriveTransfers.State.CONFLICT || (it.state == DriveTransfers.State.QUEUED && it.code == "device_locked") } -> x.warn failed && !active -> MaterialTheme.colorScheme.error else -> MaterialTheme.colorScheme.primary } - val label = stringResource(R.string.drive_uploads_running, uploads.count { it.state.finished() }, uploads.size) + val label = listOfNotNull( + if (uploads.isNotEmpty()) stringResource(R.string.drive_uploads_running, uploads.count { it.state.finished() }, uploads.size) else null, + if (jobs.isNotEmpty()) pluralStringResource(R.plurals.drive_ops_running, jobs.size, jobs.size) else null, + ).joinToString(", ") + val icon = when { + failed && !active -> GcIcons.Alert + uploading || jobs.isEmpty() -> GcIcons.Upload + else -> jobIcon(jobs.first().op.kind) + } Box( modifier .size(56.dp) @@ -463,32 +475,51 @@ internal fun DriveFab( .semantics { contentDescription = label }, contentAlignment = Alignment.Center, ) { - if (active && total == 0L) { + if (active && progress == null) { CircularProgressIndicator(modifier = Modifier.size(44.dp), color = color, strokeWidth = 4.dp) } else { - CircularProgressIndicator(progress = { if (active) progress else 1f }, modifier = Modifier.size(44.dp), color = color, strokeWidth = 4.dp, trackColor = x.panel2) + CircularProgressIndicator(progress = { if (active) progress ?: 0f else 1f }, modifier = Modifier.size(44.dp), color = color, strokeWidth = 4.dp, trackColor = x.panel2) } - Icon(if (failed && !active) GcIcons.Alert else GcIcons.Upload, contentDescription = null, tint = color, modifier = Modifier.size(18.dp)) + Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(18.dp)) } } +/** The icon of a job of [kind] (`move`, `copy`, `trash` …). */ +internal fun jobIcon(kind: String): ImageVector = when (kind) { + "copy" -> GcIcons.Copy + "move" -> GcIcons.FolderMove + else -> GcIcons.Trash +} + private fun DriveTransfers.State.finished() = this == DriveTransfers.State.DONE || this == DriveTransfers.State.SAME || this == DriveTransfers.State.SKIPPED || this == DriveTransfers.State.FAILED -/** The uploads one by one, each with its own bar; a name taken asks what to do. */ +/** + * The uploads one by one, each with its own bar; a name taken asks what to do. Above them the running jobs on several + * items ([jobs], Drive 1.12): each with "Abbrechen" and its items one by one with their bars (Drive 1.12.1; an older + * server: one bar for the job). + */ @Composable internal fun UploadsSheet( uploads: List, onConflict: (DriveTransfers.Upload) -> Unit, onClear: () -> Unit, + jobs: List = emptyList(), + onCancelJob: (String) -> Unit = {}, ) { val x = GateControlTheme.extraColors Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) { Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.drive_uploads), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + val title = when { + jobs.isEmpty() -> R.string.drive_uploads + uploads.isEmpty() -> R.string.drive_ops_title + else -> R.string.drive_ops_uploads_title + } + Text(stringResource(title), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) if (uploads.any { it.state.finished() }) TextButton(onClick = onClear) { Text(stringResource(R.string.drive_clear)) } } Column(Modifier.heightIn(max = 480.dp).verticalScroll(rememberScrollState())) { + jobs.forEach { j -> JobRows(j, onCancel = { onCancelJob(j.op.id) }) } uploads.takeLast(50).forEach { u -> val line = when (u.state) { DriveTransfers.State.QUEUED -> diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderScreen.kt index 7bb21ba9..8f8d91d6 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderScreen.kt @@ -103,6 +103,8 @@ fun DriveFolderScreen( onFolder: (Long) -> Unit, onFile: (Long) -> Unit = {}, onVault: (String, Long) -> Unit = { _, _ -> }, + /** A tap on the path above: back to the page with that folder, else a new page. */ + onCrumb: ((Long) -> Unit)? = null, viewModel: DriveFolderViewModel = hiltViewModel(), ) { DriveFolderContent( @@ -110,6 +112,7 @@ fun DriveFolderScreen( onFolder = onFolder, onFile = onFile, onVault = onVault, + onCrumb = onCrumb, topBar = { title, actions -> GcSubpageBar(title, onBack = onBack, backDescription = stringResource(R.string.drive_back)) { actions() } }, ) } @@ -231,8 +234,8 @@ internal fun DriveFolderContent( if (area?.via == "parent") { GcBanner(tone = GcTone.Warn, icon = GcIcons.Eye) { Text(stringResource(R.string.drive_child_note), style = MaterialTheme.typography.bodyMedium) } } - // jobs on several items: how far they are (they go on on the server) - OpCards(running, busy, onCancel = { id -> scope.launch { viewModel.ops.cancel(id) } }, onCancelBusy = viewModel.bulk::cancelBusy) + // sending or zipping the selection here (the jobs on several items show in the round button) + BulkBusyCard(busy, viewModel.bulk::cancelBusy) header() when (val s = state) { DriveLoad.Loading -> DriveLoading() @@ -305,6 +308,7 @@ internal fun DriveFolderContent( onOpenUploads = { uploadsOpen = true }, onAllDone = viewModel.transfers::clearFinished, modifier = Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = 16.dp), + jobs = running, ) } SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding().padding(start = 16.dp, end = 16.dp, bottom = if (selecting) 88.dp else 84.dp)) @@ -348,9 +352,15 @@ internal fun DriveFolderContent( } } - if (uploadsOpen && uploads.isNotEmpty()) { + if (uploadsOpen && (uploads.isNotEmpty() || running.isNotEmpty())) { ModalBottomSheet(onDismissRequest = { uploadsOpen = false }) { - UploadsSheet(uploads, onConflict = { uploadsOpen = false; conflict = it }, onClear = viewModel.transfers::clearFinished) + UploadsSheet( + uploads, + onConflict = { uploadsOpen = false; conflict = it }, + onClear = viewModel.transfers::clearFinished, + jobs = running, + onCancelJob = { id -> scope.launch { viewModel.ops.cancel(id) } }, + ) } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt index 12ec1d7c..078c402b 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt @@ -103,6 +103,8 @@ data class DriveNav( val vault: (String, Long) -> Unit = { _, _ -> }, /** "Drive-Einstellungen" (Drive 1.11, the gear of the menu). */ val settings: () -> Unit = {}, + /** The folder the Files page shows now (null: none, or the photos) — a folder page's path goes back to it. */ + val shown: (Long?) -> Unit = {}, ) /** @@ -165,6 +167,9 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel val pager = rememberPagerState(initialPage = DriveLogic.tabOf(DriveViewPref.photos(context), hasPhotos)) { DriveLogic.tabCount(hasPhotos) } val onPhotosPage = DriveLogic.isPhotosTab(pager.currentPage, hasPhotos) val showPage: (Boolean) -> Unit = { photos -> scope.launch { pager.animateScrollToPage(DriveLogic.tabOf(photos, hasPhotos)) } } + // the folder on the Files page: the path of a folder page goes back here when it names this one + val shownId = shown?.id?.takeIf { !onPhotosPage } + LaunchedEffect(shownId) { nav.shown(shownId) } // the page the person ends on is kept, and shown again when the person comes back to the tab var synced by remember { mutableStateOf(false) } LaunchedEffect(hasPhotos) { diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveMoreScreens.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveMoreScreens.kt index 45975b61..7a71e897 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveMoreScreens.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveMoreScreens.kt @@ -171,7 +171,7 @@ fun DriveSearchScreen(onBack: () -> Unit, onFolder: (Long) -> Unit, viewModel: D label = stringResource(R.string.drive_search_hint), ) } - OpCards(running, busy, onCancel = { id -> scope.launch { viewModel.ops.cancel(id) } }, onCancelBusy = viewModel.bulk::cancelBusy) + BulkBusyCard(busy, viewModel.bulk::cancelBusy) when (val s = state) { null -> if (q.isNotBlank()) Text(stringResource(R.string.drive_search_min), color = GateControlTheme.extraColors.muted) DriveLoad.Loading -> DriveLoading() @@ -220,7 +220,15 @@ fun DriveSearchScreen(onBack: () -> Unit, onFolder: (Long) -> Unit, viewModel: D ) } } - SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding().padding(start = 16.dp, end = 16.dp, bottom = if (selecting) 88.dp else 16.dp)) + // jobs on several items: the ring of the round button (a tap shows them one by one) + if (!selecting) { + JobsFab( + jobs = running, + onCancel = { id -> scope.launch { viewModel.ops.cancel(id) } }, + modifier = Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = 16.dp), + ) + } + SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding().padding(start = 16.dp, end = 16.dp, bottom = if (selecting) 88.dp else if (running.isNotEmpty()) 84.dp else 16.dp)) } val moreSheet: @Composable (List, Boolean, () -> Unit) -> Unit = { ns, fromRow, close -> diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt index 3029fff1..576fc10f 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotosUi.kt @@ -225,9 +225,9 @@ internal fun DrivePhotosContent(viewModel: DrivePhotosViewModel, topBar: @Compos } } } - if (running.isNotEmpty() || busy != null) { + if (busy != null) { Column(Modifier.padding(horizontal = 16.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - OpCards(running, busy, onCancel = { id -> scope.launch { viewModel.ops.cancel(id) } }, onCancelBusy = viewModel.bulk::cancelBusy) + BulkBusyCard(busy, viewModel.bulk::cancelBusy) } } Box(Modifier.weight(1f)) { @@ -264,7 +264,15 @@ internal fun DrivePhotosContent(viewModel: DrivePhotosViewModel, topBar: @Compos ) } } - SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding().padding(start = 16.dp, end = 16.dp, bottom = if (selecting) 88.dp else 16.dp)) + // jobs on several items: the ring of the round button (a tap shows them one by one) + if (!selecting) { + JobsFab( + jobs = running, + onCancel = { id -> scope.launch { viewModel.ops.cancel(id) } }, + modifier = Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = 16.dp), + ) + } + SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding().padding(start = 16.dp, end = 16.dp, bottom = if (selecting) 88.dp else if (running.isNotEmpty()) 84.dp else 16.dp)) } DriveBulkHost(viewModel.bulk, snackbar) diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt index 8f302661..a8a93af8 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt @@ -658,7 +658,7 @@ fun DriveVaultScreen(onBack: () -> Unit, onFolder: (vaultId: String, folderId: L Icon(GcIcons.Lock, contentDescription = null, tint = GateControlTheme.extraColors.muted, modifier = Modifier.padding(end = 8.dp)) Text(stringResource(R.string.drive_vault_note), style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.muted) } - OpCards(running, bulkBusy, onCancel = { id -> scope.launch { viewModel.ops.cancel(id) } }, onCancelBusy = viewModel.bulk::cancelBusy) + BulkBusyCard(bulkBusy, viewModel.bulk::cancelBusy) upload?.let { (name, p) -> GcCard { Text(stringResource(R.string.drive_vault_uploading, name), style = MaterialTheme.typography.bodyMedium) @@ -711,11 +711,10 @@ fun DriveVaultScreen(onBack: () -> Unit, onFolder: (vaultId: String, folderId: L } } if (ready != null && !selecting) { - DriveFab( - uploads = emptyList(), + JobsFab( + jobs = running, + onCancel = { id -> scope.launch { viewModel.ops.cancel(id) } }, onAdd = if (ready.folder.writable && upload == null) ({ addOpen = true }) else null, - onOpenUploads = {}, - onAllDone = {}, modifier = Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = 16.dp), ) } diff --git a/app/src/main/res/values-de/strings_drive7.xml b/app/src/main/res/values-de/strings_drive7.xml index 54fa784b..d2975e27 100644 --- a/app/src/main/res/values-de/strings_drive7.xml +++ b/app/src/main/res/values-de/strings_drive7.xml @@ -184,4 +184,18 @@ %1$d Element in den Papierkorb legen? %1$d Elemente in den Papierkorb legen? + Aufträge + Uploads und Aufträge + läuft … + abgebrochen + Verschlüsselte Datei + Verschlüsselter Ordner + + %1$d Auftrag läuft + %1$d Aufträge laufen + + + und %1$d weiteres + und %1$d weitere + diff --git a/app/src/main/res/values/strings_drive7.xml b/app/src/main/res/values/strings_drive7.xml index cd0c5bf6..b6626aac 100644 --- a/app/src/main/res/values/strings_drive7.xml +++ b/app/src/main/res/values/strings_drive7.xml @@ -184,4 +184,18 @@ Move %1$d item to the trash? Move %1$d items to the trash? + Jobs + Uploads and jobs + in progress … + cancelled + Encrypted file + Encrypted folder + + %1$d job running + %1$d jobs running + + + and %1$d more + and %1$d more + diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveBulkTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveBulkTest.kt index 8329891c..f0ae6afb 100644 --- a/app/src/test/java/com/gatecontrol/android/drive/DriveBulkTest.kt +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveBulkTest.kt @@ -362,4 +362,64 @@ class DriveBulkTest { assertEquals("b", DriveBulk.lastPart("/a/b/")) assertNull(DriveBulk.lastPart(null)) } + + // ─── the items of a job (Drive 1.12.1) ────────────────────────── + + private fun file(seq: Int, state: String, size: Long = 100, bytes: Long? = null) = + DOpFile(seq = seq, nodeId = seq.toLong(), name = "f$seq", size = size, isDir = false, state = state, bytesDone = bytes) + + private fun op(state: String = "running", total: Int = 5, done: Int = 0, bytesTotal: Long = 0, bytesDone: Long = 0, transfersLeft: Int = 0) = + DOp(id = "o".repeat(22), kind = "move", state = state, total = total, done = done, bytesTotal = bytesTotal, bytesDone = bytesDone, transfersLeft = transfersLeft) + + @Test + fun `an item's bar - full when ended, without a measure while it runs on the server`() { + assertEquals(0f, DriveBulk.fileProgress(file(1, "waiting"))) + assertNull(DriveBulk.fileProgress(file(1, "running"))) + assertEquals(0.5f, DriveBulk.fileProgress(file(1, "running", bytes = 50))) + assertEquals(1f, DriveBulk.fileProgress(file(1, "done"))) + assertEquals(1f, DriveBulk.fileProgress(file(1, "failed"))) + } + + @Test + fun `the items of the live event go over the known ones by their place`() { + val known = listOf(file(1, "done"), file(2, "running"), file(3, "waiting")) + val merged = DriveBulk.mergeFiles(known, listOf(file(2, "done"), file(3, "running")))!! + assertEquals(listOf("done", "done", "running"), merged.map { it.state }) + assertEquals(known, DriveBulk.mergeFiles(known, null), "an event without items keeps them") + // at most max: the first done ones go + val many = DriveBulk.mergeFiles((1..4).map { file(it, "done") }, listOf(file(5, "running"), file(6, "waiting")), max = 3)!! + assertEquals(listOf(4, 5, 6), many.map { it.seq }) + } + + @Test + fun `the sheet shows a window from a few before the item at work`() { + val files = (1..300).map { file(it, if (it < 150) "done" else if (it == 150) "running" else "waiting") } + val shown = DriveBulk.visibleFiles(files) + assertEquals(DriveBulk.FILES_SHOWN, shown.size) + assertEquals(150 - DriveBulk.FILES_BEFORE, shown.first().seq) + assertEquals(files.takeLast(DriveBulk.FILES_SHOWN).map { it.seq }, DriveBulk.visibleFiles(files.map { it.copy(state = "done") }).map { it.seq }) + assertEquals(3, DriveBulk.visibleFiles(files.take(3)).size) + } + + @Test + fun `a job with all files done that does not change has ended (Drive 1_12_0 left it running)`() { + assertTrue(DriveBulk.stalledDone(op(total = 5, done = 5), DriveBulk.STALL_MS)) + assertFalse(DriveBulk.stalledDone(op(total = 5, done = 5), DriveBulk.STALL_MS - 1)) + assertFalse(DriveBulk.stalledDone(op(total = 5, done = 4), DriveBulk.STALL_MS * 10)) + assertFalse(DriveBulk.stalledDone(op(total = 5, done = 5, transfersLeft = 2), DriveBulk.STALL_MS * 10), "a copy waiting for a device") + assertFalse(DriveBulk.stalledDone(op(state = "done", total = 5, done = 5), DriveBulk.STALL_MS * 10)) + assertFalse(DriveBulk.stalledDone(op(total = 0, done = 0), DriveBulk.STALL_MS * 10)) + } + + @Test + fun `the round button's ring - uploads and jobs together by their bytes`() { + assertNull(DriveBulk.ring(emptyList(), emptyList())) + assertNull(DriveBulk.ring(listOf(0L to 0L), emptyList()), "an upload of an unknown size") + assertEquals(0.5f, DriveBulk.ring(listOf(50L to 100L), emptyList())) + assertEquals(0.25f, DriveBulk.ring(emptyList(), listOf(op(bytesTotal = 400, bytesDone = 100)))) + // 100 of 100 uploaded, a job of 300 bytes half done: 250 of 400 + assertEquals(0.625f, DriveBulk.ring(listOf(100L to 100L), listOf(op(bytesTotal = 300, bytesDone = 150)))) + // a job without bytes counts by its files + assertEquals(0.4f, DriveBulk.ring(emptyList(), listOf(op(total = 5, done = 2)))) + } } diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveJsonBulkTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveJsonBulkTest.kt index 465cd97d..524fbb33 100644 --- a/app/src/test/java/com/gatecontrol/android/drive/DriveJsonBulkTest.kt +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveJsonBulkTest.kt @@ -183,4 +183,28 @@ class DriveJsonBulkTest { assertNull(DriveJson.zipFetch("not a url", "/s/tok/zip")) assertNull(DriveJson.zipFetch("https://gc.home.lan", "s/tok zip")) } + + @Test + fun `a job of Drive 1_12_1 with its files one by one`() { + val op = DriveJson.opAnswer( + json( + """{"op":{"id":"${"b".repeat(22)}","kind":"move","state":"running","items":3,"total":3,"done":1,"bytesTotal":30,"bytesDone":10,"filesMore":7, + "files":[{"seq":1,"nodeId":5,"name":"a.pdf","size":10,"isDir":false,"state":"done","bytesDone":10}, + {"seq":2,"nodeId":6,"name":"Fotos","size":20,"isDir":true,"state":"running"}, + {"seq":3,"nodeId":7,"name":null,"size":0,"isDir":false,"state":"failed","reason":"into_itself","encrypted":true}, + {"seq":4,"nodeId":8,"name":"x","state":"flying"},{"nodeId":9,"name":"y","state":"done"}]}}""", + ), + )!! + val f = op.files!! + assertEquals(listOf(1, 2, 3), f.map { it.seq }) + assertEquals(10L, f[0].bytesDone) + assertTrue(f[1].isDir) + assertNull(f[1].bytesDone, "running without a measure") + assertEquals("into_itself", f[2].reason) + assertTrue(f[2].encrypted) + assertNull(f[2].name) + assertEquals(7, op.filesMore) + // Drive 1.12.0: no files + assertNull(DriveJson.op(json("""{"id":"u","kind":"move","state":"running"}"""))!!.files) + } } diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveLogicTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveLogicTest.kt index 43020ac2..45b3de81 100644 --- a/app/src/test/java/com/gatecontrol/android/drive/DriveLogicTest.kt +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveLogicTest.kt @@ -211,4 +211,15 @@ class DriveLogicTest { // a clock that went back never waits longer than one gap assertEquals(DriveLogic.LIVE_GAP_MS, DriveLogic.liveDelay(10_000L, 1_000L)) } + + @Test + fun `the path of a folder page goes back to the page that shows that folder, else a new page`() { + // the Drive tab shows "Meine Dateien" (1), then the pages FEC (7) and Unter (9) + val stack = listOf(null, 1L, 7L, 9L) + assertEquals(2, DriveLogic.crumbPops(stack, 1L), "Meine Dateien: back to the tab") + assertEquals(1, DriveLogic.crumbPops(stack, 7L)) + assertEquals(0, DriveLogic.crumbPops(stack, 9L)) + assertNull(DriveLogic.crumbPops(stack, 3L), "a folder no page shows: a new page") + assertEquals(0, DriveLogic.crumbPops(listOf(1L, 7L, 1L), 1L), "the nearest one") + } } diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveOpsTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveOpsTest.kt new file mode 100644 index 00000000..1da458e6 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveOpsTest.kt @@ -0,0 +1,128 @@ +package com.gatecontrol.android.drive + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The end of a job on several items (Drive 1.12): told by the live event `op`, else found by asking; a late answer or + * event never makes an ended job run again; a job Drive 1.12.0 left running with all its files done ends here. + */ +class DriveOpsTest { + + private val id = "j".repeat(22) + private val events = MutableSharedFlow(extraBufferCapacity = 16) + private val api = mockk() + private val live = mockk(relaxed = true).also { every { it.ops } returns events } + + private fun op(state: String, done: Int, files: List? = null, undoUntil: String? = null) = DOp( + id = id, kind = "move", state = state, total = 5, done = done, bytesTotal = 9_600_000, bytesDone = done * 1_920_000L, + items = 5, label = "PXL_20261020.jpg", targetName = "FEC", files = files, undoUntil = undoUntil, + ) + + private fun file(seq: Int, state: String) = DOpFile(seq = seq, nodeId = seq.toLong(), name = "f$seq", size = 10, isDir = false, state = state) + + private fun TestScope.ops(scope: CoroutineScope = backgroundScope) = DriveOps(api, live, scope) { testScheduler.currentTime } + + @Test + fun `the last live event ends the job`() = runTest { + coEvery { api.startOp("move", any(), 7L, null) } returns DResult.Ok(op("running", 2)) + coEvery { api.op(id) } returns DResult.Ok(op("running", 2)) + val ops = ops() + runCurrent() + ops.start("move", listOf(1, 2, 3, 4, 5), 7L, null, "PXL_20261020.jpg", "FEC") + val end = async { ops.await(id) } + runCurrent() + assertEquals(1, ops.running.value.size) + events.emit(op("done", 5)) + runCurrent() + assertEquals("done", end.await().op.state) + assertTrue(ops.running.value.isEmpty(), "the ring becomes the plus again") + } + + @Test + fun `without events the app asks and finds the end`() = runTest { + coEvery { api.startOp(any(), any(), any(), any()) } returns DResult.Ok(op("running", 2)) + var asked = 0 + coEvery { api.op(id) } answers { asked++; DResult.Ok(if (asked < 2) op("running", 4) else op("done", 5)) } + val ops = ops() + ops.start("move", listOf(1, 2, 3, 4, 5), 7L, null, null, null) + val end = async { ops.await(id) } + advanceTimeBy(DriveOps.POLL_MS * 3 + 1) + assertEquals("done", end.await().op.state) + assertEquals(5, end.await().op.done) + } + + @Test + fun `Drive 1_12_0 left the job running with 5 of 5 files - the app stops it and shows it as done`() = runTest { + // the server's answer: running, done == total — for good + coEvery { api.startOp(any(), any(), any(), any()) } returns DResult.Ok(op("running", 5)) + coEvery { api.op(id) } returns DResult.Ok(op("running", 5)) + coEvery { api.cancelOp(id) } returns DResult.Ok(op("cancelled", 5, undoUntil = "2026-10-29T12:15:00Z")) + val ops = ops() + ops.start("move", listOf(1, 2, 3, 4, 5), 7L, null, null, "FEC") + val end = async { ops.await(id) } + advanceTimeBy(DriveBulk.STALL_MS - DriveOps.POLL_MS) + assertFalse(end.isCompleted, "not before it stood still a while") + advanceTimeBy(DriveBulk.STALL_MS + DriveOps.POLL_MS * 2) + runCurrent() + val job = end.await() + assertEquals("done", job.op.state) + assertEquals("2026-10-29T12:15:00Z", job.op.undoUntil, "Rückgängig works") + coVerify(exactly = 1) { api.cancelOp(id) } + assertTrue(ops.running.value.isEmpty()) + } + + @Test + fun `the end told before the answer of the start stays the end`() = runTest { + val ops = ops() + runCurrent() + coEvery { api.startOp(any(), any(), any(), any()) } coAnswers { + // the job ran to its end while the answer was on its way + events.emit(op("done", 5)) + yield() + DResult.Ok(op("running", 4)) + } + ops.start("move", listOf(1, 2, 3, 4, 5), 7L, null, null, null) + runCurrent() + assertEquals("done", ops.jobs.value.getValue(id).op.state) + assertTrue(ops.running.value.isEmpty()) + // a late event of before does not bring it back + events.emit(op("running", 4)) + runCurrent() + assertEquals("done", ops.jobs.value.getValue(id).op.state) + } + + @Test + fun `the items of the live event are merged with those asked for`() = runTest { + coEvery { api.startOp(any(), any(), any(), any()) } returns DResult.Ok(op("running", 1)) + coEvery { api.op(id) } returns DResult.Ok(op("running", 1, files = (1..5).map { file(it, if (it == 1) "done" else if (it == 2) "running" else "waiting") })) + val ops = ops() + runCurrent() + ops.start("move", listOf(1, 2, 3, 4, 5), 7L, null, null, null) + advanceTimeBy(DriveOps.POLL_MS + 1) + runCurrent() + assertEquals(5, ops.running.value.single().op.files!!.size) + events.emit(op("running", 3, files = listOf(file(2, "done"), file(3, "done"), file(4, "running")))) + runCurrent() + val files = ops.running.value.single().op.files!! + assertEquals(listOf("done", "done", "done", "running", "waiting"), files.map { it.state }) + // an event without items (Drive 1.12.0) keeps them + events.emit(op("running", 4)) + runCurrent() + assertEquals(5, ops.running.value.single().op.files!!.size) + } +} From 30f404359f35a3768d24e8638184107bfd2a5e72 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:20:00 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Drive:=20Pfad=20ohne=20eingeschr=C3=A4nkte?= =?UTF-8?q?=20Navigations-API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit currentBackStack ist RestrictedApi (Lint): die Seite mit dem Ordner wird über getBackStackEntry(Route) gesucht und mit popBackStack(Route) angesprungen; die Entscheidung in DriveLogic.crumbStep (mit Test). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../gatecontrol/android/drive/DriveLogic.kt | 16 ++++++---- .../android/navigation/AppNavigation.kt | 31 ++++++++++++------- .../android/drive/DriveLogicTest.kt | 12 +++---- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt index c24d901d..fc6f92c2 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt @@ -167,14 +167,18 @@ internal object DriveLogic { /** At most one live reload of a folder this often. */ const val LIVE_GAP_MS = 2_000L + /** What a tap on the path of a folder's page does (see [crumbStep]). */ + enum class CrumbStep { STAY, BACK, TAB, NEW } + /** - * A tap on the path of a folder's page: how many pages to go back to the one that shows folder [id] already (the - * nearest; 0: the page on top shows it), or null to open it as a new page. [shown]: the folder of each page on the - * back stack, the top last (null: a page that shows no folder). + * A tap on folder [id] in the path of a folder's page: nothing when the page on top shows it ([top]); back to the + * folder page that shows it ([onStack]); back to the Drive tab when its Files page shows it ([tab]); else a new page. */ - fun crumbPops(shown: List, id: Long): Int? { - val i = shown.lastIndexOf(id) - return if (i < 0) null else shown.size - 1 - i + fun crumbStep(top: Long?, id: Long, onStack: Boolean, tab: Long?): CrumbStep = when { + top == id -> CrumbStep.STAY + onStack -> CrumbStep.BACK + tab == id -> CrumbStep.TAB + else -> CrumbStep.NEW } private val SCAN_EXT = Regex("\\.(pdf|jpe?g)$", RegexOption.IGNORE_CASE) diff --git a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt index c5a07678..6dc31dbc 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -1494,25 +1494,32 @@ private const val DRIVE_TAB_SHOWN = "drive_shown" private fun NavGraphBuilder.driveRoutes(navController: NavHostController, navigateToTab: (String) -> Unit) { val back: () -> Unit = { if (!navController.popBackStack()) navigateToTab(Screen.Drive.route) } val go: (String) -> Unit = { route -> navController.navigate(route) { launchSingleTop = true } } - /** The folder shown by each page on the back stack, the top last (null: no folder page). */ - val shownFolders: () -> List = { - navController.currentBackStack.value.map { e -> - when (e.destination.route) { - Screen.DriveFolder.route -> e.arguments?.getString(Screen.DriveFolder.ARG_ID)?.toLongOrNull() - Screen.Drive.route -> e.savedStateHandle.get(DRIVE_TAB_SHOWN) - else -> null - } - } + /** The folder the page on top shows (null: no folder page). */ + val topFolder: () -> Long? = { + navController.currentBackStackEntry?.takeIf { it.destination.route == Screen.DriveFolder.route } + ?.arguments?.getString(Screen.DriveFolder.ARG_ID)?.toLongOrNull() } // A folder is a page of its own. Not "launchSingleTop": a folder page on top of a folder page is the same // destination, the navigation would only give the page on top the new id (its view model keeps showing its folder). val folder: (Long) -> Unit = { id -> - if (shownFolders().lastOrNull() != id) navController.navigate(Screen.DriveFolder.create(id)) + if (topFolder() != id) navController.navigate(Screen.DriveFolder.create(id)) } // The path above a folder: back to the page that shows that folder already (the Drive tab too), else a new page. val crumb: (Long) -> Unit = { id -> - val pops = com.gatecontrol.android.drive.DriveLogic.crumbPops(shownFolders(), id) - if (pops == null) navController.navigate(Screen.DriveFolder.create(id)) else repeat(pops) { navController.popBackStack() } + val page = Screen.DriveFolder.create(id) + val tab = runCatching { navController.getBackStackEntry(Screen.Drive.route) }.getOrNull() + val step = com.gatecontrol.android.drive.DriveLogic.crumbStep( + top = topFolder(), + id = id, + onStack = runCatching { navController.getBackStackEntry(page) }.isSuccess, + tab = tab?.savedStateHandle?.get(DRIVE_TAB_SHOWN), + ) + when (step) { + com.gatecontrol.android.drive.DriveLogic.CrumbStep.STAY -> Unit + com.gatecontrol.android.drive.DriveLogic.CrumbStep.BACK -> navController.popBackStack(page, inclusive = false) + com.gatecontrol.android.drive.DriveLogic.CrumbStep.TAB -> navController.popBackStack(Screen.Drive.route, inclusive = false) + com.gatecontrol.android.drive.DriveLogic.CrumbStep.NEW -> navController.navigate(page) + } } val file: (Long) -> Unit = { go(Screen.DriveFile.create(it)) } val vault: (String, Long) -> Unit = { v, f -> navController.navigate(Screen.DriveVault.create(v, f)) } diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveLogicTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveLogicTest.kt index 45b3de81..958b8a8b 100644 --- a/app/src/test/java/com/gatecontrol/android/drive/DriveLogicTest.kt +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveLogicTest.kt @@ -215,11 +215,11 @@ class DriveLogicTest { @Test fun `the path of a folder page goes back to the page that shows that folder, else a new page`() { // the Drive tab shows "Meine Dateien" (1), then the pages FEC (7) and Unter (9) - val stack = listOf(null, 1L, 7L, 9L) - assertEquals(2, DriveLogic.crumbPops(stack, 1L), "Meine Dateien: back to the tab") - assertEquals(1, DriveLogic.crumbPops(stack, 7L)) - assertEquals(0, DriveLogic.crumbPops(stack, 9L)) - assertNull(DriveLogic.crumbPops(stack, 3L), "a folder no page shows: a new page") - assertEquals(0, DriveLogic.crumbPops(listOf(1L, 7L, 1L), 1L), "the nearest one") + assertEquals(DriveLogic.CrumbStep.TAB, DriveLogic.crumbStep(top = 9, id = 1, onStack = false, tab = 1), "Meine Dateien: back to the tab") + assertEquals(DriveLogic.CrumbStep.BACK, DriveLogic.crumbStep(top = 9, id = 7, onStack = true, tab = 1)) + assertEquals(DriveLogic.CrumbStep.STAY, DriveLogic.crumbStep(top = 9, id = 9, onStack = true, tab = 1)) + // the tab shows another area or the photos: a page of its own + assertEquals(DriveLogic.CrumbStep.NEW, DriveLogic.crumbStep(top = 9, id = 1, onStack = false, tab = null)) + assertEquals(DriveLogic.CrumbStep.NEW, DriveLogic.crumbStep(top = 9, id = 3, onStack = false, tab = 1)) } }