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
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,22 @@ object GateDropJson {
nas = nas,
linksEnabled = links?.flag("enabled") ?: (root.flag("linksEnabled") == true),
drive = root.flag("drive") == true,
retention = retention(root.obj("limits")),
)
}

/**
* `limits.retention { afterPickupDays, unpickedDays }` (GateDrop 1.5) or `limits.retentionDays`; null when the
* server names neither. Days outside 0…365 are ignored.
*/
fun retention(limits: JsonObject?): GdRetention? {
if (limits == null) return null
fun days(v: Long?) = v?.takeIf { it in 0..365 }?.toInt()
val r = limits.obj("retention")
val unpicked = days(r?.long("unpickedDays"))?.takeIf { it > 0 } ?: days(limits.long("retentionDays"))?.takeIf { it > 0 } ?: return null
return GdRetention(unpickedDays = unpicked, afterPickupDays = days(r?.long("afterPickupDays")))
}

fun person(o: JsonObject?): GdPerson? {
if (o == null) return null
val kind = when (o.str("kind")?.lowercase()) {
Expand Down Expand Up @@ -346,6 +359,9 @@ object GateDropJson {
status = o.str("status")?.lowercase()?.takeIf { it.length <= 20 } ?: GdDelivery.PENDING,
via = o.str("via")?.lowercase()?.takeIf { it == "direct" || it == "server" },
error = line(o.str("error"), 120),
pickedAt = o.time("pickedAt"),
// `pickedAt: null` counts too: the server knows pick-ups, this one is not picked up yet
knowsPickup = o.has("pickedAt"),
)
}

Expand All @@ -372,6 +388,8 @@ object GateDropJson {
fun transferAnswer(o: JsonObject?): GdTransferAnswer = GdTransferAnswer(
fallback = fallbackOf(o),
missing = o?.arr("missing")?.mapNotNull { e -> e.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }?.asInt?.takeIf { it in 0 until MAX_ITEMS } }?.distinct()?.sorted(),
// `{ id, kind, target, status, via, error, at, pickedAt }` (pickedAt since GateDrop 1.5)
delivery = delivery(o?.obj("delivery")),
)

fun item(o: JsonObject?): GdItem? {
Expand All @@ -386,6 +404,8 @@ object GateDropJson {
bytes = o.long("bytes") ?: o.long("size") ?: 0L,
available = o.flag("available") != false,
scan = MediaScanJson.of(o),
index = o.long("index")?.toInt()?.takeIf { it in 0 until MAX_ITEMS },
local = o.flag("local") == true,
)
}

Expand Down Expand Up @@ -414,7 +434,9 @@ object GateDropJson {
items = root.arr("items")?.mapNotNull { item(it.objOrNull()) }?.take(MAX_ITEMS * 5).orEmpty(),
deliveries = root.arr("deliveries")?.mapNotNull { delivery(it.objOrNull()) }?.take(MAX_LIST).orEmpty(),
opened = root.flag("opened") == true || root.str("status") == GdDelivery.OPENED,
expired = root.flag("expired") == true,
expired = root.flag("expired") == true || root.flag("removed") == true,
removed = root.flag("removed") == true,
pickedAt = root.time("pickedAt"),
)
}

Expand Down
124 changes: 124 additions & 0 deletions app/src/main/java/com/gatecontrol/android/gatedrop/GateDropLogic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,44 @@ data class GdSendPlan(
val localItems: Boolean,
)

/**
* How a sent send (or one of its deliveries) reads in "Gesendet"; the order is the order of importance — the first
* state any delivery has wins, so a problem or a waiting device stays visible.
*/
enum class GdSentStatus {
FAILED,
/** The receiving device of a direct transfer did not get every file (GateDrop 1.5 `incomplete`). */
INCOMPLETE,
/** An administrator removed the files before it was picked up (GateDrop 1.5) — instead of "abgelaufen". */
REMOVED,
EXPIRED,
WAITING,
PENDING,
DIRECT,
/** `delivered` but not picked up yet (GateDrop 1.5 names `pickedAt`): "bereitgestellt". */
PROVIDED,
/** `delivered` of a server without pick-ups (GateDrop < 1.5), a NAS folder, Drive or a link: "zugestellt". */
DELIVERED,
OPENED,
/** The recipient confirmed it has the send (GateDrop 1.5 `pickedAt`): "abgeholt". */
PICKED,
}

