diff --git a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropJson.kt b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropJson.kt index 0df89501..9c88bf45 100644 --- a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropJson.kt +++ b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropJson.kt @@ -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()) { @@ -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"), ) } @@ -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? { @@ -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, ) } @@ -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"), ) } diff --git a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropLogic.kt b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropLogic.kt index e02448aa..aa0ec1b7 100644 --- a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropLogic.kt +++ b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropLogic.kt @@ -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//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) : 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 @@ -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, outcome: GdDirectOutcome, saved: List): 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/`): 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//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. diff --git a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropModels.kt b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropModels.kt index 409fcf2c..f9dd082c 100644 --- a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropModels.kt +++ b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropModels.kt @@ -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 get() = devices + peopleDevices @@ -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"); @@ -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" @@ -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" } } @@ -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/`. */ @@ -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`). */ @@ -296,8 +329,11 @@ fun GdResult.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//result`. */ -data class GdTransferAnswer(val fallback: String? = null, val missing: List? = null) +/** + * Answer of `POST transfers//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? = null, val delivery: GdDelivery? = null) /** * Where a direct transfer stands (`GET transfers/`), also once it diff --git a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropNotifier.kt b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropNotifier.kt index 3138c14e..86fcaec9 100644 --- a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropNotifier.kt +++ b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropNotifier.kt @@ -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() diff --git a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropRepository.kt b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropRepository.kt index 0e3c6b67..61bb902a 100644 --- a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropRepository.kt +++ b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropRepository.kt @@ -204,18 +204,27 @@ class GateDropRepository @Inject constructor( // Direct transfers (own devices in the same network). val localFiles = job.files.mapIndexed { i, f -> GdLocalFile(files[i], f.name, f.size) } val results = mutableListOf>() + // GateDrop 1.5: the receiving device confirms the pick-up itself — these devices are asked after this one's ok + val unconfirmed = mutableSetOf() if (localFiles.isNotEmpty()) { for (d in result.direct) { setJobState(job.id, GdJobState.Direct(0)) val outcome = runCatching { transferDirect(d, localFiles) { p -> percent(p, totalBytes).let { onProgress(true, it); setJobState(job.id, GdJobState.Direct(it)) } } } .getOrElse { if (it is CancellationException) throw it else GdDirectOutcome.Failed("error") } val ok = outcome is GdDirectOutcome.Done - val answer = api.transferResult(d.transferId, ok, (outcome as? GdDirectOutcome.Done)?.bytes, (outcome as? GdDirectOutcome.Failed)?.reason) - results += ok to answer.valueOrNull() + val answer = api.transferResult(d.transferId, ok, (outcome as? GdDirectOutcome.Done)?.bytes, (outcome as? GdDirectOutcome.Failed)?.reason).valueOrNull() + results += ok to answer + if (ok && GateDropLogic.awaitsPickup(answer)) unconfirmed += d.deviceId Timber.i("GateDrop: direct transfer %s %s", d.transferId, if (ok) "done" else "failed (${(outcome as? GdDirectOutcome.Failed)?.reason}), server path") } } - val missing = GateDropLogic.missingAfterDirect(localItems, job.files.size, results) + var missing = GateDropLogic.missingAfterDirect(localItems, job.files.size, results) + if (localItems && unconfirmed.isNotEmpty()) { + // not everything arrived (`incomplete`) and the delivery waits for the files: they go up after all + val late = GateDropWait.pickup(unconfirmed) { api.sendDetail(result.id).valueOrNull() }.filter { it in job.files.indices } + if (late.isNotEmpty()) Timber.i("GateDrop: %s not picked up completely, %d file(s) go over the server", result.id, late.size) + missing = (missing + late).distinct().sorted() + } if (missing.isNotEmpty()) { job = job.copy(fallback = true, missing = missing) store.saveJob(job) @@ -317,22 +326,25 @@ class GateDropRepository @Inject constructor( } }.getOrElse { if (it is CancellationException) throw it else GdDirectOutcome.Failed("error") } - if (outcome !is GdDirectOutcome.Done || pendings.size != offer.items.size) { + // Published only when every file arrived intact (else nothing is kept). + val complete = outcome is GdDirectOutcome.Done && pendings.size == offer.items.size + val uris = if (complete) offer.items.indices.map { pendings[it]?.publish()?.toString() } else emptyList() + // ok only with every file saved, and then with every byte (GateDrop 1.5: fewer bytes = `incomplete`) + val report = GateDropLogic.directReport(offer.items, outcome, uris) + if (!report.ok) { pendings.values.forEach { it.discard() } - val reason = (outcome as? GdDirectOutcome.Failed)?.reason ?: "incomplete" + val reason = report.error ?: GdDelivery.ERROR_INCOMPLETE // Reported at once: the sender stops waiting and takes the server path. api.transferResult(offer.transferId, false, error = reason) Timber.i("GateDrop: direct receive of %s failed (%s), the server delivers", offer.transferId, reason) return@withLock false } - val files = offer.items.mapIndexed { i, item -> - GdReceivedFile(item.name, item.mime, item.bytes, uri = pendings[i]?.publish()?.toString()) - } + val files = offer.items.mapIndexed { i, item -> GdReceivedFile(item.name, item.mime, item.bytes, uri = uris[i]) } val entry = GdReceived(key = key, sendId = offer.sendId, from = offer.from, at = System.currentTimeMillis(), files = files) store.upsert(entry) // Shown here: a delivery of the same send over the server (push or `pending`) adds nothing. offer.sendId?.let { store.markHandled(it) } - api.transferResult(offer.transferId, true, bytes = outcome.bytes) + api.transferResult(offer.transferId, true, bytes = report.bytes) notifier.received(entry) true } diff --git a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropWait.kt b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropWait.kt index d9d65689..f5b2cfac 100644 --- a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropWait.kt +++ b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropWait.kt @@ -66,4 +66,46 @@ object GateDropWait { poller.cancel() } } + + /** How long a sender asks for the pick-up after its own `ok` (the receiver reports right after saving). */ + const val PICKUP_WAIT_MS = 30_000L + + /** + * GateDrop 1.5: a sender's `ok` alone is no pick-up — the receiving device decides (fewer bytes = `incomplete`, the + * delivery then waits for the files). After this device's `ok` for the devices [deviceIds], [send] (`GET + * sends/`) is asked every [pollMs] until each is picked up or ended, at most [timeoutMs] → the indexes of the + * files the server still needs (empty: nothing to upload). An unanswered poll is asked again. + */ + suspend fun pickup( + deviceIds: Set, + timeoutMs: Long = PICKUP_WAIT_MS, + pollMs: Long = POLL_MS, + send: suspend () -> GdEntry?, + ): List { + val open = deviceIds.toMutableSet() + val missing = sortedSetOf() + var waited = 0L + while (open.isNotEmpty() && waited < timeoutMs) { + delay(pollMs) + waited += pollMs + val entry = try { + send() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } ?: continue + for (id in open.toList()) { + when (val c = GateDropLogic.pickupCheck(entry, id)) { + GdPickupCheck.Wait -> Unit + GdPickupCheck.Done -> open -= id + is GdPickupCheck.Upload -> { + missing += c.missing + open -= id + } + } + } + } + return missing.toList() + } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/gatedrop/GateDropShareViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/gatedrop/GateDropShareViewModel.kt index 7804ce2b..75a12b50 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/gatedrop/GateDropShareViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/gatedrop/GateDropShareViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import android.net.Uri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R import com.gatecontrol.android.gatedrop.GateDropJson import com.gatecontrol.android.gatedrop.GateDropLogic import com.gatecontrol.android.gatedrop.GateDropRepository @@ -169,7 +170,8 @@ class GateDropShareViewModel @Inject constructor( when (st) { is GdJobState.Done -> _ui.update { it.copy(stage = if (st.link != null) GdShareStage.RESULT else GdShareStage.SENT, link = st.link, retrying = false) } is GdJobState.Failed -> _ui.update { - it.copy(stage = GdShareStage.FAILED, failure = scanCodeText(getApplication(), st.code, name = st.name)) + it.copy(stage = GdShareStage.FAILED, failure = scanCodeText(getApplication(), st.code, name = st.name) + ?: getApplication().getString(R.string.gatedrop_send_gone).takeIf { GateDropLogic.isGone(st.code) }) } else -> _ui.update { it.copy(stage = GdShareStage.FAILED) } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/gatedrop/SharedScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/gatedrop/SharedScreen.kt index 9a9bf0ab..af1f00eb 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/gatedrop/SharedScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/gatedrop/SharedScreen.kt @@ -4,6 +4,7 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.content.res.Resources import android.net.Uri import android.text.format.DateUtils import android.widget.Toast @@ -35,6 +36,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -51,6 +53,8 @@ import com.gatecontrol.android.gatedrop.GdEntry import com.gatecontrol.android.gatedrop.GdLink import com.gatecontrol.android.gatedrop.GdReceiveFrom import com.gatecontrol.android.gatedrop.GdReceived +import com.gatecontrol.android.gatedrop.GdRetention +import com.gatecontrol.android.gatedrop.GdSentStatus import com.gatecontrol.android.scan.MediaScan import com.gatecontrol.android.ui.components.GcBanner import com.gatecontrol.android.ui.components.GcCard @@ -125,7 +129,7 @@ fun SharedScreen( Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(24.dp)) } } Text( - stringResource(R.string.gatedrop_shared_footer), + stringResource(R.string.gatedrop_shared_footer) + " " + retentionText(LocalResources.current, ui.retention), style = MaterialTheme.typography.bodySmall, color = extra.muted, modifier = Modifier.padding(horizontal = 4.dp), @@ -295,16 +299,35 @@ private fun ReceivedList(ui: SharedUi, onSave: (GdReceived) -> Unit, onOpened: ( } } +/** + * How long received files stay on the server: as the administrator set it (GateDrop 1.5 — after the pick-up and for + * what is not picked up), else the days of `limits.retentionDays` (7 as before). + */ +internal fun retentionText(res: Resources, r: GdRetention): String { + val unpicked = res.getQuantityString(R.plurals.gatedrop_retention_days, r.unpickedDays, r.unpickedDays) + val after = r.afterPickupDays + return when { + // a picked-up send never stays longer than one that is not: one span says it all + after == null || after >= r.unpickedDays -> res.getString(R.string.gatedrop_retention, unpicked) + after == 0 -> res.getString(R.string.gatedrop_retention_at_pickup, unpicked) + else -> res.getString(R.string.gatedrop_retention_after_pickup, res.getQuantityString(R.plurals.gatedrop_retention_days, after, after), unpicked) + } +} + @Composable -private fun statusText(status: String?): String? = when (status) { - GdDelivery.PENDING -> stringResource(R.string.gatedrop_status_pending) - GdDelivery.WAITING -> stringResource(R.string.gatedrop_status_waiting_any) - GdDelivery.DIRECT -> stringResource(R.string.gatedrop_status_direct) - GdDelivery.DELIVERED -> stringResource(R.string.gatedrop_status_delivered) - GdDelivery.OPENED -> stringResource(R.string.gatedrop_status_opened) - GdDelivery.FAILED -> stringResource(R.string.gatedrop_status_failed) - GdDelivery.EXPIRED -> stringResource(R.string.gatedrop_status_expired) - else -> null +private fun statusText(status: GdSentStatus?): String? = when (status) { + GdSentStatus.PENDING -> stringResource(R.string.gatedrop_status_pending) + GdSentStatus.WAITING -> stringResource(R.string.gatedrop_status_waiting_any) + GdSentStatus.DIRECT -> stringResource(R.string.gatedrop_status_direct) + GdSentStatus.PROVIDED -> stringResource(R.string.gatedrop_status_provided) + GdSentStatus.DELIVERED -> stringResource(R.string.gatedrop_status_delivered) + GdSentStatus.PICKED -> stringResource(R.string.gatedrop_status_picked) + GdSentStatus.OPENED -> stringResource(R.string.gatedrop_status_opened) + GdSentStatus.FAILED -> stringResource(R.string.gatedrop_status_failed) + GdSentStatus.INCOMPLETE -> stringResource(R.string.gatedrop_status_incomplete) + GdSentStatus.EXPIRED -> stringResource(R.string.gatedrop_status_expired) + GdSentStatus.REMOVED -> stringResource(R.string.gatedrop_status_removed) + null -> null } @Composable @@ -334,13 +357,14 @@ private fun SentList(ui: SharedUi) { else -> stringResource(R.string.gatedrop_item_text, e.text?.lineSequence()?.firstOrNull().orEmpty().take(60)) } val to = e.deliveries.map { deliveryName(it) }.distinct().take(3).joinToString(", ") - val overall = GateDropLogic.overallStatus(e.deliveries) + // GateDrop 1.5: "bereitgestellt" / "abgeholt", "Vom Admin entfernt", "Nicht alle Dateien angekommen" + val overall = GateDropLogic.sentStatus(e) // The target device was offline: it fetches the send once it is online. val waiting = e.deliveries.firstOrNull { it.status == GdDelivery.WAITING } // failed because the virus scan held the file back (GateDrop 1.4): the reason instead of "fehlgeschlagen" - val scanReason = if (overall == GdDelivery.FAILED) scanReasonText(ctx, GateDropLogic.scanFailure(e.deliveries)) else null + val scanReason = if (overall == GdSentStatus.FAILED) scanReasonText(ctx, GateDropLogic.scanFailure(e.deliveries)) else null val status = when { - overall == GdDelivery.WAITING && waiting != null -> + overall == GdSentStatus.WAITING && waiting != null -> waiting.targetName?.let { stringResource(R.string.gatedrop_status_waiting, it) } ?: statusText(overall) scanReason != null -> stringResource(R.string.scan_gatedrop_failed, scanReason) else -> statusText(overall) diff --git a/app/src/main/java/com/gatecontrol/android/ui/gatedrop/SharedViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/gatedrop/SharedViewModel.kt index 4d22a095..f48ee280 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/gatedrop/SharedViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/gatedrop/SharedViewModel.kt @@ -13,6 +13,7 @@ import com.gatecontrol.android.gatedrop.GdLink import com.gatecontrol.android.gatedrop.GdReceiveFrom import com.gatecontrol.android.gatedrop.GdReceived import com.gatecontrol.android.gatedrop.GdResult +import com.gatecontrol.android.gatedrop.GdRetention import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -34,6 +35,8 @@ data class SharedUi( val receiveFrom: String? = null, /** Saving it failed: the previous value is shown again. */ val receiveFromFailed: Boolean = false, + /** How long received files stay on the server (GateDrop 1.5 `limits.retention`; else 7 days). */ + val retention: GdRetention = GdRetention(), ) /** @@ -53,6 +56,11 @@ class SharedViewModel @Inject constructor( init { loadReceiveFrom() + // the retention the administrator set (`GET targets` → `limits`); known from the share sheet, asked anew + viewModelScope.launch { + repository.targets.collect { t -> _ui.update { it.copy(retention = GateDropLogic.retention(t)) } } + } + viewModelScope.launch { runCatching { repository.refreshTargets() } } viewModelScope.launch { repository.store.received.collect { local -> _ui.update { it.copy(received = GateDropLogic.mergeReceived(serverReceived, local), saving = it.saving.filter { key -> local.firstOrNull { e -> e.key == key }?.files?.all { f -> f.uri != null } != true }.toSet()) } diff --git a/app/src/main/res/values-de/strings_gatedrop.xml b/app/src/main/res/values-de/strings_gatedrop.xml index 36afa975..a1dc2ef9 100644 --- a/app/src/main/res/values-de/strings_gatedrop.xml +++ b/app/src/main/res/values-de/strings_gatedrop.xml @@ -126,7 +126,15 @@ Empfangen Gesendet Links - Was an deine eigenen Geräte geht, landet hier und als Mitteilung. Was Personen schicken, kommt wie gewohnt im Chat an. Empfangenes wird nach 7 Tagen vom Server gelöscht. + Was an deine eigenen Geräte geht, landet hier und als Mitteilung. Was Personen schicken, kommt wie gewohnt im Chat an. + + Empfangenes wird nach %1$s vom Server gelöscht. + Empfangenes wird vom Server gelöscht – kurz nach dem Abholen, sonst nach %1$s. + Empfangenes wird vom Server gelöscht – abgeholt nach %1$s, sonst nach %2$s. + + %d Tag + %d Tagen + Offline – zu sehen ist, was dieses Gerät kennt. von %1$s · %2$s an %1$s @@ -145,6 +153,12 @@ geöffnet fehlgeschlagen abgelaufen + + bereitgestellt + abgeholt + nicht alle Dateien angekommen + vom Admin entfernt + Die Sendung ist abgelaufen oder wurde vom Admin entfernt. läuft ab %1$s %d Download diff --git a/app/src/main/res/values/strings_gatedrop.xml b/app/src/main/res/values/strings_gatedrop.xml index 3bcca7fb..46c600f7 100644 --- a/app/src/main/res/values/strings_gatedrop.xml +++ b/app/src/main/res/values/strings_gatedrop.xml @@ -126,7 +126,15 @@ Received Sent Links - What goes to your own devices lands here and as a notification. What people send arrives in the chat as usual. Received items are deleted from the server after 7 days. + What goes to your own devices lands here and as a notification. What people send arrives in the chat as usual. + + Received items are deleted from the server after %1$s. + Received items are deleted from the server – shortly after pickup, otherwise after %1$s. + Received items are deleted from the server – %1$s after pickup, otherwise after %2$s. + + %d day + %d days + Offline – showing what this device knows. from %1$s · %2$s to %1$s @@ -145,6 +153,12 @@ opened failed expired + + ready to pick up + picked up + not every file arrived + removed by an admin + The send has expired or was removed by an admin. expires %1$s %d download diff --git a/app/src/test/java/com/gatecontrol/android/gatedrop/GateDropPickupTest.kt b/app/src/test/java/com/gatecontrol/android/gatedrop/GateDropPickupTest.kt new file mode 100644 index 00000000..39a1a64b --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/gatedrop/GateDropPickupTest.kt @@ -0,0 +1,252 @@ +package com.gatecontrol.android.gatedrop + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.IOException + +/** + * GateDrop 1.5: real pick-ups (`pickedAt`), sends an administrator removed, `incomplete` direct transfers and the + * retention the administrator set — and older servers without these fields, which keep the old behaviour. + */ +class GateDropPickupTest { + + private fun obj(json: String): JsonObject = JsonParser.parseString(json).asJsonObject + + private fun d(status: String, kind: String = "device", error: String? = null, pickedAt: Long? = null, knows: Boolean = true, via: String? = null, target: String = "pc") = + GdDelivery(kind = kind, target = target, status = status, via = via, error = error, pickedAt = pickedAt, knowsPickup = knows) + + private fun entry(vararg dels: GdDelivery, removed: Boolean = false, items: List = emptyList()) = + GdEntry(id = "s1", at = 1L, deliveries = dels.toList(), removed = removed, expired = removed, items = items) + + // ─── reading the new fields ───────────────────────────────────── + + @Test + fun `pickedAt of a delivery - set, null (known) or missing (older server)`() { + val picked = GateDropJson.delivery(obj("""{"kind":"device","target":"pc","status":"delivered","pickedAt":"2026-11-04T10:00:00.000Z"}"""))!! + assertTrue(picked.knowsPickup) + assertEquals(1793786400000L, picked.pickedAt) + val provided = GateDropJson.delivery(obj("""{"kind":"device","target":"pc","status":"delivered","pickedAt":null}"""))!! + assertTrue(provided.knowsPickup) + assertNull(provided.pickedAt) + val old = GateDropJson.delivery(obj("""{"kind":"device","target":"pc","status":"delivered"}"""))!! + assertFalse(old.knowsPickup) + assertNull(old.pickedAt) + } + + @Test + fun `a send - removed, removedAt, pickedAt, local items with their index`() { + val e = GateDropJson.entry( + obj( + """{"id":"aaaaaaaaaaaaaaaaaaaaaaaa","role":"sent","createdAt":"2026-11-01T10:00:00.000Z","expired":false,"removed":true, + "removedAt":"2026-11-02T10:00:00.000Z","pickedAt":null, + "items":[{"index":0,"mediaId":null,"name":"a.jpg","bytes":5,"local":true,"available":false}, + {"index":1,"mediaId":"media_0001","name":"b.jpg","bytes":7,"local":false,"available":true}], + "deliveries":[{"kind":"device","target":"pc","status":"expired","error":"removed","pickedAt":null}]}""", + ), + )!! + assertTrue(e.removed) + assertTrue(e.expired, "removed means the files are gone too") + assertNull(e.pickedAt) + assertEquals(listOf(0, 1), e.items.map { it.index }) + assertEquals(listOf(true, false), e.items.map { it.local }) + assertEquals(GdDelivery.ERROR_REMOVED, e.deliveries.single().error) + + val old = GateDropJson.entry(obj("""{"id":"aaaaaaaaaaaaaaaaaaaaaaaa","items":[{"mediaId":"media_0001","name":"b.jpg"}]}"""))!! + assertFalse(old.removed) + assertFalse(old.expired) + assertNull(old.items.single().index) + assertFalse(old.items.single().local) + } + + @Test + fun `the answer to a transfer result carries the delivery with pickedAt`() { + val a = GateDropJson.transferAnswer( + obj("""{"transfer":{"id":"t","status":"done","side":"sender"},"delivery":{"id":3,"kind":"device","target":"pc","status":"delivered","via":"direct","error":null,"at":"2026-11-04T10:00:00.000Z","pickedAt":null},"fallback":null}"""), + ) + assertNull(a.fallback) + assertEquals(GdDelivery.DELIVERED, a.delivery?.status) + assertTrue(a.delivery!!.knowsPickup) + assertNull(a.delivery.pickedAt) + // older plugins: the delivery without pickedAt + val old = GateDropJson.transferAnswer(obj("""{"delivery":{"kind":"device","target":"pc","status":"delivered","via":"direct"},"fallback":null}""")) + assertFalse(old.delivery!!.knowsPickup) + assertNull(GateDropJson.transferAnswer(obj("""{"fallback":"upload","missing":[0]}""")).delivery) + } + + @Test + fun `retention from the targets' limits - 1_5, retentionDays only, nothing`() { + val t15 = GateDropJson.targets(obj("""{"me":{},"limits":{"retentionDays":14,"retention":{"afterPickupDays":0,"unpickedDays":14}}}"""))!! + assertEquals(GdRetention(unpickedDays = 14, afterPickupDays = 0), t15.retention) + val t14 = GateDropJson.targets(obj("""{"me":{},"limits":{"retentionDays":7}}"""))!! + assertEquals(GdRetention(unpickedDays = 7, afterPickupDays = null), t14.retention) + val none = GateDropJson.targets(obj("""{"me":{}}"""))!! + assertNull(none.retention) + // nonsense is ignored + assertNull(GateDropJson.retention(obj("""{"retentionDays":0,"retention":{"unpickedDays":-3}}"""))) + assertEquals(GdRetention(30, null), GateDropJson.retention(obj("""{"retention":{"afterPickupDays":999,"unpickedDays":30}}"""))) + } + + @Test + fun `retention falls back to 7 days`() { + assertEquals(GdRetention(7, null), GateDropLogic.retention(null)) + assertEquals(GdRetention(7, null), GateDropLogic.retention(GdTargets())) + assertEquals(GdRetention(3, 1), GateDropLogic.retention(GdTargets(retention = GdRetention(3, 1)))) + } + + // ─── "Gesendet" ───────────────────────────────────────────────── + + @Test + fun `delivered - provided without pickedAt, picked up with it, delivered as before on older servers`() { + assertEquals(GdSentStatus.PROVIDED, GateDropLogic.sentStatus(d(GdDelivery.DELIVERED))) + assertEquals(GdSentStatus.PICKED, GateDropLogic.sentStatus(d(GdDelivery.DELIVERED, pickedAt = 5L))) + assertEquals(GdSentStatus.DELIVERED, GateDropLogic.sentStatus(d(GdDelivery.DELIVERED, knows = false))) + // a NAS folder, Drive or a link: delivered is all there is + assertEquals(GdSentStatus.DELIVERED, GateDropLogic.sentStatus(d(GdDelivery.DELIVERED, kind = "nas", pickedAt = 5L))) + assertEquals(GdSentStatus.DELIVERED, GateDropLogic.sentStatus(d(GdDelivery.DELIVERED, kind = "link"))) + assertEquals(GdSentStatus.DELIVERED, GateDropLogic.sentStatus(d(GdDelivery.DELIVERED, kind = "drive", pickedAt = 5L))) + assertEquals(GdSentStatus.OPENED, GateDropLogic.sentStatus(d(GdDelivery.OPENED, pickedAt = 5L))) + } + + @Test + fun `removed by an administrator instead of expired`() { + assertEquals(GdSentStatus.REMOVED, GateDropLogic.sentStatus(d(GdDelivery.EXPIRED, error = GdDelivery.ERROR_REMOVED))) + assertEquals(GdSentStatus.REMOVED, GateDropLogic.sentStatus(d(GdDelivery.EXPIRED), sendRemoved = true)) + assertEquals(GdSentStatus.EXPIRED, GateDropLogic.sentStatus(d(GdDelivery.EXPIRED))) + assertEquals(GdSentStatus.REMOVED, GateDropLogic.sentStatus(entry(d(GdDelivery.EXPIRED), removed = true))) + // nothing listed: the send's own flag + assertEquals(GdSentStatus.REMOVED, GateDropLogic.sentStatus(entry(removed = true))) + assertNull(GateDropLogic.sentStatus(entry())) + // who picked it up before keeps it: their delivery still reads picked up + assertEquals(GdSentStatus.PICKED, GateDropLogic.sentStatus(entry(d(GdDelivery.DELIVERED, pickedAt = 1L), removed = true))) + assertEquals(GdSentStatus.REMOVED, GateDropLogic.sentStatus(entry(d(GdDelivery.DELIVERED, pickedAt = 1L), d(GdDelivery.EXPIRED, error = "removed", target = "x"), removed = true))) + } + + @Test + fun `incomplete - not every file arrived`() { + assertEquals(GdSentStatus.INCOMPLETE, GateDropLogic.sentStatus(d(GdDelivery.FAILED, error = GdDelivery.ERROR_INCOMPLETE))) + assertEquals(GdSentStatus.INCOMPLETE, GateDropLogic.sentStatus(d(GdDelivery.PENDING, error = GdDelivery.ERROR_INCOMPLETE))) + assertEquals(GdSentStatus.FAILED, GateDropLogic.sentStatus(d(GdDelivery.FAILED, error = "unreachable"))) + assertEquals(GdSentStatus.PENDING, GateDropLogic.sentStatus(d(GdDelivery.PENDING, error = GdDelivery.ERROR_DIRECT_FAILED))) + // delivered over the server after all: informational only + assertEquals(GdSentStatus.PROVIDED, GateDropLogic.sentStatus(d(GdDelivery.DELIVERED, error = GdDelivery.ERROR_INCOMPLETE, via = "server"))) + } + + @Test + fun `the most important state of a send wins`() { + assertEquals(GdSentStatus.PROVIDED, GateDropLogic.sentStatus(entry(d(GdDelivery.DELIVERED, pickedAt = 1L), d(GdDelivery.DELIVERED, target = "x")))) + assertEquals(GdSentStatus.PROVIDED, GateDropLogic.sentStatus(entry(d(GdDelivery.DELIVERED, kind = "nas"), d(GdDelivery.DELIVERED, target = "x")))) + assertEquals(GdSentStatus.WAITING, GateDropLogic.sentStatus(entry(d(GdDelivery.DELIVERED), d(GdDelivery.WAITING, target = "x")))) + assertEquals(GdSentStatus.INCOMPLETE, GateDropLogic.sentStatus(entry(d(GdDelivery.DELIVERED), d(GdDelivery.FAILED, error = "incomplete", target = "x")))) + assertEquals(GdSentStatus.FAILED, GateDropLogic.sentStatus(entry(d(GdDelivery.FAILED), d(GdDelivery.FAILED, error = "incomplete", target = "x")))) + assertEquals(GdSentStatus.PICKED, GateDropLogic.sentStatus(entry(d(GdDelivery.DELIVERED, pickedAt = 1L), d(GdDelivery.DELIVERED, pickedAt = 2L, target = "x")))) + } + + @Test + fun `410 gone`() { + assertTrue(GateDropLogic.isGone("gone")) + assertFalse(GateDropLogic.isGone("not_found")) + assertFalse(GateDropLogic.isGone(null)) + } + + // ─── the receiving end ────────────────────────────────────────── + + private val items = listOf(GdOfferItem("a.jpg", 5, "image/jpeg"), GdOfferItem("b.jpg", 7, "image/jpeg")) + + @Test + fun `receiver - ok only with every file, then always with the bytes`() { + assertEquals(GdDirectReport(true, 12, null), GateDropLogic.directReport(items, GdDirectOutcome.Done(12), listOf("u1", "u2"))) + assertEquals(GdDirectReport(false, null, "incomplete"), GateDropLogic.directReport(items, GdDirectOutcome.Done(5), listOf("u1", "u2"))) + assertEquals(GdDirectReport(false, null, "incomplete"), GateDropLogic.directReport(items, GdDirectOutcome.Done(12), listOf("u1"))) + assertEquals(GdDirectReport(false, null, "save_failed"), GateDropLogic.directReport(items, GdDirectOutcome.Done(12), listOf("u1", null))) + assertEquals(GdDirectReport(false, null, "push_timeout"), GateDropLogic.directReport(items, GdDirectOutcome.Failed("push_timeout"), emptyList())) + // zero-byte files count as well + val empty = listOf(GdOfferItem("e.txt", 0, "text/plain")) + assertEquals(GdDirectReport(true, 0, null), GateDropLogic.directReport(empty, GdDirectOutcome.Done(0), listOf("u"))) + } + + // ─── the sender after its own ok ──────────────────────────────── + + private fun answer(status: String, pickedAt: Long? = null, knows: Boolean = true, via: String? = "direct", error: String? = null, fallback: String? = null) = + GdTransferAnswer(fallback = fallback, delivery = d(status, pickedAt = pickedAt, knows = knows, via = via, error = error)) + + @Test + fun `sender - asks for the pick-up only while a 1_5 server leaves it open`() { + assertTrue(GateDropLogic.awaitsPickup(answer(GdDelivery.DELIVERED))) + assertTrue(GateDropLogic.awaitsPickup(answer(GdDelivery.DIRECT, via = null))) + // the receiver failed first: the answer to an ok names no fallback, the delivery waits for the files + assertTrue(GateDropLogic.awaitsPickup(answer(GdDelivery.PENDING, via = null, error = "direct_failed"))) + assertFalse(GateDropLogic.awaitsPickup(answer(GdDelivery.DELIVERED, pickedAt = 3L))) + assertFalse(GateDropLogic.awaitsPickup(answer(GdDelivery.DELIVERED, knows = false)), "older server: as before") + assertFalse(GateDropLogic.awaitsPickup(answer(GdDelivery.DELIVERED, via = "server"))) + assertFalse(GateDropLogic.awaitsPickup(answer(GdDelivery.OPENED))) + assertFalse(GateDropLogic.awaitsPickup(answer(GdDelivery.PENDING, error = "direct_failed", fallback = "upload")), "handled by missingAfterDirect") + assertFalse(GateDropLogic.awaitsPickup(null)) + assertFalse(GateDropLogic.awaitsPickup(GdTransferAnswer())) + } + + private val localItems = listOf( + GdItem(null, "a.jpg", "image/jpeg", 5, available = false, index = 0, local = true), + GdItem(null, "b.jpg", "image/jpeg", 7, available = false, index = 1, local = true), + ) + + @Test + fun `sender - pick-up check of one device`() { + assertEquals(GdPickupCheck.Wait, GateDropLogic.pickupCheck(entry(d(GdDelivery.DELIVERED, via = "direct")), "pc")) + assertEquals(GdPickupCheck.Done, GateDropLogic.pickupCheck(entry(d(GdDelivery.DELIVERED, via = "direct", pickedAt = 1L)), "pc")) + assertEquals(GdPickupCheck.Done, GateDropLogic.pickupCheck(entry(d(GdDelivery.DELIVERED, via = "direct", knows = false)), "pc")) + assertEquals(GdPickupCheck.Done, GateDropLogic.pickupCheck(entry(d(GdDelivery.DELIVERED, via = "server", error = "direct_failed")), "pc")) + assertEquals( + GdPickupCheck.Upload(listOf(0, 1)), + GateDropLogic.pickupCheck(entry(d(GdDelivery.PENDING, error = GdDelivery.ERROR_DIRECT_FAILED), items = localItems), "pc"), + ) + // only what is still on this device + val one = listOf(localItems[0].copy(mediaId = "media_0001", local = false, available = true), localItems[1]) + assertEquals(GdPickupCheck.Upload(listOf(1)), GateDropLogic.pickupCheck(entry(d(GdDelivery.PENDING, error = "direct_failed"), items = one), "pc")) + // another device, a removed send, nothing local + assertEquals(GdPickupCheck.Done, GateDropLogic.pickupCheck(entry(d(GdDelivery.DELIVERED, via = "direct")), "laptop")) + assertEquals(GdPickupCheck.Done, GateDropLogic.pickupCheck(entry(d(GdDelivery.PENDING, error = "direct_failed"), removed = true, items = localItems), "pc")) + assertEquals(GdPickupCheck.Done, GateDropLogic.pickupCheck(entry(d(GdDelivery.PENDING, error = "direct_failed")), "pc")) + } + + @Test + fun `sender - picked up after a few polls, nothing to upload`() = runTest { + var polls = 0 + val missing = GateDropWait.pickup(setOf("pc"), timeoutMs = 30_000, pollMs = 2_000) { + polls++ + entry(if (polls < 3) d(GdDelivery.DELIVERED, via = "direct") else d(GdDelivery.DELIVERED, via = "direct", pickedAt = 9L)) + } + assertEquals(emptyList(), missing) + assertEquals(3, polls) + assertEquals(6_000, currentTime) + } + + @Test + fun `sender - the receiver reported incomplete, the files go up`() = runTest { + val missing = GateDropWait.pickup(setOf("pc", "laptop"), timeoutMs = 30_000, pollMs = 2_000) { + entry( + d(GdDelivery.PENDING, error = GdDelivery.ERROR_DIRECT_FAILED), + d(GdDelivery.DELIVERED, via = "direct", pickedAt = 1L, target = "laptop"), + items = localItems, + ) + } + assertEquals(listOf(0, 1), missing) + assertEquals(2_000, currentTime) + } + + @Test + fun `sender - an older server ends the wait at once, no answer ends it after the timeout`() = runTest { + assertEquals(emptyList(), GateDropWait.pickup(setOf("pc"), 30_000, 2_000) { entry(d(GdDelivery.DELIVERED, via = "direct", knows = false)) }) + assertEquals(2_000, currentTime) + assertEquals(emptyList(), GateDropWait.pickup(setOf("pc"), 10_000, 2_000) { throw IOException("offline") }) + assertEquals(12_000, currentTime) + assertEquals(emptyList(), GateDropWait.pickup(setOf("pc"), 10_000, 2_000) { entry(d(GdDelivery.DELIVERED, via = "direct")) }) + assertEquals(22_000, currentTime) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/ui/gatedrop/GateDropRetentionTextTest.kt b/app/src/test/java/com/gatecontrol/android/ui/gatedrop/GateDropRetentionTextTest.kt new file mode 100644 index 00000000..763f50d6 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/ui/gatedrop/GateDropRetentionTextTest.kt @@ -0,0 +1,53 @@ +package com.gatecontrol.android.ui.gatedrop + +import android.app.Application +import com.gatecontrol.android.gatedrop.GdRetention +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * The footer of "Geteilt": how long received files stay on the server — as the administrator set it (GateDrop 1.5), + * else `limits.retentionDays`, else 7 days as before. German and English. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class GateDropRetentionTextTest { + + private fun text(qualifiers: String, r: GdRetention): String { + RuntimeEnvironment.setQualifiers(qualifiers) + return retentionText(RuntimeEnvironment.getApplication().resources, r) + } + + @Test + fun `german - as before, one span, right after the pick-up, days after it`() { + assertEquals("Empfangenes wird nach 7 Tagen vom Server gelöscht.", text("de", GdRetention())) + assertEquals("Empfangenes wird nach 14 Tagen vom Server gelöscht.", text("de", GdRetention(unpickedDays = 14))) + // after ≥ unpicked: a picked-up send never stays longer — one span + assertEquals("Empfangenes wird nach 7 Tagen vom Server gelöscht.", text("de", GdRetention(unpickedDays = 7, afterPickupDays = 7))) + assertEquals( + "Empfangenes wird vom Server gelöscht – kurz nach dem Abholen, sonst nach 30 Tagen.", + text("de", GdRetention(unpickedDays = 30, afterPickupDays = 0)), + ) + assertEquals( + "Empfangenes wird vom Server gelöscht – abgeholt nach 1 Tag, sonst nach 3 Tagen.", + text("de", GdRetention(unpickedDays = 3, afterPickupDays = 1)), + ) + } + + @Test + fun `english`() { + assertEquals("Received items are deleted from the server after 7 days.", text("en", GdRetention())) + assertEquals( + "Received items are deleted from the server – shortly after pickup, otherwise after 14 days.", + text("en", GdRetention(unpickedDays = 14, afterPickupDays = 0)), + ) + assertEquals( + "Received items are deleted from the server – 1 day after pickup, otherwise after 7 days.", + text("en", GdRetention(unpickedDays = 7, afterPickupDays = 1)), + ) + } +}