Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<DOp> = 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<DOp> = 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<List<DOp>> = get("ops", mapOf("state" to "running"), DriveJson::ops)
suspend fun runningOps(): DResult<List<DOp>> = 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<DOp> = del("ops/" + (DriveJson.opId(id) ?: return bad()), DriveJson::opAnswer)
Expand Down
75 changes: 75 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DriveBulk.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<DOpFile>, max: Int = FILES_SHOWN): List<DOpFile> {
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<DOpFile>?, new: List<DOpFile>?, max: Int = FILES_KEPT): List<DOpFile>? {
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<DOpFile>(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<Pair<Long, Long>>, jobs: List<DOp>): 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
Expand Down
22 changes: 22 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}

Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,20 @@ 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 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 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)

// ─── when a photo was taken (Drive 1.7, feature `takenAt`) ──────
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<DOpFile>? = 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?)
75 changes: 62 additions & 13 deletions app/src/main/java/com/gatecontrol/android/drive/DriveOps.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Map<String, Job>>(emptyMap())
val jobs: StateFlow<Map<String, Job>> = _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<List<Job>> = _jobs.map { m -> m.values.filter { it.op.running } }
.stateIn(scope, SharingStarted.Eagerly, emptyList())

Expand All @@ -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)
}
}
Expand All @@ -72,31 +80,72 @@ 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))))
}
}

/** Starts [kind] for [ids] (to [target]); a running job is followed until it ends. */
suspend fun start(kind: String, ids: List<Long>, target: Long?, onConflict: String?, title: String?, targetName: String?): DResult<DOp> {
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1484,21 +1484,51 @@ 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.
*/
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 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 (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 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<Long>(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)) }
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) },
Expand All @@ -1513,7 +1543,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) }
Expand Down
Loading
Loading