/** How the receiving end reports a direct transfer (`POST transfers/<id>/result`). */
data class GdDirectReport(val ok: Boolean, val bytes: Long?, val error: String?)

/** What a sender does about a direct transfer it reported `ok` (GateDrop 1.5: only the receiver's `ok` counts). */
sealed interface GdPickupCheck {
/** Picked up, on the server way, ended, or a server without pick-ups: nothing more to do. */
data object Done : GdPickupCheck

/** The receiving device has not confirmed yet. */
data object Wait : GdPickupCheck

/** The receiver did not get everything and the delivery waits for the files (`pending` + `direct_failed`). */
data class Upload(val missing: List<Int>) : GdPickupCheck
}

/**
* Decisions of GateDrop that do not need Android: which targets are
* shown and can be sent to, how the files travel, when the server path
Expand Down Expand Up @@ -181,6 +219,92 @@ object GateDropLogic {
return deliveries.map { it.status }.minByOrNull { s -> order.indexOf(s).let { if (it < 0) order.size else it } }
}

/** One delivery as "Gesendet" shows it; [sendRemoved]: an administrator removed the send's files (GateDrop 1.5). */
fun sentStatus(d: GdDelivery, sendRemoved: Boolean = false): GdSentStatus? = when (d.status) {
GdDelivery.FAILED -> if (d.error == GdDelivery.ERROR_INCOMPLETE) GdSentStatus.INCOMPLETE else GdSentStatus.FAILED
GdDelivery.EXPIRED -> if (d.error == GdDelivery.ERROR_REMOVED || sendRemoved) GdSentStatus.REMOVED else GdSentStatus.EXPIRED
GdDelivery.WAITING -> GdSentStatus.WAITING
GdDelivery.PENDING -> if (d.error == GdDelivery.ERROR_INCOMPLETE) GdSentStatus.INCOMPLETE else GdSentStatus.PENDING
GdDelivery.DIRECT -> GdSentStatus.DIRECT
GdDelivery.DELIVERED -> when {
// saved into the folder / Drive, a link made: delivered is all there is
d.kind == KIND_NAS || d.kind == KIND_DRIVE || d.kind == KIND_LINK -> GdSentStatus.DELIVERED
// a server without pick-ups (GateDrop < 1.5) says nothing more: as before
!d.knowsPickup -> GdSentStatus.DELIVERED
d.pickedAt != null -> GdSentStatus.PICKED
else -> GdSentStatus.PROVIDED
}
GdDelivery.OPENED -> GdSentStatus.OPENED
else -> null
}

/** A sent entry as "Gesendet" shows it: the most important state of its deliveries ([GdSentStatus] order). */
fun sentStatus(e: GdEntry): GdSentStatus? =
e.deliveries.mapNotNull { sentStatus(it, e.removed) }.minByOrNull { it.ordinal }
?: GdSentStatus.REMOVED.takeIf { e.removed }

/** The send is gone from the server (`410 gone`: expired, or GateDrop 1.5: removed by an administrator). */
fun isGone(code: String?): Boolean = code == "gone"

/** How long received files stay on the server: the server's `limits`, else 7 days as always. */
fun retention(targets: GdTargets?): GdRetention = targets?.retention ?: GdRetention()

/**
* The receiving end's report of a direct transfer: `ok` only when every file of [items] arrived and was saved
* ([saved]: the saved copy per item, null = not saved) — then always with `bytes`, the files' plain size in all
* (GateDrop 1.5 counts fewer bytes as `incomplete`).
*/
fun directReport(items: List<GdOfferItem>, outcome: GdDirectOutcome, saved: List<String?>): GdDirectReport {
val total = items.sumOf { it.bytes }
return when {
outcome is GdDirectOutcome.Failed -> GdDirectReport(false, null, outcome.reason)
outcome !is GdDirectOutcome.Done || outcome.bytes != total || saved.size != items.size -> GdDirectReport(false, null, GdDelivery.ERROR_INCOMPLETE)
saved.any { it == null } -> GdDirectReport(false, null, "save_failed")
else -> GdDirectReport(true, total, null)
}
}

/**
* After this device reported a direct transfer `ok`: does the answer ([GdTransferAnswer.delivery]) leave the
* pick-up open — not confirmed yet, or the receiver failed before (`pending` + `direct_failed`, the answer to an
* `ok` names no `fallback`)? Only a GateDrop 1.5 server says so (`pickedAt` named); older ones: as before.
*/
fun awaitsPickup(answer: GdTransferAnswer?): Boolean {
val d = answer?.delivery ?: return false
if (serverWantsUpload(answer.fallback) || !d.knowsPickup || d.pickedAt != null) return false
return when (d.status) {
GdDelivery.DIRECT -> true
GdDelivery.DELIVERED -> d.via != "server"
GdDelivery.PENDING -> d.error == GdDelivery.ERROR_DIRECT_FAILED
else -> false
}
}

/**
* Where the delivery of [send] to the device [deviceId] stands after this device's `ok` (`GET sends/<id>`): picked
* up, or the receiver did not get everything and the delivery waits for the files still on this device
* (`pending` + `direct_failed`, the `local` items) — then they go up and `POST sends/<id>/items`.
*/
fun pickupCheck(send: GdEntry, deviceId: String): GdPickupCheck {
if (send.removed || send.expired) return GdPickupCheck.Done
val d = send.deliveries.firstOrNull { it.kind == KIND_DEVICE && it.target == deviceId } ?: return GdPickupCheck.Done
if (d.status == GdDelivery.PENDING && d.error == GdDelivery.ERROR_DIRECT_FAILED) {
val missing = send.items.withIndex().filter { it.value.local }.map { it.value.index ?: it.index }.distinct().sorted()
return if (missing.isEmpty()) GdPickupCheck.Done else GdPickupCheck.Upload(missing)
}
return when {
!d.knowsPickup -> GdPickupCheck.Done
d.pickedAt != null || d.status == GdDelivery.OPENED -> GdPickupCheck.Done
d.status == GdDelivery.DIRECT || (d.status == GdDelivery.DELIVERED && d.via != "server") -> GdPickupCheck.Wait
else -> GdPickupCheck.Done
}
}

private const val KIND_DEVICE = "device"
private const val KIND_NAS = "nas"
private const val KIND_DRIVE = "drive"
private const val KIND_LINK = "link"

/**
* Why a delivery failed because of the virus scan (GateDrop 1.4: the file was held back before it was handed on —
* `quarantined`, `scan_pending`, `scan_blocked`): the first such code of a failed delivery; null when none failed so.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ data class GdTargets(
val linksEnabled: Boolean = false,
/** "Mein Drive" is offered: Drive is there and the caller has a seat (GateDrop 1.3, `targets.drive`). */
val drive: Boolean = false,
/** How long received files stay on the server (`limits`); null when the server says nothing. */
val retention: GdRetention? = null,
) {
/** Own devices and other people's open ones — both go into `to.devices`. */
val allDevices: List<GdDevice> get() = devices + peopleDevices
Expand All @@ -82,6 +84,18 @@ data class GdTargets(
val linkOffered: Boolean get() = me.canLink && linksEnabled
}

/**
* How long the server keeps received files (`targets.limits`): [unpickedDays] for what is not picked up (counted from
* sending, `limits.retentionDays` / `limits.retention.unpickedDays`), [afterPickupDays] once picked up (GateDrop 1.5,
* 0 = right away; null on older servers, which keep everything [unpickedDays]).
*/
data class GdRetention(val unpickedDays: Int = DEFAULT_DAYS, val afterPickupDays: Int? = null) {
companion object {
/** Before GateDrop 1.5 (and without `limits`): 7 days after sending. */
const val DEFAULT_DAYS = 7
}
}

/** Validity of a link for externals (`to.link.expires`). */
enum class GdExpiry(val wire: String) {
HOUR("1h"), DAY("1d"), WEEK("7d"), ONCE("once");
Expand Down Expand Up @@ -165,6 +179,10 @@ data class GdDelivery(
val status: String,
val via: String? = null,
val error: String? = null,
/** When the recipient confirmed it has the send (GateDrop 1.5); null = not (yet) picked up. */
val pickedAt: Long? = null,
/** The server names `pickedAt` (GateDrop 1.5): `delivered` without it only means provided. */
val knowsPickup: Boolean = false,
) {
companion object {
const val PENDING = "pending"
Expand All @@ -175,6 +193,13 @@ data class GdDelivery(
const val OPENED = "opened"
const val FAILED = "failed"
const val EXPIRED = "expired"

/** `error` of an `expired` delivery whose send an administrator removed before it was picked up (GateDrop 1.5). */
const val ERROR_REMOVED = "removed"
/** `error`: the receiving device of a direct transfer did not get every file (GateDrop 1.5). */
const val ERROR_INCOMPLETE = "incomplete"
/** `error` of a device delivery whose direct transfer failed and that waits for the sender's upload. */
const val ERROR_DIRECT_FAILED = "direct_failed"
}
}

Expand Down Expand Up @@ -232,6 +257,10 @@ data class GdItem(
val available: Boolean = true,
/** The host's virus scan (GateDrop 1.4); null when the server says nothing. Blocked: no download, a mark instead. */
val scan: MediaScan? = null,
/** Position in the send (`index`); null when the server does not name it. */
val index: Int? = null,
/** The file is still only on the sender's device (`local`, a direct transfer): the server needs it uploaded. */
val local: Boolean = false,
)

/** One entry of `GET inbox?kind=received|sent` or `GET sends/<id>`. */
Expand All @@ -248,6 +277,10 @@ data class GdEntry(
val opened: Boolean = false,
/** The send expired: its files are gone from the server. */
val expired: Boolean = false,
/** An administrator removed its files from the server (GateDrop 1.5, "Vom Admin entfernt"). */
val removed: Boolean = false,
/** Every recipient picked it up (GateDrop 1.5). */
val pickedAt: Long? = null,
)

/** One link for externals (`GET inbox?kind=links`). */
Expand Down Expand Up @@ -296,8 +329,11 @@ fun <T> GdResult<T>.valueOrNull(): T? = (this as? GdResult.Ok)?.value
/** One answered part of a resumable upload ([complete] null: the server does not know resumable uploads). */
data class GdUploadPart(val complete: Boolean?, val mediaId: String?, val offset: Long?, val code: String? = null)

/** Answer of `POST transfers/<id>/result`. */
data class GdTransferAnswer(val fallback: String? = null, val missing: List<Int>? = null)
/**
* Answer of `POST transfers/<id>/result`; [delivery] the delivery as it stands now (GateDrop 1.5 adds its `pickedAt`:
* the receiving device confirmed every file).
*/
data class GdTransferAnswer(val fallback: String? = null, val missing: List<Int>? = null, val delivery: GdDelivery? = null)

/**
* Where a direct transfer stands (`GET transfers/<id>`), also once it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,16 @@ class GateDropNotifier @Inject constructor(
)
}

/**
* Why a send failed as a sentence: the virus scan's refusal (GateDrop 1.4), or `410 gone` — the send expired or an
* administrator removed it (GateDrop 1.5) before the files that were to go directly got there; null for any other code.
*/
suspend fun scanReason(code: String?, name: String?): String? {
val ctx = localized()
return scanCodeText(ctx, code, name = name) ?: ctx.getString(R.string.gatedrop_send_gone).takeIf { GateDropLogic.isGone(code) }
}

/** Not sent (no server, refused): tap opens "Gesendet". */
/** The virus scan's refusal of a send as a sentence (GateDrop 1.4), null for any other code. */
suspend fun scanReason(code: String?, name: String?): String? = scanCodeText(localized(), code, name = name)

suspend fun sendFailed(key: String, reason: String?) {
val ctx = localized()
Expand Down
Loading
Loading