From d2a0d0252e314d1804cc56c7255172cc9a3f9036 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:03:38 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Drive:=20KI-Funktionen=20D1=E2=80=93D5=20(D?= =?UTF-8?q?rive=201.14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D2: Fristen als Karten (Garantieende, Vertragsverlängerung, „neu“), „In den Familienkalender“ (deadlines//calendar) und „Erinnern“ - D4: Ablagevorschlag nach Scan/Upload (Insight abwarten, Übernehmen, Anderer Ordner, an die Zahlung erinnern) - D5: „Scan prüfen“ mit Art-Erkennung (scans/classify), Format-Chips, Vorschau, durchsuchbarem PDF auf dem Gerät (unsichtbare Textebene) und Word/Excel über scans/convert - D1: Fragen im Drive-Suchfeld (ask) mit Antwortkarte und Quellen - D3: Bildbegriffe beim Sichern (labels), Nachtragen im Hintergrund, Fotosuche (photos?q=) Alles hinter den Feature-Flags aus GET me (docAsk, scanDocs, photoSearch, deadlineCalendar). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../com/gatecontrol/android/drive/DriveApi.kt | 44 +- .../gatecontrol/android/drive/DriveFiling.kt | 130 ++++ .../gatecontrol/android/drive/DriveJson.kt | 101 ++- .../com/gatecontrol/android/drive/DriveKi.kt | 233 ++++++ .../android/drive/DriveKiModels.kt | 33 + .../gatecontrol/android/drive/DriveModels.kt | 24 +- .../gatecontrol/android/drive/DriveScanner.kt | 79 +- .../android/drive/DriveSearchablePdf.kt | 205 +++++ .../android/drive/DriveTransfers.kt | 16 +- .../android/drive/backup/DriveBackup.kt | 4 + .../android/drive/backup/DrivePhotoLabels.kt | 229 ++++++ .../android/drive/work/DriveWorkLabeler.kt | 27 +- .../android/navigation/AppNavigation.kt | 4 + .../gatecontrol/android/navigation/Screen.kt | 2 + .../android/ui/drive/DriveComponents.kt | 24 + .../android/ui/drive/DriveFileScreen.kt | 89 ++- .../android/ui/drive/DriveFolderScreen.kt | 23 +- .../android/ui/drive/DriveHomeScreen.kt | 9 +- .../gatecontrol/android/ui/drive/DriveKiUi.kt | 702 ++++++++++++++++++ .../android/ui/drive/DriveMoreScreens.kt | 100 ++- .../ui/drive/DrivePhotoSearchScreen.kt | 183 +++++ .../android/ui/drive/DriveScanUi.kt | 2 +- .../android/ui/drive/DriveViewModels.kt | 213 +++++- app/src/main/res/values-de/strings_drive3.xml | 1 - app/src/main/res/values-de/strings_drive9.xml | 96 +++ app/src/main/res/values/strings_drive3.xml | 1 - app/src/main/res/values/strings_drive9.xml | 96 +++ .../android/drive/DriveJsonKiTest.kt | 118 +++ .../gatecontrol/android/drive/DriveKiTest.kt | 181 +++++ .../android/drive/DriveSearchablePdfTest.kt | 94 +++ 30 files changed, 3007 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/com/gatecontrol/android/drive/DriveFiling.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/DriveKi.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/DriveKiModels.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/DriveSearchablePdf.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/backup/DrivePhotoLabels.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/drive/DriveKiUi.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotoSearchScreen.kt create mode 100644 app/src/main/res/values-de/strings_drive9.xml create mode 100644 app/src/main/res/values/strings_drive9.xml create mode 100644 app/src/test/java/com/gatecontrol/android/drive/DriveJsonKiTest.kt create mode 100644 app/src/test/java/com/gatecontrol/android/drive/DriveKiTest.kt create mode 100644 app/src/test/java/com/gatecontrol/android/drive/DriveSearchablePdfTest.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 37410e68..34a875d3 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt @@ -50,6 +50,10 @@ class DriveApi @Inject constructor( /** At most this many ids per `photos/review` (Drive 1.13). */ private const val REVIEW_IDS_MAX = 500 private const val TIMEOUT_MS = 25_000L + /** The AI takes its time: a question, what a scan is. */ + private const val AI_TIMEOUT_MS = 60_000L + /** A scan read by the AI and written as Word or Excel. */ + private const val CONVERT_TIMEOUT_MS = 180_000L /** Small files go in one piece. */ private const val RESUMABLE_FROM = 8L * 1024 * 1024 /** Part size of a resumable upload: a multiple of 64 KiB. */ @@ -252,8 +256,8 @@ class DriveApi @Inject constructor( * Photos and videos, newest first; [before] is the cursor of the page before. Without [area]: all I can read. * [camera]: only the camera uploads (Drive 1.10, feature `photosFolder`). */ - suspend fun photos(area: String? = null, before: String? = null, limit: Int = 120, camera: Boolean = false, cls: String? = null): DResult = - get("photos", DriveJson.photosQuery(area, before, limit, camera, cls = cls) ?: return bad()) { o -> DriveJson.photos(o)?.let { it.copy(items = DriveLogic.withoutVaults(it.items)) } } + suspend fun photos(area: String? = null, before: String? = null, limit: Int = 120, camera: Boolean = false, cls: String? = null, q: String? = null): DResult = + get("photos", DriveJson.photosQuery(area, before, limit, camera, cls = cls, q = q) ?: return bad()) { o -> DriveJson.photos(o)?.let { it.copy(items = DriveLogic.withoutVaults(it.items)) } } suspend fun photoMonths(area: String? = null, camera: Boolean = false, cls: String? = null): DResult> = get("photos/months", DriveJson.photosQuery(area, camera = camera, cls = cls) ?: return bad(), DriveJson::months) @@ -383,12 +387,48 @@ class DriveApi @Inject constructor( suspend fun unremind(id: Long): DResult = del("deadlines/$id/remind", DriveJson::deadlineAnswer) + /** + * Deadline [id] into the family calendar (Drive 1.14, feature `deadlineCalendar`) → the deadline with `calendar`; + * no Familie or it refuses: `familie_unavailable`. + */ + suspend fun deadlineToCalendar(id: Long): DResult = post("deadlines/$id/calendar", JsonObject(), DriveJson::deadlineAnswer) + /** AI for documents in area [id] on or off (`PATCH areas/ { docAi }`, the write right) → the setting now. */ suspend fun setAreaDocAi(id: String, on: Boolean): DResult { val area = DriveJson.areaId(id) ?: return bad() return patch("areas/$area", DriveJson.docAiBody(on), DriveJson::areaDocAi) } + // ─── AI in Drive (Drive 1.14) ──────────────────────────────────── + + /** + * A question to my documents (feature `docAsk`), answered only from the files I may read (never a vault); [area] + * narrows it. 30 an hour: `rate_limited`. + */ + suspend fun ask(q: String, area: String? = null): DResult { + val a = area?.let { DriveJson.areaId(it) ?: return bad() } + return call(timeoutMs = AI_TIMEOUT_MS, media = true, request = { pluginPost(DRIVE_PLUGIN, "ask", DriveJson.askBody(q, a), lang()) }, parse = DriveJson::answer) + } + + /** What the scanned pages [mediaIds] are (feature `scanDocs`); the [text] recognised on the phone goes first. */ + suspend fun classifyScan(mediaIds: List, text: String?): DResult { + if (mediaIds.isEmpty() || mediaIds.any { DriveJson.mediaId(it) == null }) return bad() + return call(timeoutMs = AI_TIMEOUT_MS, media = true, request = { pluginPost(DRIVE_PLUGIN, "scans/classify", DriveJson.classifyBody(mediaIds, text), lang()) }, parse = DriveJson::scanKind) + } + + /** + * The scanned pages [mediaIds] as a document [target] (`docx` | `xlsx` | `pdf`) of [kind] in [folderId], named [name]; + * [keepOriginal]: the scan stays next to it. + */ + suspend fun convertScan(mediaIds: List, target: String, kind: String, folderId: Long?, name: String?, keepOriginal: Boolean): DResult { + if (mediaIds.isEmpty() || mediaIds.any { DriveJson.mediaId(it) == null } || target !in DriveJson.SCAN_TARGETS) return bad() + val body = DriveJson.convertBody(mediaIds, target, kind.takeIf { it in DriveJson.SCAN_KINDS } ?: "other", folderId, name, keepOriginal) + return call(timeoutMs = CONVERT_TIMEOUT_MS, media = true, request = { pluginPost(DRIVE_PLUGIN, "scans/convert", body, lang()) }, parse = DriveJson::converted) + } + + /** What is in photo [id], found on this phone (feature `photoSearch`). */ + suspend fun putLabels(id: Long, labels: List): DResult = put("nodes/$id/labels", DriveJson.labelsBody(labels), unit) + // ─── office in the browser (Drive 1.9, feature `office`) ──────── /** The address of file [id] in the office (edit with the write right, else view). */ diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveFiling.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveFiling.kt new file mode 100644 index 00000000..e779f1d8 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveFiling.kt @@ -0,0 +1,130 @@ +package com.gatecontrol.android.drive + +import com.gatecontrol.android.support.CrashReports +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.time.LocalDate +import javax.inject.Inject +import javax.inject.Singleton + +/** + * "Ablegen gleich nach dem Scan" (Drive 1.14, D4; the server as in Drive 1.8): after a scan or a document the person + * uploaded here, Drive's AI reads it; once its insight is `ready` with a suggested name or folder, the bar + * "Vorschlag · Ablegen als … in …" shows ([proposal]), with "An die Zahlung am … erinnern" for a payment deadline. It + * waits for the live event `changed` or asks again now and then, about a minute at most — then the bar stays away + * without a word. Only with `docAi`, never for a vault, at most a few files at once. + */ +@Singleton +class DriveFiling @Inject constructor( + private val api: DriveApi, + private val live: DriveLive, + transfers: DriveTransfers, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler) + + /** The bar for [node]: what Drive suggests. */ + data class Proposal(val node: DNode, val filing: DriveKi.Filing) + + private val _proposal = MutableStateFlow(null) + val proposal: StateFlow = _proposal.asStateFlow() + + private val watching = LinkedHashMap() + + init { + scope.launch { transfers.committed.collect { watch(it.node, it.scan) } } + } + + /** Waits for the insight of [node] (a new upload); the newest file's suggestion wins. */ + fun watch(node: DNode, scan: Boolean) { + if (!DriveKi.fileable(node, scan)) return + scope.launch { + val me = live.current() ?: return@launch + if (!me.has(DMe.FEATURE_DOC_AI)) return@launch + if (me.areas.firstOrNull { it.id == node.areaId }?.let { it.docAi == false || !it.writable } == true) return@launch + synchronized(watching) { + watching.remove(node.id)?.cancel() + // a whole folder uploaded: only the last few are waited for + while (watching.size >= MAX_WATCHED) watching.remove(watching.keys.first())?.cancel() + watching[node.id] = scope.launch { wait(node) } + } + } + } + + private suspend fun wait(node: DNode) { + try { + val changed = Channel(Channel.CONFLATED) + val listen = scope.launch { live.changed.filter { DriveLogic.affects(it, node.areaId) }.collect { changed.trySend(Unit) } } + try { + for (pause in DriveKi.FILING_POLL_MS) { + withTimeoutOrNull(pause) { changed.receive() } + val r = api.insight(node.id) + if (r is DResult.Failed && r.code in GIVE_UP) return + val insight = (r as? DResult.Ok)?.value ?: continue + if (insight.status == "failed" || insight.status == "off") return + val filing = DriveKi.filing(insight, LocalDate.now()) + if (filing != null) { + _proposal.value = Proposal(node, filing) + return + } + // ready without a suggestion: the file is where it belongs + if (insight.status == "ready") return + } + } finally { + listen.cancel() + } + } finally { + synchronized(watching) { if (watching[node.id]?.isActive != true) watching.remove(node.id) } + } + } + + /** ✕: the bar goes. */ + fun dismiss() { + _proposal.value = null + } + + /** + * "Übernehmen": renamed and moved as suggested ([folder] false: the name only, "Anderer Ordner" chooses the + * folder), and with [remind] the reminder of the payment. Null when done, else a code. + */ + suspend fun accept(p: Proposal, folder: Boolean, remind: Boolean): Pair { + _proposal.value = null + val name = p.filing.name != null + val move = folder && p.filing.folderId != null + var node: DNode? = p.node + if (name || move) { + when (val r = api.insightApply(p.node.id, name, move)) { + is DResult.Ok -> node = r.value.node + else -> return null to (r.code() ?: "failed") + } + } + val payment = p.filing.payment + if (remind && payment != null) { + val r = api.remind(payment.id, DriveLogic.REMIND_DEFAULT) + if (r !is DResult.Ok) return node to (r.code() ?: "failed") + } + return node to null + } + + private fun DResult<*>.code(): String? = when (this) { + is DResult.Ok -> null + is DResult.Failed -> code + DResult.Offline -> "offline" + DResult.NotConfigured -> "not_configured" + } + + private companion object { + const val MAX_WATCHED = 3 + /** Nothing will come: the file is gone, may not be read, or Drive has no AI. */ + val GIVE_UP = setOf("not_found", "forbidden", "feature_off", "encrypted", "unsupported") + } +} 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 5c155faf..2c9c5d5c 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt @@ -426,7 +426,7 @@ internal object DriveJson { /** `GET photos` → `{ items, next }`. */ fun photos(o: JsonObject): DPhotos? { if (o.arr("items") == null) return null - return DPhotos(nodes(o), o.cursor("next")) + return DPhotos(nodes(o), o.cursor("next"), o.int("total")?.takeIf { it >= 0 }, terms(o)) } /** `GET photos/months` → `{ months: [{ month, count }] }`. */ @@ -455,6 +455,7 @@ internal object DriveJson { camera: Boolean = false, date: String? = null, cls: String? = null, + q: String? = null, ): Map? = buildMap { if (area != null) put("area", areaId(area) ?: return null) @@ -463,6 +464,7 @@ internal object DriveJson { if (date != null) put("date", date) if (camera) put("camera", "1") if (cls != null) put("class", cls.takeIf { it in PHOTO_CLASSES } ?: return null) + if (q != null) q.trim().take(PHOTO_Q_MAX).takeIf { it.isNotEmpty() }?.let { put("q", it) } } /** The values of `class` (Drive 1.13). */ @@ -671,7 +673,8 @@ internal object DriveJson { private val DATE = Regex("^[0-9]{4}-[0-9]{2}-[0-9]{2}$") private val INSIGHT_STATUS = setOf("none", "pending", "ready", "failed", "off") val DOC_KINDS = setOf("invoice", "contract", "notice", "medical", "certificate", "statement", "insurance", "warranty", "other") - val DEADLINE_KINDS = setOf("payment", "cancellation", "objection") + /** Drive 1.14 adds `warranty` (end of the warranty) and `renewal` (last day to cancel before a contract renews). */ + val DEADLINE_KINDS = setOf("payment", "cancellation", "objection", "warranty", "renewal") fun insightShort(o: JsonObject): DInsightShort? { val status = o.str("status")?.takeIf { it in INSIGHT_STATUS } ?: return null @@ -711,10 +714,11 @@ internal object DriveJson { label = o.str("label")?.trim()?.take(200)?.ifEmpty { null }, reminder = r?.let { DReminder(it.str("at")?.take(40), it.int("daysBefore")?.coerceIn(0, 90) ?: 0, it.bool("sent") ?: false) }, node = node(o.obj("node")), + calendar = o.obj("calendar")?.let { c -> DCalendarEntry(c.idString("eventId")?.take(200), c.str("at")?.take(40)) }, ) } - /** `POST|DELETE deadlines//remind` → `{ deadline }`. */ + /** `POST|DELETE deadlines//remind`, `POST deadlines//calendar` → `{ deadline }`. */ fun deadlineAnswer(o: JsonObject): DDeadline? = deadline(o.obj("deadline")) /** `GET deadlines` → `{ deadlines }`. */ @@ -1125,6 +1129,97 @@ internal object DriveJson { // ─── helpers ──────────────────────────────────────────────────── + // ─── Drive 1.14: AI in Drive ──────────────────────────────────── + + /** How long a photo search may be. */ + const val PHOTO_Q_MAX = 100 + val SCAN_KINDS = setOf("printed", "handwriting", "table", "whiteboard", "other") + val SCAN_TARGETS = setOf("pdf", "docx", "xlsx") + /** At most this many labels of a photo, each at most [LABEL_MAX] characters (`PUT nodes//labels`). */ + const val LABELS_MAX = 20 + const val LABEL_MAX = 40 + private const val TERMS_MAX = 20 + + /** + * Words of an answer: `terms` (or `chips`, `labels`) as strings or `{ term | label | name | text }`, trimmed, without + * repeats, at most [TERMS_MAX]. + */ + fun terms(o: JsonObject): List { + val a = o.arr("terms") ?: o.arr("chips") ?: o.arr("labels") ?: return emptyList() + return a.mapNotNull { e -> + when { + e.isJsonPrimitive -> runCatching { e.asString }.getOrNull() + e.isJsonObject -> e.asJsonObject.let { it.str("term") ?: it.str("label") ?: it.str("name") ?: it.str("text") } + else -> null + }?.trim()?.take(60)?.ifEmpty { null } + }.distinctBy { it.lowercase() }.take(TERMS_MAX) + } + + fun askBody(q: String, area: String?) = JsonObject().apply { + addProperty("q", q.trim().take(ASK_MAX)) + if (area != null) addProperty("area", area) + } + + /** How long a question may be. */ + const val ASK_MAX = 500 + + /** `POST ask` → `{ status, answer, sources, terms }`. */ + fun answer(o: JsonObject): DAnswer? { + val status = o.str("status")?.takeIf { it == DAnswer.STATUS_ANSWERED || it == DAnswer.STATUS_NOT_FOUND } ?: return null + val sources = o.arr("sources")?.mapNotNull { e -> + val s = e as? JsonObject ?: return@mapNotNull null + DSource( + nodeId = s.long("nodeId")?.takeIf { it > 0 } ?: return@mapNotNull null, + name = s.str("name")?.trim()?.take(255)?.ifEmpty { null } ?: return@mapNotNull null, + path = s.str("path")?.trim()?.take(2000)?.ifEmpty { null }, + page = s.int("page")?.takeIf { it > 0 }, + quote = s.str("quote")?.trim()?.take(600)?.ifEmpty { null }, + ) + }.orEmpty().distinctBy { it.nodeId to it.page }.take(8) + return DAnswer(status, o.str("answer")?.trim()?.take(4000)?.ifEmpty { null }, sources, terms(o)) + } + + fun classifyBody(mediaIds: List, text: String?) = JsonObject().apply { + add("mediaIds", JsonArray().apply { mediaIds.forEach { add(it) } }) + if (!text.isNullOrBlank()) addProperty("text", text.take(TEXT_MAX)) + } + + /** `POST scans/classify` → `{ kind, confidence, summary }` (an unknown kind is `other`). */ + fun scanKind(o: JsonObject): DScanKind? { + val kind = o.str("kind") ?: return null + return DScanKind( + kind = kind.takeIf { it in SCAN_KINDS } ?: "other", + confidence = o.prim("confidence")?.let { runCatching { it.asDouble }.getOrNull() }?.takeIf { it.isFinite() }?.coerceIn(0.0, 1.0), + summary = o.str("summary")?.trim()?.take(200)?.ifEmpty { null }, + ) + } + + fun convertBody(mediaIds: List, target: String, kind: String, folderId: Long?, name: String?, keepOriginal: Boolean) = JsonObject().apply { + add("mediaIds", JsonArray().apply { mediaIds.forEach { add(it) } }) + addProperty("target", target) + addProperty("kind", kind) + if (folderId != null) addProperty("folderId", folderId) + if (!name.isNullOrBlank()) addProperty("name", name.take(255)) + addProperty("keepOriginal", keepOriginal) + } + + /** `POST scans/convert` → `{ node, original? }`. */ + fun converted(o: JsonObject): DConverted? { + val node = node(o.obj("node")) ?: return null + return DConverted(node, node(o.obj("original"))) + } + + /** `PUT nodes//labels { labels, source: 'device' }`: at most [LABELS_MAX] labels of at most [LABEL_MAX] characters. */ + fun labelsBody(labels: List) = JsonObject().apply { + add( + "labels", + JsonArray().apply { + labels.map { it.trim().take(LABEL_MAX) }.filter { it.isNotEmpty() }.distinctBy { it.lowercase() }.take(LABELS_MAX).forEach { add(it) } + }, + ) + addProperty("source", "device") + } + private fun JsonObject.prim(key: String): JsonElement? = get(key)?.takeIf { it.isJsonPrimitive } fun JsonObject.str(key: String): String? = prim(key)?.let { runCatching { it.asString }.getOrNull() } fun JsonObject.long(key: String): Long? = prim(key)?.let { runCatching { it.asNumber.toLong() }.getOrNull() } diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveKi.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveKi.kt new file mode 100644 index 00000000..78aa788e --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveKi.kt @@ -0,0 +1,233 @@ +package com.gatecontrol.android.drive + +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * The pure rules of AI in Drive (Drive 1.14): what a scan may become and how it is named (D5), the filing suggestion + * after an upload (D4), which deadline an answer speaks of and which deadlines are new (D1, D2), the terms of a photo + * search and the labels a photo gets (D3), and what the phone itself reads off a scan (the table of the preview). + * Nothing here talks to the network or Android. + */ +object DriveKi { + + // ─── D5: a scan becomes a document ───────────────────────────── + + /** What a scan can be saved as, in the order of the chips. */ + enum class Format(val ext: String, val mime: String, val target: String?) { + /** A searchable PDF, made on the phone (no AI). */ + PDF("pdf", "application/pdf", null), + DOCX("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"), + XLSX("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"), + /** As before: the pictures only. */ + IMAGE("jpg", "image/jpeg", null), + } + + const val KIND_PRINTED = "printed" + const val KIND_HANDWRITING = "handwriting" + const val KIND_TABLE = "table" + const val KIND_WHITEBOARD = "whiteboard" + const val KIND_OTHER = "other" + + /** + * The chips for a scan of [kind]: PDF and the picture always; Word for text, handwriting, a whiteboard and a table; + * Excel only for a table. Without the AI ([ai] false: no `scanDocs`, the area without AI, the recognition failed) + * only PDF and the picture. + */ + fun formats(kind: String?, ai: Boolean): List = buildList { + add(Format.PDF) + if (ai && kind in setOf(KIND_PRINTED, KIND_HANDWRITING, KIND_WHITEBOARD, KIND_TABLE)) add(Format.DOCX) + if (ai && kind == KIND_TABLE) add(Format.XLSX) + add(Format.IMAGE) + } + + /** The chip chosen first: Excel for a table, Word for handwriting and a whiteboard, else the server's setting. */ + fun defaultFormat(kind: String?, ai: Boolean, scanFormat: String?): Format { + val offered = formats(kind, ai) + val wanted = when (kind) { + KIND_TABLE -> Format.XLSX + KIND_HANDWRITING, KIND_WHITEBOARD -> Format.DOCX + KIND_PRINTED -> Format.PDF + else -> if (scanFormat == "image") Format.IMAGE else Format.PDF + } + return wanted.takeIf { it in offered } ?: Format.PDF + } + + private val BAD_NAME = Regex("[\\\\/:*?\"<>|\\p{Cntrl}]+") + private val SPACES = Regex("\\s+") + private const val NAME_MAX = 80 + + /** A name without what a file name may not hold, at most [NAME_MAX] characters; null when nothing is left. */ + fun cleanName(s: String?): String? = + s?.replace(BAD_NAME, " ")?.replace(SPACES, " ")?.trim()?.trim('.', ' ', '-')?.take(NAME_MAX)?.trim()?.ifEmpty { null } + + /** + * The scan's title: the first line the phone read that looks like one (3…60 characters, with letters, not only a + * date or a number); null when there is none. + */ + fun title(lines: List): String? = lines.asSequence() + .map { it.trim() } + .filter { it.length in 3..60 && it.count(Char::isLetter) >= 3 } + .mapNotNull { cleanName(it) } + .firstOrNull() + + /** "2026-11-03 Sprint-Planung KW 45" from the title, else [fallback] ("Scan 2026-11-03 09.14"). */ + fun scanName(title: String?, day: LocalDate, fallback: String): String = + cleanName(title)?.let { "${day.format(DateTimeFormatter.ISO_LOCAL_DATE)} $it" } ?: fallback + + /** [base] with the extension of [format] (a typed extension is replaced). */ + fun fileName(base: String, format: Format): String { + val clean = cleanName(base) ?: "Scan" + val known = Format.entries.map { it.ext } + listOf("jpeg", "png") + val stem = clean.substringBeforeLast('.', clean).takeIf { clean.substringAfterLast('.', "").lowercase() in known } ?: clean + return "$stem.${format.ext}" + } + + /** A line the phone read, with its box in the picture's pixels. */ + data class OcrLine(val text: String, val left: Int, val top: Int, val right: Int, val bottom: Int) { + val height: Int get() = (bottom - top).coerceAtLeast(1) + val centerY: Float get() = (top + bottom) / 2f + val centerX: Float get() = (left + right) / 2f + } + + /** The beginning of the text for the Word preview: at most [max] non-empty lines. */ + fun docPreview(text: String?, max: Int = 8): List = + text.orEmpty().lines().map { it.trim() }.filter { it.isNotEmpty() }.take(max) + + /** + * A table read off the lines of a scan, for the preview (the AI builds the real one): lines whose middles lie at + * about the same height form a row; the row with most cells gives the columns, every other cell goes to the + * nearest one. At most [maxRows] rows and [maxCols] columns; empty when it is no table (fewer than two columns). + */ + fun tableGrid(lines: List, maxRows: Int = 5, maxCols: Int = 5): List> { + val cells = lines.filter { it.text.isNotBlank() }.sortedBy { it.centerY } + if (cells.size < 4) return emptyList() + val typical = cells.map { it.height }.sorted()[cells.size / 2].toFloat() + val rows = ArrayList>() + for (c in cells) { + val row = rows.lastOrNull() + if (row != null && kotlin.math.abs(row.map { it.centerY }.average().toFloat() - c.centerY) <= typical * 0.6f) row += c else rows += mutableListOf(c) + } + val anchors = rows.maxByOrNull { it.size }?.sortedBy { it.left }?.map { it.centerX } ?: return emptyList() + if (anchors.size < 2) return emptyList() + val cols = anchors.size.coerceAtMost(maxCols) + return rows.filter { it.size >= 2 || rows.size <= maxRows }.take(maxRows).map { row -> + val out = MutableList(cols) { "" } + row.sortedBy { it.left }.forEach { cell -> + val i = anchors.indices.minByOrNull { kotlin.math.abs(anchors[it] - cell.centerX) } ?: return@forEach + if (i < cols) out[i] = listOf(out[i], cell.text.trim()).filter { it.isNotEmpty() }.joinToString(" ") + } + out + } + } + + // ─── D4: filing right after the upload ───────────────────────── + + /** The suggestion bar: the new [name] and / or [folderId] (with its path), and a payment to be reminded of. */ + data class Filing(val name: String?, val folderId: Long?, val folderPath: String?, val payment: DDeadline?) + + /** The bar for [insight], when it is ready and suggests a name or a folder; the payment only when still ahead and not reminded. */ + fun filing(insight: DInsight, today: LocalDate): Filing? { + if (insight.status != "ready") return null + val s = insight.suggestion ?: return null + if (s.name == null && s.folderId == null) return null + val payment = insight.deadlines + .filter { it.kind == "payment" && it.reminder == null && (DriveLogic.daysUntil(it.date, today) ?: -1) >= 0 } + .minByOrNull { it.date } + return Filing(s.name, s.folderId, s.folderPath, payment) + } + + /** Asked again after these pauses (about a minute in all), then the bar stays away. */ + val FILING_POLL_MS = listOf(3_000L, 4_000L, 5_000L, 8_000L, 10_000L, 12_000L, 18_000L) + + /** Whether an upload is waited for: a scan, or a document (no photo, video or music, no vault). */ + fun fileable(node: DNode, scan: Boolean): Boolean { + if (node.isDir || node.encrypted) return false + if (scan) return true + return when (DriveLogic.kind(node)) { + DriveLogic.Kind.IMAGE, DriveLogic.Kind.VIDEO, DriveLogic.Kind.AUDIO, DriveLogic.Kind.ARCHIVE -> false + else -> true + } + } + + // ─── D2: deadlines ───────────────────────────────────────────── + + /** The deadlines not there at the last visit ("neu"); none at the very first one (nothing to compare with). */ + fun fresh(ids: Collection, seen: Set?): Set = if (seen == null) emptySet() else ids.toSet() - seen + + /** "In den Familienkalender" makes sense for a deadline still ahead. */ + fun calendarOpen(d: DDeadline, today: LocalDate): Boolean = (DriveLogic.daysUntil(d.date, today) ?: -1) >= 0 + + // ─── D1: questions ───────────────────────────────────────────── + + /** A question rather than a file name: it ends with a question mark. */ + fun isQuestion(q: String): Boolean = q.trim().let { it.length >= 3 && (it.endsWith('?') || it.endsWith('?')) } + + private val DATE_DOTTED = Regex("(? = + (DATE_DOTTED.findAll(text) + DATE_ISO.findAll(text)).map { it.range }.sortedBy { it.first }.toList() + + /** The days named in [text]; one without a year takes the year of [today] (or the next, when already past). */ + fun datesIn(text: String, today: LocalDate): Set { + val out = HashSet() + DATE_DOTTED.findAll(text).forEach { m -> + val d = m.groupValues[1].toIntOrNull() ?: return@forEach + val mo = m.groupValues[2].toIntOrNull() ?: return@forEach + val y = m.groupValues[3].takeIf { it.isNotEmpty() }?.toIntOrNull()?.let { if (it < 100) 2000 + it else it } + val date = runCatching { LocalDate.of(y ?: today.year, mo, d) }.getOrNull() ?: return@forEach + out += if (y == null && date.isBefore(today)) date.plusYears(1) else date + } + DATE_ISO.findAll(text).forEach { m -> runCatching { LocalDate.parse(m.value) }.getOrNull()?.let { out += it } } + return out + } + + /** + * The deadline of a source the answer speaks of: one whose day the answer names. Still ahead comes first, then + * cancellation and renewal (what the family calendar is for), then the earliest. + */ + fun matchDeadline(answer: String, deadlines: List, today: LocalDate): DDeadline? { + val named = datesIn(answer, today) + if (named.isEmpty()) return null + return deadlines + .filter { d -> runCatching { LocalDate.parse(d.date) }.getOrNull() in named } + .sortedWith( + compareBy({ !calendarOpen(it, today) }, { it.kind !in setOf("cancellation", "renewal") }, { it.date }), + ) + .firstOrNull() + } + + // ─── D3: photos by what is in them ───────────────────────────── + + /** Labels the labeler is at least this sure of go to Drive. */ + const val LABEL_MIN = 0.6f + + /** The labels of a photo for Drive: the surest first, at most 20, each at most 40 characters. */ + fun labels(found: List>, min: Float = LABEL_MIN): List = found + .filter { it.second >= min && it.first.isNotBlank() } + .sortedByDescending { it.second } + .map { it.first.trim().take(DriveJson.LABEL_MAX) } + .distinctBy { it.lowercase() } + .take(DriveJson.LABELS_MAX) + + /** The words of a photo search ("Strand Meer", "Strand + Meer"). */ + fun words(q: String): List = q.split(Regex("[\\s+,]+")).map { it.trim() }.filter { it.isNotEmpty() }.distinctBy { it.lowercase() } + + /** The search with [term] added. */ + fun withTerm(q: String, term: String): String = (words(q) + term.trim()).distinctBy { it.lowercase() }.joinToString(" ").take(DriveJson.PHOTO_Q_MAX) + + /** The search without [term]. */ + fun withoutTerm(q: String, term: String): String = words(q).filterNot { it.equals(term.trim(), ignoreCase = true) }.joinToString(" ") + + /** A chip of the photo search: a word searched for ([on]) or a frequent term of the hits to add. */ + data class Chip(val term: String, val on: Boolean) + + /** The searched words first (on), then the hits' terms not searched yet, at most [max] in all. */ + fun chips(q: String, terms: List, max: Int = 8): List { + val ws = words(q) + val more = terms.filter { t -> ws.none { it.equals(t, ignoreCase = true) } } + return (ws.map { Chip(it, true) } + more.map { Chip(it, false) }).take(max) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveKiModels.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveKiModels.kt new file mode 100644 index 00000000..cdb8e69b --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveKiModels.kt @@ -0,0 +1,33 @@ +package com.gatecontrol.android.drive + +// ─── Drive 1.14: AI in Drive (features `docAsk`, `scanDocs`, `photoSearch`, `deadlineCalendar`) ── + +/** + * `POST ask`: the answer to a question, only from the person's own files (never a vault). [status] `answered` | + * `not_found`; [terms] what Drive searched for ("Gesucht nach: …"). + */ +data class DAnswer( + val status: String, + val answer: String?, + val sources: List, + val terms: List, +) { + val answered: Boolean get() = status == STATUS_ANSWERED && !answer.isNullOrBlank() + + companion object { + const val STATUS_ANSWERED = "answered" + const val STATUS_NOT_FOUND = "not_found" + } +} + +/** Where an answer comes from: the file, its folder path, the page and the words quoted. */ +data class DSource(val nodeId: Long, val name: String, val path: String?, val page: Int?, val quote: String?) + +/** + * `POST scans/classify`: what a scan is — `printed` | `handwriting` | `table` | `whiteboard` | `other` — how sure the + * AI is (0…1) and a short line about it ("3 Spalten, 12 Zeilen, Summenzeile"). + */ +data class DScanKind(val kind: String, val confidence: Double?, val summary: String?) + +/** `POST scans/convert`: the new document and, kept, the original scan. */ +data class DConverted(val node: DNode, val original: DNode?) 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 837fca30..419c7055 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt @@ -102,6 +102,14 @@ data class DMe( * filter `class=work|private|review` and the counts `photos/classes`). */ const val FEATURE_PHOTO_CLASSES = "photoClasses" + /** Drive 1.14: questions to the documents (`POST ask`). */ + const val FEATURE_DOC_ASK = "docAsk" + /** Drive 1.14: a scan becomes a document (`scans/classify`, `scans/convert`: Word, Excel). */ + const val FEATURE_SCAN_DOCS = "scanDocs" + /** Drive 1.14: photos found by what is in them (`PUT nodes//labels`, `GET photos?q=`). */ + const val FEATURE_PHOTO_SEARCH = "photoSearch" + /** Drive 1.14: a deadline into the family calendar (`POST deadlines//calendar`). */ + const val FEATURE_DEADLINE_CALENDAR = "deadlineCalendar" } } @@ -287,7 +295,14 @@ sealed class DUploadOutcome { // ─── Drive 1.7: photos, albums, time travel, storage ──────────────── /** `GET photos` / `GET albums/`: photos and videos, newest first; [next] is the cursor for `before` (null: the end). */ -data class DPhotos(val items: List, val next: String?) +data class DPhotos( + val items: List, + val next: String?, + /** Drive 1.14 `photos?q=`: how many photos match in all (null: not said). */ + val total: Int? = null, + /** Drive 1.14 `photos?q=`: the most frequent terms of the hits (the chips). */ + val terms: List = emptyList(), +) /** `GET photos/months`: how many photos a month (`2026-10`) has. */ data class DMonth(val month: String, val count: Int) @@ -406,7 +421,7 @@ data class DSuggestion(val folderId: Long?, val folderPath: String?, val name: S /** A payment, cancellation or objection deadline of a file; [reminder] is the asking person's own. */ data class DDeadline( val id: Long, - /** `payment` | `cancellation` | `objection`. */ + /** `payment` | `cancellation` | `objection` | `warranty` (end of the warranty) | `renewal` (Drive 1.14: the last day to cancel before a contract renews). */ val kind: String, /** `YYYY-MM-DD`. */ val date: String, @@ -414,8 +429,13 @@ data class DDeadline( val reminder: DReminder?, /** `GET deadlines`: the file it belongs to. */ val node: DNode? = null, + /** Drive 1.14 (`deadlineCalendar`): my entry in the family calendar; null when there is none. */ + val calendar: DCalendarEntry? = null, ) +/** A deadline's entry in the family calendar (Drive 1.14). */ +data class DCalendarEntry(val eventId: String?, val at: String?) + data class DReminder(val at: String?, val daysBefore: Int, val sent: Boolean) /** `POST nodes//insight/apply` → the renamed / moved file and its insight now. */ diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveScanner.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveScanner.kt index d284b058..447bee63 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveScanner.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveScanner.kt @@ -44,12 +44,27 @@ class DriveScanner @Inject constructor( ) /** The recognised text of each page; null for a page without text (or when recognising failed). */ - suspend fun recognize(pages: List): List = withContext(Dispatchers.IO) { + suspend fun recognize(pages: List): List = read(pages).map { it?.text } + + /** + * What the phone read on a page: the [text], and its [lines] with their boxes in a picture of [width] × [height] + * pixels (for the searchable PDF and the table preview, Drive 1.14). + */ + data class Read(val text: String?, val width: Int, val height: Int, val lines: List) + + /** The text of each page with where it stands; null for a page that could not be read. */ + suspend fun read(pages: List): List = withContext(Dispatchers.IO) { val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) try { pages.map { uri -> try { - recognizer.process(InputImage.fromFilePath(context, uri)).await().text.trim().ifEmpty { null } + val image = InputImage.fromFilePath(context, uri) + val result = recognizer.process(image).await() + val lines = result.textBlocks.flatMap { b -> b.lines }.mapNotNull { l -> + val box = l.boundingBox ?: return@mapNotNull null + DriveKi.OcrLine(l.text, box.left, box.top, box.right, box.bottom) + } + Read(result.text.trim().ifEmpty { null }, image.width, image.height, lines) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -77,11 +92,69 @@ class DriveScanner @Inject constructor( } for ((file, name, text) in copies) { file ?: continue - transfers.uploadWritten(file, name, if (asPdf) "application/pdf" else "image/jpeg", folderId, DriveLogic.ON_CONFLICT_RENAME, text) + transfers.uploadWritten(file, name, if (asPdf) "application/pdf" else "image/jpeg", folderId, DriveLogic.ON_CONFLICT_RENAME, text, scan = true) } true } + /** + * The searchable PDF of the scan (Drive 1.14, D5): the scanner's JPEG pages with the lines read on them as + * invisible text ([DriveSearchablePdf]), in the cache; null when a page cannot be read or is no JPEG. + */ + suspend fun searchablePdf(scan: Pages, reads: List): File? = withContext(Dispatchers.IO) { + val copies = scan.pages.map { copy(it) } + try { + if (copies.any { it == null }) return@withContext null + val files = copies.filterNotNull() + val pages = files.mapIndexed { i, f -> + val head = f.inputStream().use { input -> ByteArray(64 * 1024).let { b -> b.copyOf(input.read(b).coerceAtLeast(0)) } } + val info = DriveSearchablePdf.jpegInfo(head) ?: return@withContext null + val r = reads.getOrNull(i) + DriveSearchablePdf.Page(f.length(), info, r?.width ?: 0, r?.height ?: 0, r?.lines.orEmpty()) + } + val dir = File(context.cacheDir, "drive-scan").apply { mkdirs() } + val out = File.createTempFile("scan", ".pdf", dir) + val paint = android.graphics.Paint().apply { typeface = android.graphics.Typeface.SANS_SERIF; textSize = 100f } + try { + out.outputStream().buffered().use { o -> + DriveSearchablePdf.write(o, pages, open = { files[it].inputStream() }, measure = { t -> paint.measureText(t) / 100.0 }) + } + out + } catch (e: Exception) { + if (e is CancellationException) throw e + Timber.d("Drive scan: no PDF made (%s)", e.javaClass.simpleName) + out.delete() + null + } + } finally { + copies.forEach { it?.delete() } + } + } + + /** The scan's pages as media of Drive (for `scans/classify` and `scans/convert`, Drive 1.14); in order. */ + suspend fun uploadPages(scan: Pages, api: DriveApi): DResult> = withContext(Dispatchers.IO) { + val out = ArrayList() + for (uri in scan.pages) { + val file = copy(uri) ?: return@withContext DResult.Failed("scan_read", 0) + try { + when (val r = api.upload(file, "image/jpeg")) { + is DResult.Ok -> out += r.value + else -> return@withContext r.notOk() + } + } finally { + file.delete() + } + } + DResult.Ok(out) + } + + /** The scan as the pictures of its pages, each with its text (as before Drive 1.14), named after [base]. */ + suspend fun savePictures(scan: Pages, texts: List, base: String, folderId: Long): Boolean = save(scan, texts, base, pdf = false, folderId = folderId) + + /** A file made here (the searchable PDF) goes up into [folderId] as [name], with its [text]; [onDone] hears the new file. */ + fun uploadMade(file: File, name: String, mime: String, folderId: Long, text: String?, onDone: (DNode?) -> Unit) = + transfers.uploadWritten(file, name, mime, folderId, DriveLogic.ON_CONFLICT_RENAME, text, onDone = onDone, scan = true) + /** The scanner's file in the cache, handed over to the upload (which deletes it); null when it cannot be read. */ private fun copy(uri: Uri): File? { val dir = File(context.cacheDir, "drive-scan").apply { mkdirs() } diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveSearchablePdf.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveSearchablePdf.kt new file mode 100644 index 00000000..c1b387d4 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveSearchablePdf.kt @@ -0,0 +1,205 @@ +package com.gatecontrol.android.drive + +import java.io.InputStream +import java.io.OutputStream +import java.nio.charset.Charset +import java.util.Locale + +/** + * A searchable PDF of scanned pages, made on the phone without any AI (Drive 1.14, D5 "printed"): each page is the + * scanner's JPEG as it is (`DCTDecode`, nothing re-encoded), and over it the lines the phone read, as invisible text + * (`3 Tr`) where they stand in the picture — the PDF looks like the scan, but can be searched and copied. The text uses + * the standard font Helvetica (WinAnsi; what it cannot show becomes "?"), stretched with `Tz` to the width of the line. + * Written straight to a stream (a scan has up to 30 pages of a few MB), unit-tested. + */ +internal object DriveSearchablePdf { + + /** A JPEG's size in pixels and its colour components (1 grey, 3 RGB, 4 CMYK). */ + data class JpegInfo(val width: Int, val height: Int, val components: Int) + + /** + * One page: its JPEG ([length] bytes, [info]) and the lines read off it, their boxes in a picture of + * [ocrWidth] × [ocrHeight] pixels (the text recognition may have read a scaled picture). + */ + data class Page(val length: Long, val info: JpegInfo, val ocrWidth: Int, val ocrHeight: Int, val lines: List) + + /** The size and colours from a JPEG's start (its SOF segment); null when it is no JPEG this writer can embed. */ + fun jpegInfo(head: ByteArray): JpegInfo? { + fun u8(i: Int) = head[i].toInt() and 0xFF + fun u16(i: Int) = (u8(i) shl 8) or u8(i + 1) + if (head.size < 4 || u8(0) != 0xFF || u8(1) != 0xD8) return null + var i = 2 + while (i + 3 < head.size) { + if (u8(i) != 0xFF) return null + val marker = u8(i + 1) + if (marker == 0xFF) { i++; continue } + if (marker == 0xD8 || marker in 0xD0..0xD7 || marker == 0x01) { i += 2; continue } + if (marker == 0xD9 || marker == 0xDA) return null + val len = u16(i + 2) + if (len < 2) return null + val sof = marker in 0xC0..0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC + if (sof) { + if (i + 9 >= head.size) return null + val h = u16(i + 5) + val w = u16(i + 7) + val c = u8(i + 9) + return if (w > 0 && h > 0 && c in setOf(1, 3, 4)) JpegInfo(w, h, c) else null + } + i += 2 + len + } + return null + } + + /** Width of an A4 page in points; a landscape scan gets the A4 height as its width. */ + private const val A4_SHORT = 595.28 + private const val A4_LONG = 841.89 + private val WIN_ANSI: Charset = Charset.forName("windows-1252") + + /** The page's size in points: A4 wide (landscape: A4 long), as high as the picture says. */ + fun pageSize(info: JpegInfo): Pair { + val w = if (info.width > info.height) A4_LONG else A4_SHORT + return w to w * info.height / info.width + } + + /** + * Writes the PDF of [pages] to [out]: [open] gives page i's JPEG (exactly its length), [measure] the width of a + * text at font size 1 in Helvetica-like letters (used to stretch the invisible line to its box). + */ + fun write(out: OutputStream, pages: List, open: (Int) -> InputStream, measure: (String) -> Double = ::roughWidth) { + require(pages.isNotEmpty()) + val w = Counting(out) + val offsets = ArrayList() + fun obj(n: Int, body: () -> Unit) { + while (offsets.size < n) offsets += 0L + offsets[n - 1] = w.count + w.ascii("$n 0 obj\n") + body() + w.ascii("\nendobj\n") + } + w.ascii("%PDF-1.4\n") + w.bytes(byteArrayOf('%'.code.toByte(), 0xE2.toByte(), 0xE3.toByte(), 0xCF.toByte(), 0xD3.toByte(), '\n'.code.toByte())) + // 1 catalog, 2 pages, 3 font, 4 info; page i: 5+3i page, 6+3i picture, 7+3i content + val kids = pages.indices.joinToString(" ") { "${5 + 3 * it} 0 R" } + obj(1) { w.ascii("<< /Type /Catalog /Pages 2 0 R >>") } + obj(2) { w.ascii("<< /Type /Pages /Kids [$kids] /Count ${pages.size} >>") } + obj(3) { w.ascii("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") } + obj(4) { w.ascii("<< /Producer (GateControl) >>") } + pages.forEachIndexed { i, p -> + val (pw, ph) = pageSize(p.info) + val page = 5 + 3 * i + obj(page) { + w.ascii( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${num(pw)} ${num(ph)}] " + + "/Resources << /XObject << /Im0 ${page + 1} 0 R >> /Font << /F1 3 0 R >> >> /Contents ${page + 2} 0 R >>", + ) + } + obj(page + 1) { + val cs = when (p.info.components) { + 1 -> "/DeviceGray" + 4 -> "/DeviceCMYK /Decode [1 0 1 0 1 0 1 0]" + else -> "/DeviceRGB" + } + w.ascii( + "<< /Type /XObject /Subtype /Image /Width ${p.info.width} /Height ${p.info.height} /ColorSpace $cs " + + "/BitsPerComponent 8 /Filter /DCTDecode /Length ${p.length} >>\nstream\n", + ) + var copied = 0L + open(i).use { input -> + val buf = ByteArray(64 * 1024) + while (true) { + val n = input.read(buf) + if (n < 0) break + val take = minOf(n.toLong(), p.length - copied).toInt() + if (take > 0) w.bytes(buf, take) + copied += take + if (copied >= p.length) break + } + } + require(copied == p.length) { "picture shorter than announced" } + w.ascii("\nendstream") + } + obj(page + 2) { + val content = content(p, pw, ph, measure) + w.ascii("<< /Length ${content.size} >>\nstream\n") + w.bytes(content) + w.ascii("\nendstream") + } + } + val xref = w.count + w.ascii("xref\n0 ${offsets.size + 1}\n0000000000 65535 f \n") + offsets.forEach { w.ascii(String.format(Locale.ROOT, "%010d 00000 n \n", it)) } + w.ascii("trailer\n<< /Size ${offsets.size + 1} /Root 1 0 R /Info 4 0 R >>\nstartxref\n$xref\n%%EOF\n") + w.flush() + } + + /** The page's drawing: the picture over the whole page, then each line as invisible text at its place. */ + fun content(p: Page, pw: Double, ph: Double, measure: (String) -> Double = ::roughWidth): ByteArray { + val s = java.io.ByteArrayOutputStream() + fun a(t: String) = s.write(t.toByteArray(Charsets.US_ASCII)) + a("q ${num(pw)} 0 0 ${num(ph)} 0 0 cm /Im0 Do Q\n") + if (p.ocrWidth <= 0 || p.ocrHeight <= 0) return s.toByteArray() + val sx = pw / p.ocrWidth + val sy = ph / p.ocrHeight + val lines = p.lines.filter { it.text.isNotBlank() && it.right > it.left && it.bottom > it.top } + if (lines.isEmpty()) return s.toByteArray() + a("BT 3 Tr\n") + for (l in lines) { + val text = l.text.trim() + val h = (l.bottom - l.top) * sy + val size = (h * 0.85).coerceAtLeast(1.0) + val natural = measure(text) * size + val target = (l.right - l.left) * sx + val stretch = if (natural > 0) (100.0 * target / natural).coerceIn(10.0, 1000.0) else 100.0 + val x = l.left * sx + val y = ph - l.bottom * sy + h * 0.2 + a("/F1 ${num(size)} Tf ${num(stretch)} Tz 1 0 0 1 ${num(x)} ${num(y)} Tm (") + s.write(escape(text)) + a(") Tj\n") + } + a("ET\n") + return s.toByteArray() + } + + /** [text] as a PDF string in WinAnsi: `\`, `(` and `)` escaped, other bytes outside ASCII as octal. */ + fun escape(text: String): ByteArray { + val enc = WIN_ANSI.newEncoder() + val out = java.io.ByteArrayOutputStream() + for (ch in text) { + val c = if (ch.isISOControl()) ' ' else ch + val b = if (enc.canEncode(c)) c.toString().toByteArray(WIN_ANSI).firstOrNull() ?: '?'.code.toByte() else '?'.code.toByte() + val v = b.toInt() and 0xFF + when { + v == '\\'.code || v == '('.code || v == ')'.code -> { out.write('\\'.code); out.write(v) } + v in 0x20..0x7E -> out.write(v) + else -> out.write(String.format(Locale.ROOT, "\\%03o", v).toByteArray(Charsets.US_ASCII)) + } + } + return out.toByteArray() + } + + /** About Helvetica's widths at size 1: narrow, wide and the rest. */ + fun roughWidth(text: String): Double = text.sumOf { c -> + when { + c in "iljtfrI.,:;'!|" -> 0.28 + c in "mwMW" -> 0.85 + c.isUpperCase() -> 0.67 + c == ' ' -> 0.28 + else -> 0.55 + } + } + + private fun num(v: Double): String = String.format(Locale.ROOT, "%.2f", v).trimEnd('0').trimEnd('.') + + /** Counts what goes out (the offsets of the cross-reference table). */ + private class Counting(private val out: OutputStream) { + var count = 0L + private set + + fun ascii(s: String) = bytes(s.toByteArray(Charsets.US_ASCII)) + fun bytes(b: ByteArray, n: Int = b.size) { + out.write(b, 0, n) + count += n + } + fun flush() = out.flush() + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt index dfa9a3e5..f9ec199c 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt @@ -77,8 +77,14 @@ class DriveTransfers @Inject constructor( val text: String? = null, /** An empty file is dropped unless the server takes empty files (a "save as" closed without content). */ val dropEmpty: Boolean = false, + /** Drive 1.14 (D4): the new file may get the filing suggestion; [scan]: it is a scan from the app. */ + val propose: Boolean = false, + val scan: Boolean = false, ) + /** A file this device uploaded that may get the filing suggestion (D4): the person chose it, or a scan. */ + data class Committed(val node: DNode, val scan: Boolean) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler) private val mutex = Mutex() private val ids = AtomicLong(0) @@ -115,6 +121,10 @@ class DriveTransfers @Inject constructor( } } + private val _committed = MutableSharedFlow(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST) + /** Files the person uploaded here (picked or scanned), once they are in Drive ([DriveFiling]). */ + val committed: SharedFlow = _committed.asSharedFlow() + /** A folder whose content changed through an upload here (reload it). */ private val _changed = MutableSharedFlow(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST) val changed: SharedFlow = _changed.asSharedFlow() @@ -136,7 +146,7 @@ class DriveTransfers @Inject constructor( local.hold(uri) val id = ids.incrementAndGet() val (name, size) = describe(uri) - queue.addLast(Job(id, uri, folderId, name = name)) + queue.addLast(Job(id, uri, folderId, name = name, propose = true)) _uploads.update { it + Upload(id, name, folderId, size) } } } @@ -157,6 +167,7 @@ class DriveTransfers @Inject constructor( onExists: String, text: String? = null, dropEmpty: Boolean = false, + scan: Boolean = false, onDone: (DNode?) -> Unit = {}, ) { scope.launch { @@ -166,7 +177,7 @@ class DriveTransfers @Inject constructor( queue.addLast( Job( id, null, folderId, file = file, name = name, mime = mime, mtime = System.currentTimeMillis(), sha = sha, - onExists = onExists, onDone = onDone, text = text, dropEmpty = dropEmpty, + onExists = onExists, onDone = onDone, text = text, dropEmpty = dropEmpty, propose = scan, scan = scan, ), ) _uploads.update { it + Upload(id, name, folderId, file.length()) } @@ -288,6 +299,7 @@ class DriveTransfers @Inject constructor( file.delete() set(job.id) { it.copy(state = State.DONE, sent = it.total) } _changed.tryEmit(job.folderId) + if (job.propose && r.value.second) _committed.tryEmit(Committed(r.value.first, job.scan)) job.onDone?.invoke(r.value.first) } is DResult.Failed -> if (r.code == DriveJson.DEVICE_LOCKED) pause(job, choice) else fail(job, r.code) diff --git a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackup.kt b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackup.kt index fcc07d6c..88319835 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackup.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackup.kt @@ -55,6 +55,7 @@ class DriveBackup @Inject constructor( private val camera: DriveCameraUploads, private val work: com.gatecontrol.android.drive.work.DriveWorkPhotos, private val wifi: com.gatecontrol.android.drive.work.DriveWorkWifi, + private val labels: DrivePhotoLabels, ) { /** The run in progress: [done] of [total] photos, [current] the one going up. */ data class Status(val running: Boolean = false, val done: Int = 0, val total: Int = 0, val current: String? = null) @@ -357,6 +358,9 @@ class DriveBackup @Inject constructor( return when (val r = api.commit(media.value, folder, item.name, item.mtime, DriveLogic.ON_CONFLICT_RENAME, takenAt = takenAt)) { is DResult.Ok -> { placed?.let { p -> runCatching { work.afterBackup(r.value.first, p, item.id, item.video) }.onFailure { if (it is CancellationException) throw it } } + // Drive 1.14 (`photoSearch`): what is in the photo, found on the phone (the thumbnail, no location data) + val plain = ContentUris.withAppendedId(if (item.video) MediaStore.Video.Media.EXTERNAL_CONTENT_URI else MediaStore.Images.Media.EXTERNAL_CONTENT_URI, item.id) + runCatching { labels.afterBackup(r.value.first, plain, item.video) }.onFailure { if (it is CancellationException) throw it } null } else -> { diff --git a/app/src/main/java/com/gatecontrol/android/drive/backup/DrivePhotoLabels.kt b/app/src/main/java/com/gatecontrol/android/drive/backup/DrivePhotoLabels.kt new file mode 100644 index 00000000..13596597 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/backup/DrivePhotoLabels.kt @@ -0,0 +1,229 @@ +package com.gatecontrol.android.drive.backup + +import android.content.Context +import android.net.Uri +import androidx.core.content.edit +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.drive.DMe +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DResult +import com.gatecontrol.android.drive.DriveApi +import com.gatecontrol.android.drive.DriveKi +import com.gatecontrol.android.drive.DriveLive +import com.gatecontrol.android.drive.DriveLogic +import com.gatecontrol.android.drive.DriveSettings +import com.gatecontrol.android.drive.work.DriveWorkLabeler +import com.gatecontrol.android.familie.PluginDirectory +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import java.io.File +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * "Fotos nach Inhalt finden" (Drive 1.14, D3, feature `photoSearch`): what is in a photo — found on the phone by ML Kit's + * image labeling ([DriveWorkLabeler], the same as for the work photos; no picture leaves the phone for it) — goes to + * Drive as words (`PUT nodes//labels`, ML Kit's English labels; Drive knows them in German too). A photo gets them + * when the backup sends it ([afterBackup]); photos already in Drive without them (sent before, or from the PC) get them + * later from their preview ([backfill], in the background under the backup's conditions, a few hundred a run). The + * photos this phone labeled are remembered, so none is labeled twice. + */ +@Singleton +class DrivePhotoLabels @Inject constructor( + @ApplicationContext private val context: Context, + private val api: DriveApi, + private val live: DriveLive, + private val labeler: DriveWorkLabeler, + private val setup: SetupRepository, +) { + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val lock = Mutex() + private val file = File(context.filesDir, FILE) + @Volatile private var done: MutableSet? = null + + private fun owner() = PluginDirectory.ownerKey(setup.getServerUrl(), setup.getApiToken()) + + /** The ids of the photos labeled for this login (read once). */ + private fun doneSet(): MutableSet { + done?.let { return it } + val set: MutableSet = java.util.concurrent.ConcurrentHashMap.newKeySet() + if (prefs.getString(KEY_OWNER, null) == owner()) { + runCatching { file.forEachLine { l -> l.trim().toLongOrNull()?.let(set::add) } } + } else { + file.delete() + prefs.edit { + clear() + putString(KEY_OWNER, owner()) + } + } + done = set + return set + } + + private fun remember(id: Long) { + val set = doneSet() + if (set.add(id)) runCatching { file.appendText("$id\n") } + } + + private suspend fun enabled(): Boolean = live.supports(DMe.FEATURE_PHOTO_SEARCH) + + /** The photo [node] the backup just sent from [uri] gets its labels (best effort). */ + suspend fun afterBackup(node: DNode, uri: Uri, video: Boolean) { + if (video || node.encrypted || !enabled()) return + val found = labeler.namesOfUri(uri) ?: return + send(node.id, found) + } + + private suspend fun send(id: Long, found: List>): Boolean = lock.withLock { + val labels = DriveKi.labels(found) + // nothing sure enough: an empty list says "looked at" all the same + when (val r = api.putLabels(id, labels)) { + is DResult.Ok -> { remember(id); true } + // gone, not mine to change, a vault, no such feature: never again + is DResult.Failed -> { if (r.code in FINAL) remember(id); r.code in FINAL } + else -> false + } + } + + enum class Outcome { DONE, MORE, RETRY } + + /** + * Labels up to [budget] photos in Drive that this phone has not labeled yet, from their previews, going on where the + * last run stopped; a whole pass through the photos comes at most once a day. + */ + suspend fun backfill(budget: Int = BUDGET): Outcome { + if (!enabled()) return Outcome.DONE + doneSet() + var cursor = prefs.getString(KEY_CURSOR, null) + var labeled = 0 + var pages = 0 + while (labeled < budget && pages < MAX_PAGES) { + val r = api.photos(before = cursor, limit = PAGE) + val page = when (r) { + is DResult.Ok -> r.value + DResult.Offline -> return Outcome.RETRY + else -> return Outcome.DONE + } + pages++ + for (n in page.items) { + if (labeled >= budget) break + if (n.id in doneSet() || n.encrypted || !n.preview || DriveLogic.kind(n) != DriveLogic.Kind.IMAGE) continue + val found = try { + labeler.namesOfNode(n) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + if (found == null) { + // no preview to read: skipped for good + remember(n.id) + continue + } + if (!send(n.id, found)) return Outcome.RETRY + labeled++ + } + if (labeled >= budget) break + cursor = page.next + prefs.edit { putString(KEY_CURSOR, cursor) } + if (cursor == null) { + prefs.edit { putLong(KEY_PASS_AT, System.currentTimeMillis()) } + Timber.i("Drive labels: a pass through the photos is done (%d now)", labeled) + return Outcome.DONE + } + } + return Outcome.MORE + } + + /** Whether a new pass is due (the last whole one is more than a day ago, or a pass is half done). */ + fun due(): Boolean { + doneSet() + return prefs.getString(KEY_CURSOR, null) != null || System.currentTimeMillis() - prefs.getLong(KEY_PASS_AT, 0L) > PASS_GAP_MS + } + + companion object { + private const val PREFS = "drive_photo_labels" + private const val FILE = "drive_photo_labels.txt" + private const val KEY_OWNER = "owner" + private const val KEY_CURSOR = "cursor" + private const val KEY_PASS_AT = "pass_at" + private const val PAGE = 120 + private const val MAX_PAGES = 30 + const val BUDGET = 200 + private const val PASS_GAP_MS = 24 * 3_600_000L + private val FINAL = setOf("not_found", "forbidden", "encrypted", "invalid", "feature_off") + } +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface DrivePhotoLabelsEntryPoint { + fun drivePhotoLabels(): DrivePhotoLabels + fun driveBackup(): DriveBackup +} + +/** One run of [DrivePhotoLabels.backfill]; a run with more to do asks for the next one. */ +class DrivePhotoLabelsWorker(appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + val labels = EntryPointAccessors.fromApplication(applicationContext, DrivePhotoLabelsEntryPoint::class.java).drivePhotoLabels() + return when (labels.backfill()) { + DrivePhotoLabels.Outcome.DONE -> Result.success() + DrivePhotoLabels.Outcome.MORE -> Result.success().also { DrivePhotoLabelsWork.next(applicationContext) } + DrivePhotoLabels.Outcome.RETRY -> if (runAttemptCount < 5) Result.retry() else Result.success() + } + } +} + +/** Scheduling of the labels' backfill: under the photo backup's conditions (WLAN, charging), quietly. */ +object DrivePhotoLabelsWork { + private const val RUN = "drive-photo-labels" + + private fun constraints(context: Context): Constraints { + val cfg = runCatching { EntryPointAccessors.fromApplication(context, DrivePhotoLabelsEntryPoint::class.java).driveBackup().config.value }.getOrNull() + ?: DriveBackupConfig() + return Constraints.Builder() + .setRequiredNetworkType(if (cfg.wifiOnly || DriveSettings.wifiOnly(context)) NetworkType.UNMETERED else NetworkType.CONNECTED) + .setRequiresCharging(cfg.chargingOnly) + .setRequiresBatteryNotLow(true) + .build() + } + + /** The Photos page opened with `photoSearch`: a run when one is due (an existing one is kept). */ + fun request(context: Context) { + runCatching { + val labels = EntryPointAccessors.fromApplication(context, DrivePhotoLabelsEntryPoint::class.java).drivePhotoLabels() + if (!labels.due()) return + enqueue(context, ExistingWorkPolicy.KEEP, 0) + }.onFailure { Timber.w("Drive labels: not scheduled (%s)", it.javaClass.simpleName) } + } + + /** The next part of the pass, a little later. */ + fun next(context: Context) { + runCatching { enqueue(context, ExistingWorkPolicy.APPEND_OR_REPLACE, 2) } + } + + private fun enqueue(context: Context, policy: ExistingWorkPolicy, delayMinutes: Long) { + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints(context)) + .setInitialDelay(delayMinutes, TimeUnit.MINUTES) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.MINUTES) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(RUN, policy, request) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkLabeler.kt b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkLabeler.kt index d036d351..c94f815b 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkLabeler.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/work/DriveWorkLabeler.kt @@ -9,6 +9,7 @@ import com.gatecontrol.android.drive.DriveNodeResolver import com.gatecontrol.android.drive.DrivePreviews import com.google.android.gms.tasks.Task import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.label.ImageLabel import com.google.mlkit.vision.label.ImageLabeler import com.google.mlkit.vision.label.ImageLabeling import com.google.mlkit.vision.label.defaults.ImageLabelerOptions @@ -45,7 +46,22 @@ class DriveWorkLabeler @Inject constructor( ).also { labeler = it } /** The labels of a photo on the phone; null when it cannot be read or labeled (Play services missing, …). */ - suspend fun ofUri(uri: Uri): Map? = withContext(Dispatchers.IO) { + suspend fun ofUri(uri: Uri): Map? = labelsOfUri(uri)?.associate { it.index to it.confidence } + + /** The labels of a photo in Drive (its preview); null without one. */ + suspend fun ofNode(node: DNode): Map? = labelsOfNode(node)?.associate { it.index to it.confidence } + + /** What is in a photo on the phone, in words (ML Kit's English labels, Drive 1.14 `photoSearch`), with how sure. */ + suspend fun namesOfUri(uri: Uri): List>? = labelsOfUri(uri)?.map { it.text to it.confidence } + + /** What is in a photo in Drive (its preview), in words. */ + suspend fun namesOfNode(node: DNode): List>? = labelsOfNode(node)?.map { it.text to it.confidence } + + /** The last photo labeled: the backup asks twice (where it goes, what is in it). */ + @Volatile private var last: Pair>? = null + + private suspend fun labelsOfUri(uri: Uri): List? = withContext(Dispatchers.IO) { + last?.takeIf { it.first == uri }?.let { return@withContext it.second } val bmp = try { context.contentResolver.loadThumbnail(uri, Size(THUMB, THUMB), null) } catch (e: CancellationException) { @@ -54,11 +70,10 @@ class DriveWorkLabeler @Inject constructor( Timber.d("Drive work: no thumbnail (%s)", e.javaClass.simpleName) return@withContext null } - label(bmp) + label(bmp)?.also { last = uri to it } } - /** The labels of a photo in Drive (its preview); null without one. */ - suspend fun ofNode(node: DNode): Map? { + private suspend fun labelsOfNode(node: DNode): List? { if (node.isDir || node.encrypted) return null val full = if (node.mediaId != null) node else resolver.resolve(node.id) ?: return null if (!full.preview) return null @@ -66,9 +81,9 @@ class DriveWorkLabeler @Inject constructor( return label(bmp) } - private suspend fun label(bmp: Bitmap): Map? = lock.withLock { + private suspend fun label(bmp: Bitmap): List? = lock.withLock { try { - client().process(InputImage.fromBitmap(bmp, 0)).await().associate { it.index to it.confidence } + client().process(InputImage.fromBitmap(bmp, 0)).await() } catch (e: CancellationException) { throw e } catch (e: Exception) { 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 c70f52c9..98fb43da 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -1828,6 +1828,7 @@ private fun DriveHome(navController: NavHostController, onShown: (Long?) -> Unit album = { nav.go(Screen.DriveAlbum.create(it)) }, file = nav.file, deadlines = { nav.go(Screen.DriveDeadlines.route) }, + photoSearch = { nav.go(Screen.DrivePhotoSearch.route) }, vault = nav.vault, settings = { nav.go(Screen.DriveSettings.route) }, cleanup = { nav.go(Screen.DriveCleanup.route) }, @@ -1862,6 +1863,9 @@ private fun NavGraphBuilder.driveRoutes( composable(Screen.DriveDeadlines.route) { GcContentWidth { com.gatecontrol.android.ui.drive.DriveDeadlinesScreen(onBack = back, onFile = file) } } + composable(Screen.DrivePhotoSearch.route) { + GcContentWidth { com.gatecontrol.android.ui.drive.DrivePhotoSearchScreen(onBack = back) } + } composable( Screen.DriveVault.route, arguments = listOf( diff --git a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt index 307d9b24..76928683 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt @@ -156,6 +156,8 @@ sealed class Screen(val route: String) { } /** "Fristen": the deadlines of my documents (Drive 1.8). */ data object DriveDeadlines : Screen("drive/deadlines") + /** Photos by what is in them (Drive 1.14, feature `photoSearch`). */ + data object DrivePhotoSearch : Screen("drive/photos/search") /** A folder of a vault (Drive 1.9); `requests=1` shows the approval requests at once (a request's push). */ data object DriveVault : Screen("drive/vault/{vault}/{folder}?requests={requests}") { const val ARG_VAULT = "vault" 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 1ded8268..f7b420d1 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 @@ -368,6 +368,11 @@ internal fun driveText(context: Context, code: String): String = when (code) { "done_approved" -> context.getString(R.string.drive_done_approved) "done_declined" -> context.getString(R.string.drive_done_declined) "done_upload" -> context.getString(R.string.drive_done_upload) + "done_scan_doc" -> context.getString(R.string.drive_ki_done_scan_doc) + "done_calendar" -> context.getString(R.string.drive_ki_done_calendar) + "familie_unavailable" -> context.getString(R.string.drive_ki_familie_unavailable) + "rate_limited" -> context.getString(R.string.drive_ki_rate_limited) + "ai_unavailable", "no_ai" -> context.getString(R.string.drive_ki_ai_unavailable) "no_text" -> context.getString(R.string.drive_insight_no_text) "media_disabled" -> context.getString(R.string.drive_insight_media_disabled) "encrypted" -> context.getString(R.string.drive_code_encrypted) @@ -606,6 +611,25 @@ internal object DriveViewPref { fun setSort(context: Context, sort: DriveLogic.FolderSort) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_SORT, sort.key).putBoolean(KEY_SORT_DESC, sort.desc).apply() } + + private const val KEY_KEEP_ORIGINAL = "scan_keep_original" + + /** "Original-Scan behalten" of "Scan prüfen" (Drive 1.14): on until switched off. */ + fun keepOriginal(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_KEEP_ORIGINAL, true) + + fun setKeepOriginal(context: Context, on: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_KEEP_ORIGINAL, on).apply() + } + + private const val KEY_DEADLINES_SEEN = "deadlines_seen" + + /** The deadlines shown at the last visit of "Fristen" (Drive 1.14: the others are "neu"); null before the first. */ + fun deadlinesSeen(context: Context): Set? = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_DEADLINES_SEEN, null)?.split(',')?.mapNotNull { it.trim().toLongOrNull() }?.toSet() + + fun setDeadlinesSeen(context: Context, ids: Collection) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_DEADLINES_SEEN, ids.distinct().take(2000).joinToString(",")).apply() + } } /** List or tiles, the same for every Drive screen ([DriveViewPref.grid]). */ diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFileScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFileScreen.kt index e15a9d95..2f947e6c 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFileScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFileScreen.kt @@ -53,6 +53,7 @@ import com.gatecontrol.android.drive.DOffice import com.gatecontrol.android.drive.DResult import com.gatecontrol.android.drive.DriveApi import com.gatecontrol.android.drive.DriveJson +import com.gatecontrol.android.drive.DriveKi import com.gatecontrol.android.drive.DriveLive import com.gatecontrol.android.drive.DriveLogic import com.gatecontrol.android.drive.DriveTransfers @@ -459,6 +460,8 @@ internal fun deadlineWhen(left: Long?): String? = when { internal fun deadlineLabel(kind: String): Int = when (kind) { "payment" -> R.string.drive_deadline_payment "cancellation" -> R.string.drive_deadline_cancellation + "warranty" -> R.string.drive_ki_deadline_warranty + "renewal" -> R.string.drive_ki_deadline_renewal else -> R.string.drive_deadline_objection } @@ -493,42 +496,86 @@ internal fun openInBrowser(context: Context, url: String) { if (!ok) Toast.makeText(context, R.string.drive_portal_unavailable, Toast.LENGTH_LONG).show() } -// ─── "Fristen": the deadlines of all my documents (Drive 1.8) ─────── +// ─── "Fristen": the deadlines of all my documents (Drive 1.8; as cards with the family calendar, Drive 1.14) ── @HiltViewModel -class DriveDeadlinesViewModel @Inject constructor(private val api: DriveApi) : ViewModel() { +class DriveDeadlinesViewModel @Inject constructor( + @dagger.hilt.android.qualifiers.ApplicationContext private val context: Context, + private val api: DriveApi, + val live: DriveLive, +) : ViewModel() { private val _state = MutableStateFlow>>(DriveLoad.Loading) val state: StateFlow>> = _state.asStateFlow() private val _message = MutableStateFlow(null) val message: StateFlow = _message.asStateFlow() - init { refresh() } + /** Deadlines not there at the last visit ("neu", Drive 1.14). */ + private val _fresh = MutableStateFlow>(emptySet()) + val fresh: StateFlow> = _fresh.asStateFlow() + + /** The deadline going into the family calendar right now. */ + private val _calendarBusy = MutableStateFlow(null) + val calendarBusy: StateFlow = _calendarBusy.asStateFlow() + + private var seen: Set? = DriveViewPref.deadlinesSeen(context) + + init { + refresh() + if (live.me.value == null) viewModelScope.launch { live.current() } + } fun clearMessage() { _message.value = null } fun refresh(quiet: Boolean = false) { viewModelScope.launch { if (!quiet || _state.value !is DriveLoad.Ready) _state.value = DriveLoad.Loading - _state.value = api.deadlines().toLoad { list -> list.filter { it.node?.encrypted != true } } + val r = api.deadlines().toLoad { list -> list.filter { it.node?.encrypted != true } } + if (r is DriveLoad.Ready) { + val ids = r.value.map { it.id } + // "neu" stays for this visit; the next one compares with what is here now + _fresh.value = _fresh.value + DriveKi.fresh(ids, seen) + DriveViewPref.setDeadlinesSeen(context, ids) + } + _state.value = r } } fun remind(d: DDeadline, days: Int?) { viewModelScope.launch { val r = if (days == null) api.unremind(d.id) else api.remind(d.id, days) + if (r is DResult.Ok) took(d, r.value) else _message.value = r.errorCode() + } + } + + /** "In den Familienkalender" (Drive 1.14, feature `deadlineCalendar`). */ + fun calendar(d: DDeadline) { + if (_calendarBusy.value != null) return + viewModelScope.launch { + _calendarBusy.value = d.id + val r = api.deadlineToCalendar(d.id) + _calendarBusy.value = null if (r is DResult.Ok) { - _state.update { s -> (s as? DriveLoad.Ready)?.let { DriveLoad.Ready(it.value.map { x -> if (x.id == d.id) r.value.copy(node = x.node) else x }) } ?: s } + took(d, r.value) + _message.value = "done_calendar" } else { _message.value = r.errorCode() } } } + + private fun took(d: DDeadline, now: DDeadline) { + _state.update { s -> (s as? DriveLoad.Ready)?.let { DriveLoad.Ready(it.value.map { x -> if (x.id == d.id) now.copy(node = now.node ?: x.node) else x }) } ?: s } + } } @Composable fun DriveDeadlinesScreen(onBack: () -> Unit, onFile: (Long) -> Unit, viewModel: DriveDeadlinesViewModel = hiltViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() val message by viewModel.message.collectAsStateWithLifecycle() + val fresh by viewModel.fresh.collectAsStateWithLifecycle() + val busy by viewModel.calendarBusy.collectAsStateWithLifecycle() + val me by viewModel.live.me.collectAsStateWithLifecycle() + val calendar = me?.has(DMe.FEATURE_DEADLINE_CALENDAR) == true val context = LocalContext.current var shown by remember { mutableStateOf(false) } LifecycleResumeEffect(viewModel) { @@ -546,9 +593,9 @@ fun DriveDeadlinesScreen(onBack: () -> Unit, onFile: (Long) -> Unit, viewModel: GcSubpageBar(stringResource(R.string.drive_deadlines), onBack = onBack, backDescription = stringResource(R.string.drive_back)) Column( Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(top = 8.dp, bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(stringResource(R.string.drive_deadlines_desc), style = MaterialTheme.typography.bodyMedium, color = GateControlTheme.extraColors.muted, modifier = Modifier.padding(horizontal = 4.dp)) + Text(stringResource(R.string.drive_ki_deadlines_desc), style = MaterialTheme.typography.bodyMedium, color = GateControlTheme.extraColors.muted, modifier = Modifier.padding(horizontal = 4.dp)) when (val s = state) { DriveLoad.Loading -> DriveLoading() is DriveLoad.Error -> DriveErrorBox(s.code, onRetry = { viewModel.refresh() }) @@ -556,22 +603,18 @@ fun DriveDeadlinesScreen(onBack: () -> Unit, onFile: (Long) -> Unit, viewModel: GcBanner(tone = GcTone.Neutral, icon = GcIcons.Calendar) { Text(stringResource(R.string.drive_deadlines_none), style = MaterialTheme.typography.bodyMedium) } } else { // by month: "März 2026" - s.value.groupBy { it.date.take(7) }.forEach { (month, list) -> - GcSectionLabel(monthTitle(month), modifier = Modifier.padding(start = 4.dp, top = 4.dp)) - GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(0.dp)) { - list.forEachIndexed { n, d -> - if (n > 0) GcRowDivider() - d.node?.let { node -> - GcListRow( - title = nodeTitle(node), - description = node.path, - onClick = { onFile(node.id) }, - leading = { NodeIcon(node, 36) }, - trailing = { GcChevron() }, - ) - } - DeadlineReminderRow(d, viewModel::remind) - } + s.value.sortedBy { it.date }.groupBy { it.date.take(7) }.forEach { (month, list) -> + GcSectionLabel(monthTitle(month), modifier = Modifier.padding(start = 4.dp, top = 12.dp, bottom = 2.dp)) + list.forEach { d -> + DeadlineCard( + d = d, + fresh = d.id in fresh, + calendar = calendar, + calendarBusy = busy == d.id, + onCalendar = { viewModel.calendar(d) }, + onRemind = viewModel::remind, + onFile = d.node?.let { n -> { onFile(n.id) } }, + ) } } } 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 50988bc6..cd1562ee 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 @@ -301,6 +301,16 @@ internal fun DriveFolderContent( ) } } + // Drive 1.14 (D4): file the new upload where Drive suggests + val filing by viewModel.filingProposal.collectAsStateWithLifecycle() + filing?.takeIf { !selecting }?.let { p -> + FilingBar( + p, + onAccept = viewModel::acceptFiling, + onDismiss = viewModel::dismissFiling, + modifier = Modifier.align(Alignment.BottomCenter).navigationBarsPadding().padding(start = 12.dp, end = 12.dp, bottom = 84.dp), + ) + } if (!selecting) { DriveFab( uploads, @@ -528,7 +538,18 @@ internal fun DriveFolderContent( LinkSheetContent(sheet, onCreate = viewModel::createLink, onEnd = viewModel::endLink) } } - scanDraft?.let { d -> + scanDraft?.takeIf { it.review }?.let { d -> + ScanReviewDialog( + draft = d, + onFormat = viewModel::setScanFormat, + onName = viewModel::setScanName, + onPickFolder = viewModel::pickScanFolder, + onKeepOriginal = viewModel::setKeepOriginal, + onSave = viewModel::saveReviewed, + onClose = viewModel::closeScan, + ) + } + scanDraft?.takeIf { !it.review }?.let { d -> ScanSaveDialog( draft = d, onPdf = viewModel::setScanPdf, 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 1d6adba2..46ae7215 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 @@ -99,6 +99,8 @@ data class DriveNav( val file: (Long) -> Unit = {}, /** "Fristen" (Drive 1.8). */ val deadlines: () -> Unit = {}, + /** Photos by what is in them (Drive 1.14, `photoSearch`): the search of the Photos page. */ + val photoSearch: () -> Unit = {}, /** A vault folder (Drive 1.9). */ val vault: (String, Long) -> Unit = { _, _ -> }, /** "Drive-Einstellungen" (Drive 1.11, the gear of the menu). */ @@ -171,6 +173,11 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel 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 } + // Drive 1.14 (`photoSearch`): photos without words get them now (in the background, under the backup's conditions) + val photoSearch = me?.has(DMe.FEATURE_PHOTO_SEARCH) == true + LaunchedEffect(onPhotosPage, photoSearch) { + if (onPhotosPage && photoSearch) com.gatecontrol.android.drive.backup.DrivePhotoLabelsWork.request(context) + } 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) } @@ -271,7 +278,7 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel else -> current.label }, onMenu = openMenu, - onSearch = nav.search, + onSearch = if (onPhotosPage && me?.has(DMe.FEATURE_PHOTO_SEARCH) == true) nav.photoSearch else nav.search, actions = { if (!onPhotosPage) FolderViewActions(folderViewModel, grid) }, ) if (hasPhotos) DriveModeSwitch(photos = onPhotosPage, onPhotos = showPage) diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveKiUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveKiUi.kt new file mode 100644 index 00000000..5c5d5c12 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveKiUi.kt @@ -0,0 +1,702 @@ +package com.gatecontrol.android.ui.drive + +import android.graphics.Bitmap +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.paneTitle +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.gatecontrol.android.R +import com.gatecontrol.android.drive.DAnswer +import com.gatecontrol.android.drive.DDeadline +import com.gatecontrol.android.drive.DSource +import com.gatecontrol.android.drive.DriveFiling +import com.gatecontrol.android.drive.DriveKi +import com.gatecontrol.android.drive.DriveLogic +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcChip +import com.gatecontrol.android.ui.components.GcFilterChip +import com.gatecontrol.android.ui.components.GcIconButton +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.GcRowDivider +import com.gatecontrol.android.ui.components.GcSwitch +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.theme.GateControlTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Date +import java.util.Locale + +// ─── Drive 1.14: AI in Drive — Scan prüfen (D5), Ablegen (D4), Fristen (D2), Fragen (D1) ── + +/** "28.11." / "Nov 28" of a `YYYY-MM-DD`; with the weekday "Sa 28.11." / "Sat, Nov 28". */ +internal fun kiDay(date: String?, weekday: Boolean = false): String? = date?.let { + runCatching { + val d = LocalDate.parse(it) + val de = Locale.getDefault().language == "de" + val pattern = when { + weekday && de -> "EE dd.MM." + weekday -> "EEE, MMM d" + de -> "dd.MM." + else -> "MMM d" + } + val withYear = if (d.year != LocalDate.now().year) (if (de) "$pattern yyyy" else "$pattern, yyyy") else pattern + DateTimeFormatter.ofPattern(withYear, Locale.getDefault()).format(d).replace("..", ".") + }.getOrNull() +} + +/** A folder path of a suggestion ("Finanzen/Strom" → "Finanzen › Strom"; empty: the top folder). */ +@Composable +internal fun kiPath(path: String?): String = + path?.split('/')?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() }?.joinToString(" › ") ?: stringResource(R.string.drive_insight_top) + +/** A small button of a card (`.btn`): outlined, or tinted with a tick when [done]. */ +@Composable +internal fun KiButton(text: String, icon: ImageVector, done: Boolean = false, enabled: Boolean = true, busy: Boolean = false, onClick: () -> Unit) { + val x = GateControlTheme.extraColors + val shape = RoundedCornerShape(10.dp) + Row( + Modifier + .heightIn(min = 40.dp) + .clip(shape) + .background(if (done) x.accentBg else Color.Transparent) + .border(1.dp, if (done) Color.Transparent else x.border2, shape) + .clickable(enabled = enabled && !busy, role = Role.Button, onClick = onClick) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (busy) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = x.muted) + } else { + Icon(if (done) GcIcons.Check else icon, contentDescription = null, tint = if (done) x.accentText else x.muted, modifier = Modifier.size(16.dp)) + } + Text( + text, + style = MaterialTheme.typography.titleSmall, + color = if (done) x.accentText else if (enabled) MaterialTheme.colorScheme.onSurface else x.faint, + ) + } +} + +/** Its children side by side, wrapping to the next line when the row is full ([gap] between them). */ +@Composable +internal fun KiFlow(modifier: Modifier = Modifier, gap: androidx.compose.ui.unit.Dp = 8.dp, content: @Composable () -> Unit) { + androidx.compose.ui.layout.Layout(content = content, modifier = modifier) { measurables, constraints -> + val g = gap.roundToPx() + val loose = constraints.copy(minWidth = 0, minHeight = 0) + val placeables = measurables.map { it.measure(loose) } + val max = if (constraints.hasBoundedWidth) constraints.maxWidth else Int.MAX_VALUE + val spots = ArrayList>() + var x = 0 + var y = 0 + var rowH = 0 + var width = 0 + placeables.forEach { p -> + if (x > 0 && x + p.width > max) { + x = 0 + y += rowH + g + rowH = 0 + } + spots += x to y + x += p.width + g + width = maxOf(width, x - g) + rowH = maxOf(rowH, p.height) + } + val height = if (placeables.isEmpty()) 0 else y + rowH + layout(width.coerceIn(constraints.minWidth, max), height.coerceAtLeast(constraints.minHeight)) { + placeables.forEachIndexed { i, p -> p.placeRelative(spots[i].first, spots[i].second) } + } + } +} + +// ─── D2: a deadline as a card ─────────────────────────────────────── + +internal fun deadlineTone(kind: String): GcTone = when (kind) { + "cancellation" -> GcTone.Error + "payment" -> GcTone.Warn + "warranty" -> GcTone.Info + "renewal" -> GcTone.Pro + else -> GcTone.Neutral +} + +/** + * A deadline (mockup "Fristen"): its kind, the day and how far off, what it is about, the file; "In den + * Familienkalender" (with `deadlineCalendar`) and "Erinnern" (the days before to choose). + */ +@Composable +internal fun DeadlineCard( + d: DDeadline, + fresh: Boolean, + calendar: Boolean, + calendarBusy: Boolean, + onCalendar: () -> Unit, + onRemind: (DDeadline, Int?) -> Unit, + onFile: (() -> Unit)?, +) { + val x = GateControlTheme.extraColors + val today = remember { LocalDate.now() } + val left = DriveLogic.daysUntil(d.date, today) + val day = kiDay(d.date, weekday = true) ?: d.date + GcCard( + modifier = if (onFile != null) Modifier.clip(MaterialTheme.shapes.medium).clickable(role = Role.Button, onClick = onFile) else Modifier, + contentPadding = PaddingValues(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcChip(stringResource(deadlineLabel(d.kind)), tone = deadlineTone(d.kind), small = true) + val whenText = if (d.kind == "renewal") stringResource(R.string.drive_ki_renewal_until, day) else listOfNotNull(day, deadlineWhen(left)).joinToString(" · ") + Text(whenText, style = MaterialTheme.typography.bodySmall, color = x.muted, modifier = Modifier.weight(1f, fill = false)) + if (fresh) GcChip(stringResource(R.string.drive_ki_new), tone = GcTone.Ok, small = true) + } + val file = d.node?.let { nodeTitle(it) } + Text(d.label ?: file ?: stringResource(deadlineLabel(d.kind)), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + if (d.label != null && file != null) Text(file, style = MaterialTheme.typography.bodySmall, color = x.muted, maxLines = 2, overflow = TextOverflow.Ellipsis) + val open = DriveLogic.canRemind(d, today) + if (open || d.reminder != null || d.calendar != null) { + KiFlow { + if (calendar && (d.calendar != null || DriveKi.calendarOpen(d, today))) { + if (d.calendar != null) { + KiButton(stringResource(R.string.drive_ki_calendar_done), GcIcons.Calendar, done = true, enabled = false) {} + } else { + KiButton(stringResource(R.string.drive_ki_calendar_add), GcIcons.Calendar, busy = calendarBusy, onClick = onCalendar) + } + } + RemindButton(d, open, onRemind) + } + } + } +} + +/** "Erinnern" → how many days before; set: "Erinnerung N Tage vorher ✓" (another day, or none). */ +@Composable +private fun RemindButton(d: DDeadline, open: Boolean, onRemind: (DDeadline, Int?) -> Unit) { + var pick by remember { mutableStateOf(false) } + val r = d.reminder + Box { + when { + r?.sent == true -> KiButton(stringResource(R.string.drive_deadline_sent), GcIcons.Bell, done = true, enabled = false) {} + r != null -> KiButton(remindDoneText(r.daysBefore), GcIcons.Bell, done = true, enabled = open) { pick = true } + open -> KiButton(stringResource(R.string.drive_ki_remind), GcIcons.Bell) { pick = true } + else -> return@Box + } + DropdownMenu(expanded = pick, onDismissRequest = { pick = false }) { + DriveLogic.REMIND_DAYS.forEach { n -> + DropdownMenuItem( + text = { Text(remindText(n)) }, + leadingIcon = { if (n == r?.daysBefore) Icon(GcIcons.Check, contentDescription = null) }, + onClick = { pick = false; onRemind(d, n) }, + ) + } + if (r != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.drive_ki_remind_off)) }, + leadingIcon = { Icon(GcIcons.BellOff, contentDescription = null) }, + onClick = { pick = false; onRemind(d, null) }, + ) + } + } + } +} + +@Composable +private fun remindDoneText(days: Int): String = + if (days == 0) stringResource(R.string.drive_ki_reminded_day0) else pluralStringResource(R.plurals.drive_ki_reminded_days, days, days) + +// ─── D4: the filing bar ───────────────────────────────────────────── + +/** + * "Vorschlag · Ablegen als in " (mockup "Ablegen") of a file just uploaded, with "An die Zahlung am … + * erinnern" for a payment deadline: "Übernehmen", "Anderer Ordner", ✕. + */ +@Composable +internal fun FilingBar( + p: DriveFiling.Proposal, + onAccept: (folder: Boolean, remind: Boolean) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val x = GateControlTheme.extraColors + var remind by rememberSaveable(p.node.id) { mutableStateOf(true) } + val desc = stringResource(R.string.drive_ki_filing_title) + Surface( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, x.border2), + shadowElevation = 6.dp, + ) { + Column( + Modifier.padding(14.dp).semantics { paneTitle = desc }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(GcIcons.Zap, contentDescription = null, tint = x.accentText, modifier = Modifier.size(16.dp)) + Text(stringResource(R.string.drive_ki_suggestion), style = MaterialTheme.typography.titleSmall, color = x.accentText) + } + val f = p.filing + if (f.name != null) { + Text(stringResource(R.string.drive_ki_file_as), style = MaterialTheme.typography.bodySmall, color = x.muted) + Text(f.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + } else { + Text(nodeTitle(p.node), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + } + if (f.folderId != null) { + Text(stringResource(R.string.drive_ki_file_in), style = MaterialTheme.typography.bodySmall, color = x.muted) + Text(kiPath(f.folderPath), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + } + f.payment?.let { pay -> + Row( + Modifier.fillMaxWidth().heightIn(min = 44.dp).toggleable(value = remind, role = Role.Checkbox, onValueChange = { remind = it }), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Checkbox(checked = remind, onCheckedChange = null) + Text(stringResource(R.string.drive_ki_filing_remind, kiDay(pay.date) ?: pay.date), style = MaterialTheme.typography.bodyMedium) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + GcPrimaryButton(stringResource(R.string.drive_ki_accept), onClick = { onAccept(true, remind) }, modifier = Modifier.weight(1f)) + if (f.folderId != null || f.name != null) { + GcOutlineButton(stringResource(R.string.drive_ki_other_folder), onClick = { onAccept(false, remind) }, fillWidth = false) + } + GcIconButton(GcIcons.Close, contentDescription = stringResource(R.string.drive_ki_filing_dismiss), onClick = onDismiss, size = 44.dp, iconSize = 18.dp) + } + } + } +} + +// ─── D5: "Scan prüfen" ────────────────────────────────────────────── + +@Composable +private fun formatLabel(f: DriveKi.Format): String = when (f) { + DriveKi.Format.PDF -> "PDF" + DriveKi.Format.DOCX -> stringResource(R.string.drive_ki_format_word) + DriveKi.Format.XLSX -> stringResource(R.string.drive_ki_format_excel) + DriveKi.Format.IMAGE -> stringResource(R.string.drive_ki_format_image) +} + +private fun kindLabel(kind: String): Int = when (kind) { + DriveKi.KIND_PRINTED -> R.string.drive_ki_kind_printed + DriveKi.KIND_HANDWRITING -> R.string.drive_ki_kind_handwriting + DriveKi.KIND_TABLE -> R.string.drive_ki_kind_table + DriveKi.KIND_WHITEBOARD -> R.string.drive_ki_kind_whiteboard + else -> R.string.drive_ki_kind_other +} + +/** + * "Scan prüfen" (mockups "Main" and "Whiteboard", Drive 1.14 `scanDocs`): what Drive recognised, the format (PDF · + * Word · Excel · Nur Bild), a preview, name and folder, "Original-Scan behalten", the cost line and the save button. + */ +@Composable +internal fun ScanReviewDialog( + draft: DriveFolderViewModel.ScanDraft, + onFormat: (DriveKi.Format) -> Unit, + onName: (String) -> Unit, + onPickFolder: () -> Unit, + onKeepOriginal: (Boolean) -> Unit, + onSave: (base: String) -> Unit, + onClose: () -> Unit, +) { + val x = GateControlTheme.extraColors + val context = LocalContext.current + val fallback = remember(draft.scan) { "Scan " + SimpleDateFormat("yyyy-MM-dd HH.mm", Locale.GERMANY).format(Date()) } + val today = remember(draft.scan) { LocalDate.now() } + val base = draft.name ?: DriveKi.scanName(DriveKi.title(draft.lines), today, fallback) + val format = draft.formatNow + val kind = draft.kindNow + val known = draft.kind != null && kind != DriveKi.KIND_OTHER + val waiting = draft.ai && !draft.classified + var renaming by remember { mutableStateOf(false) } + val first by produceState(null, draft.scan) { value = withContext(Dispatchers.IO) { draft.scan.pages.firstOrNull()?.let { decodeSmall(context, it, 900) } } } + + Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding().imePadding()) { + Row(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) { + GcIconButton(GcIcons.Close, contentDescription = stringResource(R.string.drive_cancel), onClick = onClose, tint = MaterialTheme.colorScheme.onBackground) + Text(stringResource(R.string.drive_ki_scan_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Text( + pluralStringResource(R.plurals.drive_scan_pages, draft.scan.pages.size, draft.scan.pages.size), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + modifier = Modifier.padding(end = 12.dp), + ) + } + Column( + Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(top = 4.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // what was recognised + GcCard(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(Modifier.size(width = 52.dp, height = 68.dp).clip(RoundedCornerShape(6.dp)).background(x.panel2), contentAlignment = Alignment.Center) { + first?.let { Image(it.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (waiting) CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp, color = x.accentText) + Text( + when { + waiting -> stringResource(R.string.drive_ki_recognising) + known -> stringResource(R.string.drive_ki_recognised, stringResource(kindLabel(kind))) + draft.texts?.any { it != null } == true -> stringResource(R.string.drive_scan_text_yes) + draft.texts == null -> stringResource(R.string.drive_scan_text_running) + else -> stringResource(R.string.drive_scan_text_no) + }, + style = MaterialTheme.typography.titleSmall, + color = x.accentText, + ) + } + draft.kind?.summary?.let { Text(it, style = MaterialTheme.typography.titleMedium) } + Text(question(format, kind), style = MaterialTheme.typography.bodySmall, color = x.muted) + } + } + } + // the format + KiFlow(Modifier.selectableGroup()) { + draft.formats.forEach { f -> GcFilterChip(formatLabel(f), selected = f == format, onClick = { onFormat(f) }) } + } + // the preview + GcCard(contentPadding = PaddingValues(12.dp)) { + Text(stringResource(R.string.drive_ki_preview), style = MaterialTheme.typography.bodySmall, color = x.muted) + ScanPreview(draft, format, kind, first) + Text(note(format, kind), style = MaterialTheme.typography.bodySmall, color = x.muted) + } + // name, folder, original + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(0.dp)) { + ChangeRow(stringResource(R.string.drive_scan_name), DriveKi.fileName(base, format)) { renaming = true } + GcRowDivider() + ChangeRow(stringResource(R.string.drive_ki_file_in_label), draft.folderLabel, onPickFolder) + if (format.target != null) { + GcRowDivider() + Row( + Modifier.fillMaxWidth().heightIn(min = 52.dp).toggleable(value = draft.keepOriginal, role = Role.Switch, onValueChange = onKeepOriginal) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.drive_ki_keep_original), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + GcSwitch(checked = draft.keepOriginal, onCheckedChange = null) + } + } + } + Text(cost(format), style = MaterialTheme.typography.bodySmall, color = x.muted, modifier = Modifier.padding(horizontal = 4.dp)) + } + Surface(color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, x.border)) { + Box(Modifier.fillMaxWidth().padding(16.dp)) { + GcPrimaryButton( + saveLabel(format), + onClick = { onSave(DriveKi.cleanName(base) ?: fallback) }, + enabled = !draft.saving && (format.target == null || draft.aiReady), + loading = draft.saving, + ) + } + } + } + } + } + if (renaming) { + NameDialog( + title = stringResource(R.string.drive_rename), + initial = base, + confirm = stringResource(R.string.drive_save), + onConfirm = { renaming = false; onName(it) }, + onDismiss = { renaming = false }, + ) + } +} + +@Composable +private fun ChangeRow(label: String, value: String, onChange: () -> Unit) { + val x = GateControlTheme.extraColors + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, style = MaterialTheme.typography.bodySmall, color = x.muted) + Text(value, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + GcFilterChip(stringResource(R.string.drive_ki_change), selected = false, onClick = onChange) + } +} + +@Composable +private fun question(format: DriveKi.Format, kind: String): String = stringResource( + when (format) { + DriveKi.Format.XLSX -> R.string.drive_ki_ask_xlsx + DriveKi.Format.DOCX -> if (kind == DriveKi.KIND_WHITEBOARD) R.string.drive_ki_ask_docx_outline else R.string.drive_ki_ask_docx + DriveKi.Format.PDF -> R.string.drive_ki_ask_pdf + DriveKi.Format.IMAGE -> R.string.drive_ki_ask_image + }, +) + +@Composable +private fun note(format: DriveKi.Format, kind: String): String = stringResource( + when (format) { + DriveKi.Format.XLSX -> R.string.drive_ki_note_xlsx + DriveKi.Format.PDF -> R.string.drive_ki_note_pdf + DriveKi.Format.IMAGE -> R.string.drive_ki_note_image + DriveKi.Format.DOCX -> when (kind) { + DriveKi.KIND_TABLE -> R.string.drive_ki_note_docx_table + DriveKi.KIND_WHITEBOARD -> R.string.drive_ki_note_docx_whiteboard + DriveKi.KIND_HANDWRITING -> R.string.drive_ki_note_docx_handwriting + else -> R.string.drive_ki_note_docx + } + }, +) + +@Composable +private fun cost(format: DriveKi.Format): String = stringResource( + when (format) { + DriveKi.Format.PDF -> R.string.drive_ki_cost_pdf + DriveKi.Format.IMAGE -> R.string.drive_ki_cost_image + else -> R.string.drive_ki_cost_ai + }, +) + +@Composable +private fun saveLabel(format: DriveKi.Format): String = stringResource( + when (format) { + DriveKi.Format.XLSX -> R.string.drive_ki_save_xlsx + DriveKi.Format.DOCX -> R.string.drive_ki_save_docx + DriveKi.Format.PDF -> R.string.drive_ki_save_pdf + DriveKi.Format.IMAGE -> R.string.drive_ki_save_image + }, +) + +/** Excel: the first rows as cells (A, B, C …); Word: the beginning of the text; PDF and picture: the first page. */ +@Composable +private fun ScanPreview(draft: DriveFolderViewModel.ScanDraft, format: DriveKi.Format, kind: String, first: Bitmap?) { + val x = GateControlTheme.extraColors + val grid = remember(draft.reads) { DriveKi.tableGrid(draft.reads?.firstOrNull()?.lines.orEmpty()) } + when (format) { + DriveKi.Format.XLSX -> if (grid.isNotEmpty()) SheetGrid(grid) else TextPreview(DriveKi.docPreview(draft.text)) + DriveKi.Format.DOCX -> Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).background(Color.White).padding(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + val lines = DriveKi.docPreview(draft.text) + if (kind == DriveKi.KIND_TABLE && grid.isNotEmpty()) { + DriveKi.title(draft.lines)?.let { Text(it, color = Color(0xFF1A1D22), fontWeight = FontWeight.Bold, fontSize = 14.sp) } + grid.take(3).forEachIndexed { r, row -> + Row(Modifier.fillMaxWidth()) { + row.forEach { cell -> + Text(cell, color = Color(0xFF1A1D22), fontSize = 11.sp, fontWeight = if (r == 0) FontWeight.Bold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).padding(4.dp)) + } + } + } + } else if (lines.isEmpty()) { + Text(stringResource(R.string.drive_scan_text_no), color = Color(0xFF465262), fontSize = 13.sp) + } else { + lines.forEachIndexed { i, l -> + Text(l, color = Color(0xFF1A1D22), fontSize = if (i == 0) 14.sp else 12.sp, fontWeight = if (i == 0) FontWeight.Bold else FontWeight.Normal, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } + DriveKi.Format.PDF, DriveKi.Format.IMAGE -> Box( + Modifier.fillMaxWidth().height(220.dp).clip(RoundedCornerShape(8.dp)).background(x.panel2), + contentAlignment = Alignment.Center, + ) { + if (first != null) { + Image(first.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize()) + } else { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = x.muted) + } + } + } +} + +@Composable +private fun TextPreview(lines: List) { + val x = GateControlTheme.extraColors + Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).background(x.panel2).padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (lines.isEmpty()) Text(stringResource(R.string.drive_scan_text_no), style = MaterialTheme.typography.bodySmall, color = x.muted) + lines.forEach { Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } +} + +/** The first rows as a sheet: column letters on top, row numbers on the left, the first row in bold. */ +@Composable +private fun SheetGrid(rows: List>) { + val x = GateControlTheme.extraColors + val cols = rows.maxOf { it.size } + val shape = RoundedCornerShape(10.dp) + Column(Modifier.fillMaxWidth().clip(shape).border(1.dp, x.border, shape)) { + Row(Modifier.fillMaxWidth().background(x.panel2)) { + SheetCell("", head = true, modifier = Modifier.width(28.dp)) + (0 until cols).forEach { c -> SheetCell(('A' + c).toString(), head = true, modifier = Modifier.weight(1f)) } + } + rows.forEachIndexed { r, row -> + Row(Modifier.fillMaxWidth()) { + SheetCell((r + 1).toString(), head = true, modifier = Modifier.width(28.dp)) + (0 until cols).forEach { c -> + SheetCell(row.getOrElse(c) { "" }, bold = r == 0, modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun SheetCell(text: String, modifier: Modifier, head: Boolean = false, bold: Boolean = false) { + val x = GateControlTheme.extraColors + Text( + text, + modifier = modifier.border(0.5.dp, x.border).padding(horizontal = 6.dp, vertical = 5.dp), + fontSize = 12.sp, + color = if (head) x.faint else MaterialTheme.colorScheme.onSurface, + fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +// ─── D1: the answer card ──────────────────────────────────────────── + +/** [text] with its dates in bold. */ +internal fun boldDates(text: String): AnnotatedString { + val b = AnnotatedString.Builder(text) + DriveKi.dateSpans(text).forEach { r -> b.addStyle(SpanStyle(fontWeight = FontWeight.Bold), r.first, r.last + 1) } + return b.toAnnotatedString() +} + +/** + * The answer to a question (mockup "Fragen"): the answer with its dates in bold, "Frist in den Familienkalender" when a + * recognised deadline of a source fits, the sources with their quotes (a tap opens the file); nothing found: "Dazu + * habe ich in deinen Dateien nichts gefunden." with what was searched for and where. + */ +@Composable +internal fun AnswerCard( + a: DAnswer, + areas: String, + deadline: DDeadline?, + calendarBusy: Boolean, + onCalendar: (DDeadline) -> Unit, + onSource: (DSource) -> Unit, +) { + val x = GateControlTheme.extraColors + if (!a.answered) { + GcCard { + Text(stringResource(R.string.drive_ki_ask_none), style = MaterialTheme.typography.titleMedium) + val terms = a.terms.joinToString(", ") + Text( + if (terms.isEmpty()) stringResource(R.string.drive_ki_ask_where, areas) else stringResource(R.string.drive_ki_ask_searched, terms, areas), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + ) + } + return + } + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(0.dp)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(boldDates(a.answer.orEmpty()), style = MaterialTheme.typography.bodyLarge) + if (deadline != null) { + if (deadline.calendar != null) { + KiButton(stringResource(R.string.drive_ki_calendar_done), GcIcons.Calendar, done = true, enabled = false) {} + } else { + KiButton(stringResource(R.string.drive_ki_ask_calendar), GcIcons.Calendar, busy = calendarBusy) { onCalendar(deadline) } + } + } + } + if (a.sources.isNotEmpty()) { + GcRowDivider() + Text( + pluralStringResource(R.plurals.drive_ki_sources, a.sources.size, a.sources.size), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + modifier = Modifier.padding(start = 16.dp, top = 10.dp, bottom = 2.dp), + ) + a.sources.forEach { s -> + SourceRow(s) { onSource(s) } + } + } + } +} + +@Composable +private fun SourceRow(s: DSource, onClick: () -> Unit) { + val x = GateControlTheme.extraColors + val ext = s.name.substringAfterLast('.', "").uppercase(Locale.ROOT).take(4) + val look = kindLook(DriveLogic.kind(null, s.name)) + Row( + Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onClick).padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(look.bg), contentAlignment = Alignment.Center) { + Text(ext.ifEmpty { "…" }, color = look.fg, fontSize = 10.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(s.name, style = MaterialTheme.typography.titleSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + val where = listOfNotNull(s.path?.split('/')?.filter { it.isNotBlank() }?.joinToString(" › ")?.ifEmpty { null }, s.page?.let { stringResource(R.string.drive_ki_page, it) }) + if (where.isNotEmpty()) Text(where.joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = x.muted) + s.quote?.let { + Text( + "„$it“", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp).fillMaxWidth().clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.background).padding(horizontal = 8.dp, vertical = 6.dp), + ) + } + } + } +} 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 7a71e897..17f85ead 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 @@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog @@ -51,6 +53,47 @@ import com.gatecontrol.android.ui.components.GcTextField import com.gatecontrol.android.ui.theme.GateControlTheme import kotlinx.coroutines.launch +/** The question's answer (Drive 1.14, D1), or what to do; the note "Antwort nur aus deinen Dateien …". */ +@Composable +private fun AskContent( + asked: DriveSearchViewModel.Asked?, + areas: List, + calendarBusy: Boolean, + onCalendar: (com.gatecontrol.android.drive.DDeadline) -> Unit, + onRetry: () -> Unit, + onSource: (com.gatecontrol.android.drive.DSource) -> Unit, +) { + val x = GateControlTheme.extraColors + when (val a = asked?.answer) { + null -> Text(stringResource(R.string.drive_ki_ask_hint), color = x.muted, style = MaterialTheme.typography.bodyMedium) + DriveLoad.Loading -> DriveLoading() + is DriveLoad.Error -> DriveErrorBox(a.code, onRetry = onRetry) + is DriveLoad.Ready -> AnswerCard( + a = a.value, + areas = joinAreas(areas.filter { !it.archived }.map { it.label }), + deadline = asked?.deadline, + calendarBusy = calendarBusy, + onCalendar = onCalendar, + onSource = onSource, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 4.dp)) { + androidx.compose.material3.Icon(GcIcons.Lock, contentDescription = null, tint = x.muted, modifier = Modifier.padding(top = 2.dp).size(14.dp)) + Text(stringResource(R.string.drive_ki_ask_note), style = MaterialTheme.typography.bodySmall, color = x.muted) + } +} + +/** "Meine Dateien und Familie" / "A, B and C". */ +@Composable +private fun joinAreas(labels: List): String { + val and = stringResource(R.string.drive_ki_and) + return when (labels.size) { + 0 -> "" + 1 -> labels[0] + else -> labels.dropLast(1).joinToString(", ") + " $and " + labels.last() + } +} + /** The trash of every area the person writes to: restore or delete for good. */ @Composable fun DriveTrashScreen(onBack: () -> Unit, viewModel: DriveTrashViewModel = hiltViewModel()) { @@ -125,6 +168,12 @@ fun DriveSearchScreen(onBack: () -> Unit, onFolder: (Long) -> Unit, viewModel: D val haptic = LocalHapticFeedback.current val snackbar = remember { SnackbarHostState() } var q by rememberSaveable { mutableStateOf("") } + // Drive 1.14 (`docAsk`): "Dateien | Fragen" — a question goes to Drive's AI, answered only from my files + val askable = me?.has(DMe.FEATURE_DOC_ASK) == true + var askMode by rememberSaveable { mutableStateOf(false) } + val asking = askable && askMode + val asked by viewModel.asked.collectAsStateWithLifecycle() + val calendarBusy by viewModel.calendarBusy.collectAsStateWithLifecycle() var menuFor by remember { mutableStateOf(null) } var moreOpen by remember { mutableStateOf(false) } var renaming by remember { mutableStateOf(null) } @@ -167,12 +216,57 @@ fun DriveSearchScreen(onBack: () -> Unit, onFolder: (Long) -> Unit, viewModel: D if (!selecting) { GcTextField( value = q, - onValueChange = { q = it.take(100); viewModel.search(q) }, - label = stringResource(R.string.drive_search_hint), + onValueChange = { + q = it.take(if (askable) com.gatecontrol.android.drive.DriveJson.ASK_MAX else 100) + // a question mark at the end: it is a question + if (askable && !askMode && com.gatecontrol.android.drive.DriveKi.isQuestion(q)) { + askMode = true + viewModel.search("") + } + if (!(askable && askMode)) viewModel.search(q.take(100)) + }, + label = stringResource(if (askable) R.string.drive_ki_search_or_ask else R.string.drive_search_hint), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search), + keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { if (askable && askMode) viewModel.ask(q) }), + trailingIcon = if (asking) { + { GcIconButton(GcIcons.Search, contentDescription = stringResource(R.string.drive_ki_ask_send), onClick = { viewModel.ask(q) }, size = 44.dp, iconSize = 20.dp) } + } else { + null + }, ) + if (askable) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + com.gatecontrol.android.ui.components.GcFilterChip(stringResource(R.string.drive_ki_mode_files), selected = !askMode, onClick = { + askMode = false + viewModel.clearAsk() + viewModel.search(q.take(100)) + }) + com.gatecontrol.android.ui.components.GcFilterChip(stringResource(R.string.drive_ki_mode_ask), selected = askMode, onClick = { + askMode = true + viewModel.search("") + if (q.trim().length >= 3) viewModel.ask(q) + }) + } + } } BulkBusyCard(busy, viewModel.bulk::cancelBusy) - when (val s = state) { + if (asking) { + AskContent( + asked = asked, + areas = areas, + calendarBusy = calendarBusy, + onCalendar = viewModel::calendar, + onRetry = { viewModel.ask(q) }, + onSource = { src -> + scope.launch { + when (val r = viewModel.node(src.nodeId)) { + is com.gatecontrol.android.drive.DResult.Ok -> open(r.value, false) + else -> Toast.makeText(context, driveText(context, r.errorCode() ?: "failed"), Toast.LENGTH_LONG).show() + } + } + }, + ) + } else when (val s = state) { null -> if (q.isNotBlank()) Text(stringResource(R.string.drive_search_min), color = GateControlTheme.extraColors.muted) DriveLoad.Loading -> DriveLoading() is DriveLoad.Error -> DriveErrorBox(s.code, onRetry = { viewModel.search(q) }) diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotoSearchScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotoSearchScreen.kt new file mode 100644 index 00000000..dcdc6f9e --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DrivePhotoSearchScreen.kt @@ -0,0 +1,183 @@ +package com.gatecontrol.android.ui.drive + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +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.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DResult +import com.gatecontrol.android.drive.DriveApi +import com.gatecontrol.android.drive.DriveKi +import com.gatecontrol.android.drive.DriveLive +import com.gatecontrol.android.drive.DriveTransfers +import com.gatecontrol.android.drive.backup.DrivePhotoLabelsWork +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcFilterChip +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcTextField +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.theme.GateControlTheme +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +/** + * "Fotos nach Inhalt finden" (Drive 1.14, D3, feature `photoSearch`): photos by the words of what is in them — found on + * the phones that backed them up ([com.gatecontrol.android.drive.backup.DrivePhotoLabels]); the chips add the hits' + * most frequent terms. Opening it asks for the labels still missing (in the background, under the backup's conditions). + */ +@HiltViewModel +class DrivePhotoSearchViewModel @Inject constructor( + @ApplicationContext context: Context, + private val api: DriveApi, + private val transfers: DriveTransfers, + val live: DriveLive, +) : ViewModel() { + + /** How many in all and the hits' terms (from the first page). */ + data class Meta(val total: Int?, val terms: List) + + private var term = "" + private val _meta = MutableStateFlow(Meta(null, emptyList())) + val meta: StateFlow = _meta.asStateFlow() + + private val pager = PhotoPager(viewModelScope) { before -> + api.photos(before = before, q = term).also { r -> + if (before == null && r is DResult.Ok) _meta.value = Meta(r.value.total, r.value.terms) + } + } + val photos: StateFlow = pager.state + + private val _active = MutableStateFlow(false) + /** A search is typed (else nothing shows). */ + val active: StateFlow = _active.asStateFlow() + private var job: Job? = null + + init { + DrivePhotoLabelsWork.request(context) + } + + fun search(q: String) { + job?.cancel() + val t = q.trim().take(com.gatecontrol.android.drive.DriveJson.PHOTO_Q_MAX) + if (t.length < 2) { + term = "" + _active.value = false + _meta.value = Meta(null, emptyList()) + return + } + job = viewModelScope.launch { + delay(300) + term = t + _active.value = true + pager.reload() + } + } + + fun more() = pager.more() + + fun retry() = pager.retry() + + suspend fun fetch(node: DNode): DResult = transfers.fetch(node) +} + +@Composable +fun DrivePhotoSearchScreen(onBack: () -> Unit, viewModel: DrivePhotoSearchViewModel = hiltViewModel()) { + val photos by viewModel.photos.collectAsStateWithLifecycle() + val meta by viewModel.meta.collectAsStateWithLifecycle() + val active by viewModel.active.collectAsStateWithLifecycle() + val x = GateControlTheme.extraColors + var q by rememberSaveable { mutableStateOf("") } + var viewing by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { if (q.isNotBlank()) viewModel.search(q) } + val set: (String) -> Unit = { q = it; viewModel.search(it) } + Column(Modifier.fillMaxSize()) { + GcSubpageBar(stringResource(R.string.drive_ki_photo_search), onBack = onBack, backDescription = stringResource(R.string.drive_back)) + Column(Modifier.padding(horizontal = 16.dp).padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + GcTextField( + value = q, + onValueChange = { set(it.take(com.gatecontrol.android.drive.DriveJson.PHOTO_Q_MAX)) }, + label = stringResource(R.string.drive_ki_photo_search_hint), + placeholder = stringResource(R.string.drive_ki_photo_search_example), + ) + val chips = DriveKi.chips(q, if (active) meta.terms else emptyList()) + if (chips.isNotEmpty()) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(chips, key = { it.term.lowercase() + it.on }) { c -> + GcFilterChip( + if (c.on) c.term else "+ " + c.term, + selected = c.on, + onClick = { set(if (c.on) DriveKi.withoutTerm(q, c.term) else DriveKi.withTerm(q, c.term)) }, + ) + } + } + } + if (active && photos.loaded) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + val n = meta.total ?: photos.items.size + Text( + pluralStringResource(R.plurals.drive_ki_photos_found, n, n), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text(stringResource(R.string.drive_ki_photo_labels_device), style = MaterialTheme.typography.bodySmall, color = x.muted) + } + } + } + Box(Modifier.weight(1f).padding(top = 8.dp)) { + if (active) { + PhotoGrid( + pages = photos, + selected = emptySet(), + onOpen = { viewing = it }, + onToggle = {}, + onMore = viewModel::more, + onRetry = viewModel::retry, + empty = stringResource(R.string.drive_ki_photo_search_none), + ) + } + } + GcBanner(tone = GcTone.Info, icon = GcIcons.Info, modifier = Modifier.navigationBarsPadding().padding(12.dp)) { + Text(stringResource(R.string.drive_ki_photo_search_note), style = MaterialTheme.typography.bodySmall) + } + } + viewing?.let { start -> + PhotoViewer(items = photos.items, start = start, fetch = viewModel::fetch, onMore = viewModel::more, onClose = { viewing = null }) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveScanUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveScanUi.kt index a36b985c..d4bec108 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveScanUi.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveScanUi.kt @@ -233,7 +233,7 @@ private fun ScanPage(uri: Uri, first: Boolean) { } /** The page at about [max] px on its long side (a scan page is several megapixels). */ -private fun decodeSmall(context: Context, uri: Uri, max: Int): Bitmap? = runCatching { +internal fun decodeSmall(context: Context, uri: Uri, max: Int): Bitmap? = runCatching { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } var sample = 1 diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt index 0195dd2c..86a35644 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt @@ -297,6 +297,7 @@ class DriveFolderViewModel @Inject constructor( private val settings: com.gatecontrol.android.drive.DriveSettings, val ops: com.gatecontrol.android.drive.DriveOps, private val workPhotos: com.gatecontrol.android.drive.work.DriveWorkPhotos, + private val filing: com.gatecontrol.android.drive.DriveFiling, ) : ViewModel() { /** The route's `id` (a string argument, see Screen.DriveFolder); on the Drive tab the chosen area's top ([open]). */ @@ -600,6 +601,10 @@ class DriveFolderViewModel @Inject constructor( /** * A scan waiting to be saved: its pages, their recognised text (null while that runs), PDF or pictures (the * server's "Scans speichern als" until the person picks), and the folder it goes to (this one at first). + * + * Drive 1.14 (`scanDocs`, D5): "Scan prüfen" instead ([review]) — the phone reads the pages with where the text + * stands ([reads]), the pages go up as media and Drive's AI says what the scan is ([kind]; null while it runs, + * [classified] once it answered or failed); the person picks PDF (searchable, made here), Word, Excel or the picture. */ data class ScanDraft( val scan: DriveScanner.Pages, @@ -608,31 +613,94 @@ class DriveFolderViewModel @Inject constructor( val formatPicked: Boolean = false, val folderId: Long, val folderLabel: String, - ) + val review: Boolean = false, + val reads: List? = null, + /** The AI may be asked (the folder's area has AI for documents). */ + val ai: Boolean = false, + val kind: com.gatecontrol.android.drive.DScanKind? = null, + val classified: Boolean = false, + val mediaIds: List? = null, + val format: com.gatecontrol.android.drive.DriveKi.Format? = null, + /** The name typed ("Ändern"), without extension; null: the suggested one. */ + val name: String? = null, + val keepOriginal: Boolean = true, + val saving: Boolean = false, + val scanFormat: String? = null, + ) { + /** What the AI said the scan is (`other` while it has not, or could not). */ + val kindNow: String get() = kind?.kind ?: com.gatecontrol.android.drive.DriveKi.KIND_OTHER + /** Word and Excel only once the AI said what it is and its pages are in Drive. */ + val aiReady: Boolean get() = ai && kind != null && !mediaIds.isNullOrEmpty() + val formats: List get() = com.gatecontrol.android.drive.DriveKi.formats(kindNow, aiReady) + val formatNow: com.gatecontrol.android.drive.DriveKi.Format + get() = format?.takeIf { it in formats } ?: com.gatecontrol.android.drive.DriveKi.defaultFormat(kindNow, aiReady, scanFormat) + val lines: List get() = reads.orEmpty().flatMap { r -> r?.lines.orEmpty().map { it.text } } + val text: String? get() = DriveLogic.scanText(texts ?: reads?.map { it?.text }.orEmpty()) + } private val _scan = MutableStateFlow(null) val scan: StateFlow = _scan.asStateFlow() private var recognizing: Deferred>? = null + private var reading: Deferred>? = null /** The scanner is back with [pages]: the save sheet opens, the text is recognised meanwhile. */ fun scanned(pages: DriveScanner.Pages) { val here = (_state.value as? DriveLoad.Ready)?.value ?: return if (pages.pages.isEmpty()) return - _scan.value = ScanDraft(pages, folderId = here.node.id, folderLabel = label(here, _area.value?.label)) + val review = live.me.value?.has(com.gatecontrol.android.drive.DMe.FEATURE_SCAN_DOCS) == true + val ai = review && _area.value?.docAi != false + val keep = DriveViewPref.keepOriginal(context) + _scan.value = ScanDraft(pages, folderId = here.node.id, folderLabel = label(here, _area.value?.label), review = review, ai = ai, keepOriginal = keep) viewModelScope.launch { val format = api.me().valueOrNull()?.scanFormat - _scan.update { d -> if (d != null && d.scan == pages && !d.formatPicked && format != null) d.copy(pdf = format != "image") else d } + _scan.update { d -> if (d != null && d.scan == pages && !d.formatPicked && format != null) d.copy(pdf = format != "image", scanFormat = format) else d } } - val job = viewModelScope.async { scanner.recognize(pages.pages) } - recognizing = job + if (!review) { + val job = viewModelScope.async { scanner.recognize(pages.pages) } + recognizing = job + viewModelScope.launch { + val texts = job.await() + _scan.update { d -> if (d?.scan == pages) d.copy(texts = texts) else d } + } + return + } + val read = viewModelScope.async { scanner.read(pages.pages) } + reading = read + recognizing = viewModelScope.async { read.await().map { it?.text } } viewModelScope.launch { - val texts = job.await() - _scan.update { d -> if (d?.scan == pages) d.copy(texts = texts) else d } + val reads = read.await() + _scan.update { d -> if (d?.scan == pages) d.copy(reads = reads, texts = reads.map { it?.text }) else d } + if (!ai) { + _scan.update { d -> if (d?.scan == pages) d.copy(classified = true) else d } + return@launch + } + // text first: the pages go up as media (Word and Excel are made from them), the AI gets the text + val media = scanner.uploadPages(pages, api) + val ids = media.valueOrNull() + if (ids == null) { + _scan.update { d -> if (d?.scan == pages) d.copy(classified = true) else d } + return@launch + } + _scan.update { d -> if (d?.scan == pages) d.copy(mediaIds = ids) else d } + val kind = api.classifyScan(ids, DriveLogic.scanText(reads.map { it?.text })) + _scan.update { d -> if (d?.scan == pages) d.copy(kind = kind.valueOrNull(), classified = true) else d } } } fun setScanPdf(pdf: Boolean) = _scan.update { it?.copy(pdf = pdf, formatPicked = true) } + /** "Scan prüfen": the chip [format]. */ + fun setScanFormat(format: com.gatecontrol.android.drive.DriveKi.Format) = _scan.update { it?.copy(format = format, formatPicked = true) } + + /** "Scan prüfen": the name typed ("Ändern"). */ + fun setScanName(name: String) = _scan.update { it?.copy(name = com.gatecontrol.android.drive.DriveKi.cleanName(name)) } + + /** "Original-Scan behalten" (kept on this phone for the next scan). */ + fun setKeepOriginal(on: Boolean) { + DriveViewPref.setKeepOriginal(context, on) + _scan.update { it?.copy(keepOriginal = on) } + } + /** Another folder for the scan: the folder picker, starting here. */ fun pickScanFolder() { val d = _scan.value ?: return @@ -659,6 +727,80 @@ class DriveFolderViewModel @Inject constructor( } } + /** + * "Scan prüfen" saves as [base] (the shown name, without extension): the searchable PDF made here, the pictures, + * or Word / Excel made by Drive (`scans/convert`); the dialog stays open on a refusal. + */ + fun saveReviewed(base: String) { + val d = _scan.value ?: return + if (d.saving) return + val format = d.formatNow + val name = com.gatecontrol.android.drive.DriveKi.fileName(base, format) + viewModelScope.launch { + _scan.update { it?.copy(saving = true) } + val reads = d.reads ?: reading?.await() + val text = DriveLogic.scanText(reads?.map { it?.text } ?: recognizing?.await().orEmpty()) + when (format) { + com.gatecontrol.android.drive.DriveKi.Format.IMAGE -> { + _scan.value = null + val ok = scanner.savePictures(d.scan, reads?.map { it?.text } ?: recognizing?.await().orEmpty(), base, d.folderId) + if (!ok) _message.value = "scan_read" + } + com.gatecontrol.android.drive.DriveKi.Format.PDF -> { + val pdf = scanner.searchablePdf(d.scan, reads.orEmpty()) + if (pdf == null) { + // no JPEG pages: the scanner's own PDF as before + _scan.value = null + val ok = scanner.save(d.scan, reads?.map { it?.text }.orEmpty(), base, true, d.folderId) + if (!ok) _message.value = "scan_read" + } else { + _scan.value = null + scanner.uploadMade(pdf, name, format.mime, d.folderId, text) { } + } + } + else -> { + val ids = d.mediaIds + val target = format.target + if (ids.isNullOrEmpty() || target == null) { + _scan.update { it?.copy(saving = false) } + _message.value = "failed" + return@launch + } + when (val r = api.convertScan(ids, target, d.kindNow, d.folderId, name, d.keepOriginal)) { + is DResult.Ok -> { + _scan.value = null + _message.value = "done_scan_doc" + if (r.value.node.parentId == folderId) refresh(quiet = true) + filing.watch(r.value.node, scan = true) + } + else -> { + _scan.update { it?.copy(saving = false) } + _message.value = r.errorCode() + } + } + } + } + } + } + + // ─── filing right after the upload (Drive 1.14, D4) ───────────── + + /** The bar "Vorschlag · Ablegen als … in …" of a file just uploaded here. */ + val filingProposal: StateFlow get() = filing.proposal + + fun dismissFiling() = filing.dismiss() + + /** "Übernehmen" ([folder] true) or "Anderer Ordner" (the name now, then the folder picker). */ + fun acceptFiling(folder: Boolean, remind: Boolean) { + val p = filing.proposal.value ?: return + viewModelScope.launch { + val (node, code) = filing.accept(p, folder, remind) + _message.value = code ?: if (folder) "done_insight" else null + refresh(quiet = true) + if (!folder && node != null) startMove(node) + } + } + // ─── time travel (Drive 1.7, feature `timeTravel`) ────────────── private val _timeTravel = MutableStateFlow(null) @@ -887,4 +1029,61 @@ class DriveSearchViewModel @Inject constructor( suspend fun office(node: DNode): DResult = api.office(node.id) suspend fun fetch(node: DNode): DResult = transfers.fetch(node) + + // ─── questions to the documents (Drive 1.14, feature `docAsk`) ── + + /** A question and its answer; [deadline]: a recognised deadline of a source that the answer names. */ + data class Asked(val q: String, val answer: DriveLoad, val deadline: com.gatecontrol.android.drive.DDeadline? = null) + + private val _asked = MutableStateFlow(null) + val asked: StateFlow = _asked.asStateFlow() + private var askJob: Job? = null + + private val _calendarBusy = MutableStateFlow(false) + val calendarBusy: StateFlow = _calendarBusy.asStateFlow() + + /** Asks Drive [q] (only from my files, never a vault). */ + fun ask(q: String) { + val t = q.trim() + if (t.length < 3) return + if ((_asked.value?.answer is DriveLoad.Loading) && _asked.value?.q == t) return + askJob?.cancel() + job?.cancel() + _state.value = null + askJob = viewModelScope.launch { + _asked.value = Asked(t, DriveLoad.Loading) + val r = api.ask(t.take(com.gatecontrol.android.drive.DriveJson.ASK_MAX)) + _asked.value = Asked(t, r.toLoad { it }) + val a = r.valueOrNull()?.takeIf { it.answered } ?: return@launch + if (live.current()?.has(com.gatecontrol.android.drive.DMe.FEATURE_DEADLINE_CALENDAR) != true) return@launch + // "Frist in den Familienkalender": only when a deadline of a source is the one the answer names + val deadlines = a.sources.map { it.nodeId }.distinct().take(3).flatMap { id -> api.insight(id).valueOrNull()?.deadlines.orEmpty() } + val d = com.gatecontrol.android.drive.DriveKi.matchDeadline(a.answer.orEmpty(), deadlines, java.time.LocalDate.now()) ?: return@launch + _asked.update { cur -> if (cur?.q == t) cur.copy(deadline = d) else cur } + } + } + + /** Back to the file search: the answer goes. */ + fun clearAsk() { + askJob?.cancel() + _asked.value = null + } + + fun calendar(d: com.gatecontrol.android.drive.DDeadline) { + if (_calendarBusy.value) return + viewModelScope.launch { + _calendarBusy.value = true + val r = api.deadlineToCalendar(d.id) + _calendarBusy.value = false + if (r is DResult.Ok) { + _asked.update { it?.copy(deadline = r.value) } + _message.value = "done_calendar" + } else { + _message.value = r.errorCode() + } + } + } + + /** A source of the answer, to open it. */ + suspend fun node(id: Long): DResult = api.node(id) } diff --git a/app/src/main/res/values-de/strings_drive3.xml b/app/src/main/res/values-de/strings_drive3.xml index 3672e595..bc8612ff 100644 --- a/app/src/main/res/values-de/strings_drive3.xml +++ b/app/src/main/res/values-de/strings_drive3.xml @@ -37,7 +37,6 @@ Garantie Sonstiges Dokument Fristen - Zahlungs-, Kündigungs- und Widerspruchsfristen deiner Dokumente Keine Fristen im kommenden Jahr. Zahlung Kündigung diff --git a/app/src/main/res/values-de/strings_drive9.xml b/app/src/main/res/values-de/strings_drive9.xml new file mode 100644 index 00000000..9240aa85 --- /dev/null +++ b/app/src/main/res/values-de/strings_drive9.xml @@ -0,0 +1,96 @@ + + + + Als Dokument gespeichert. + Im Familienkalender eingetragen. + Der Familienkalender ist nicht erreichbar (Familie ist nicht eingerichtet oder hat abgelehnt). + Zu viele Fragen in kurzer Zeit. Bitte später noch einmal. + Die KI deines GateControl ist gerade nicht verfügbar. + + Aus deinen Dokumenten erkannt · nur Dateien, die du lesen darfst + Garantieende + Vertragsverlängerung + kündigen bis %1$s + neu + In den Familienkalender + Im Familienkalender + Erinnern + Keine Erinnerung + Erinnerung am Tag selbst + + Erinnerung %d Tag vorher + Erinnerung %d Tage vorher + + + Ablagevorschlag + Vorschlag + Ablegen als + in + An die Zahlung am %1$s erinnern + Übernehmen + Anderer Ordner + Vorschlag verwerfen + + Scan prüfen + Wird erkannt … + Erkannt: %1$s + Gedruckter Text + Handschrift + Tabelle + Whiteboard + Bild + Word + Excel + Nur Bild + Als Excel-Datei ablegen? + Als Word-Datei ablegen? + Als Word-Datei mit Gliederung ablegen? + Als durchsuchbares PDF ablegen? + Nur als Bild ablegen? + Vorschau + Zahlen als Zahlen, Datum als Datum, Kopfzeile fixiert. Die Summe wird als Formel angelegt. + Das Bild bleibt, darüber liegt der erkannte Text unsichtbar: durchsuchbar und kopierbar. Ohne KI, auf dem Gerät. + Die Tabelle steht als Word-Tabelle im Dokument. + Überschriften, Stichpunkte und Aufgaben als Liste; das bereinigte Foto steht obenauf. Eingekreistes wird zu „Offen“. + Der abgeschriebene Text, der Scan als letzte Seite. + Der erkannte Text als Word-Dokument, der Scan als letzte Seite. + Wie bisher: nur das Bild. + Ablegen in + Ändern + Original-Scan behalten + Erkannt auf dem Handy, ohne KI. + Ausgewertet über den KI-Anbieter deines GateControl. + Nichts wird ausgewertet. + Als Excel-Datei speichern + Als Word-Datei speichern + Als PDF speichern + Bild speichern + + Suchen oder fragen + Dateien + Fragen + Fragen + Frag deine Dokumente, z. B. „Wann läuft die Kfz-Versicherung ab?“ + Antwort nur aus deinen Dateien. Tresore werden nie durchsucht. + Dazu habe ich in deinen Dateien nichts gefunden. + Gesucht nach: %1$s · in %2$s + Gesucht in %1$s + Frist in den Familienkalender + und + Seite %1$d + + %d Quelle + %d Quellen + + + Fotos durchsuchen + Was ist auf dem Foto? + z. B. Strand + Begriffe auf dem Handy erkannt + Keine Fotos dazu gefunden. + Fotos, die nicht vom Handy gesichert wurden (z. B. vom PC), haben noch keine Begriffe. Sie werden beim nächsten Öffnen auf dem Handy nachgetragen. + + %d Foto + %d Fotos + + diff --git a/app/src/main/res/values/strings_drive3.xml b/app/src/main/res/values/strings_drive3.xml index fe2e8f11..47deab25 100644 --- a/app/src/main/res/values/strings_drive3.xml +++ b/app/src/main/res/values/strings_drive3.xml @@ -37,7 +37,6 @@ Warranty Other document Deadlines - Payment, cancellation and objection deadlines of your documents No deadlines in the coming year. Payment Cancellation diff --git a/app/src/main/res/values/strings_drive9.xml b/app/src/main/res/values/strings_drive9.xml new file mode 100644 index 00000000..496eb4e4 --- /dev/null +++ b/app/src/main/res/values/strings_drive9.xml @@ -0,0 +1,96 @@ + + + + Saved as a document. + Added to the family calendar. + The family calendar cannot be reached (Familie is not set up or refused). + Too many questions in a short time. Please try again later. + The AI of your GateControl is not available right now. + + Recognised in your documents · only files you may read + End of warranty + Contract renewal + cancel by %1$s + new + To the family calendar + In the family calendar + Remind + No reminder + Reminder on the day + + Reminder %d day before + Reminder %d days before + + + Filing suggestion + Suggestion + File as + in + Remind me of the payment on %1$s + Accept + Other folder + Dismiss suggestion + + Check scan + Recognising … + Recognised: %1$s + Printed text + Handwriting + Table + Whiteboard + Picture + Word + Excel + Picture only + Save as an Excel file? + Save as a Word file? + Save as a Word file with an outline? + Save as a searchable PDF? + Save just the picture? + Preview + Numbers as numbers, dates as dates, header row frozen. A total becomes a formula. + The picture stays, the recognised text lies invisibly on top: searchable and copyable. Without AI, on the phone. + The table is a Word table in the document. + Headings, bullet points and tasks as a checklist; the cleaned-up photo on top. Anything circled goes under “Open”. + The transcribed text, the scan as the last page. + The recognised text as a Word document, the scan as the last page. + As before: just the picture. + Save in + Change + Keep the original scan + Recognised on the phone, without AI. + Evaluated by the AI provider of your GateControl. + Nothing is evaluated. + Save as an Excel file + Save as a Word file + Save as PDF + Save picture + + Search or ask + Files + Questions + Ask + Ask your documents, e.g. “When does the car insurance end?” + Answers only from your files. Vaults are never searched. + I found nothing about this in your files. + Searched for: %1$s · in %2$s + Searched in %1$s + Deadline to the family calendar + and + Page %1$d + + %d source + %d sources + + + Search photos + What is in the photo? + e.g. beach + Terms recognised on the phone + No photos found for this. + Photos not backed up from a phone (e.g. from the PC) have no terms yet. They are added the next time you open them on the phone. + + %d photo + %d photos + + diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveJsonKiTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveJsonKiTest.kt new file mode 100644 index 00000000..883cc568 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveJsonKiTest.kt @@ -0,0 +1,118 @@ +package com.gatecontrol.android.drive + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +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 + +/** Drive 1.14 (AI in Drive): answers and bodies of `ask`, `scans/classify`, `scans/convert`, `labels`, the photo search and the family calendar. */ +class DriveJsonKiTest { + + private fun json(s: String): JsonObject = JsonParser.parseString(s).asJsonObject + + @Test + fun `me reports the new features`() { + val me = DriveJson.me(json("""{"me":{"id":7,"seat":"family"},"licence":{"valid":true},"features":["docAsk","scanDocs","photoSearch","deadlineCalendar"]}"""))!! + listOf(DMe.FEATURE_DOC_ASK, DMe.FEATURE_SCAN_DOCS, DMe.FEATURE_PHOTO_SEARCH, DMe.FEATURE_DEADLINE_CALENDAR).forEach { assertTrue(me.has(it), it) } + } + + @Test + fun `deadlines know warranty and renewal and the calendar entry`() { + val list = DriveJson.deadlines( + json( + """{"deadlines":[ + {"id":1,"kind":"warranty","date":"2026-12-12","label":"Geschirrspüler"}, + {"id":2,"kind":"renewal","date":"2027-01-31","label":"Fitnessstudio","calendar":{"eventId":"ev_9","at":"2026-11-03T09:00:00Z"}}, + {"id":3,"kind":"weird","date":"2027-01-31"}]}""", + ), + )!! + assertEquals(listOf(1L, 2L), list.map { it.id }) + assertNull(list[0].calendar) + assertEquals(DCalendarEntry("ev_9", "2026-11-03T09:00:00Z"), list[1].calendar) + // a numeric event id is taken as text + val d = DriveJson.deadlineAnswer(json("""{"deadline":{"id":4,"kind":"cancellation","date":"2026-11-30","calendar":{"eventId":42}}}"""))!! + assertEquals("42", d.calendar?.eventId) + } + + @Test + fun `the answer of ask`() { + val a = DriveJson.answer( + json( + """{"status":"answered","answer":" Kündigen bis 30.11.2026. ","terms":["Kfz","Versicherung",{"term":"Laufzeit"},"kfz"], + "sources":[{"nodeId":12,"name":"Versicherungsschein Kfz 2026.pdf","path":"Versicherungen","page":1,"quote":"Vertragslaufzeit"}, + {"nodeId":0,"name":"x"},{"nodeId":13},{"nodeId":12,"name":"dup","page":1}]}""", + ), + )!! + assertTrue(a.answered) + assertEquals("Kündigen bis 30.11.2026.", a.answer) + assertEquals(listOf("Kfz", "Versicherung", "Laufzeit"), a.terms) + assertEquals(listOf(12L), a.sources.map { it.nodeId }) + assertEquals(DSource(12, "Versicherungsschein Kfz 2026.pdf", "Versicherungen", 1, "Vertragslaufzeit"), a.sources[0]) + + val none = DriveJson.answer(json("""{"status":"not_found","answer":null,"sources":[],"terms":["Kfz"]}"""))!! + assertFalse(none.answered) + assertEquals(listOf("Kfz"), none.terms) + assertNull(DriveJson.answer(json("""{"status":"maybe"}"""))) + // answered without words is no answer + assertFalse(DriveJson.answer(json("""{"status":"answered","answer":" "}"""))!!.answered) + + val body = DriveJson.askBody(" Wann? ", "family:3") + assertEquals("Wann?", body.get("q").asString) + assertEquals("family:3", body.get("area").asString) + assertFalse(DriveJson.askBody("x".repeat(900), null).has("area")) + assertEquals(DriveJson.ASK_MAX, DriveJson.askBody("x".repeat(900), null).get("q").asString.length) + } + + @Test + fun `classify and convert`() { + assertEquals(DScanKind("table", 0.92, "3 Spalten, 12 Zeilen, Summenzeile"), DriveJson.scanKind(json("""{"kind":"table","confidence":0.92,"summary":"3 Spalten, 12 Zeilen, Summenzeile"}"""))) + assertEquals("other", DriveJson.scanKind(json("""{"kind":"poem","confidence":7}"""))!!.kind) + assertEquals(1.0, DriveJson.scanKind(json("""{"kind":"poem","confidence":7}"""))!!.confidence) + assertNull(DriveJson.scanKind(json("""{"confidence":0.5}"""))) + + val c = DriveJson.classifyBody(listOf("abcdef12"), "Text") + assertEquals("abcdef12", c.getAsJsonArray("mediaIds")[0].asString) + assertEquals("Text", c.get("text").asString) + assertFalse(DriveJson.classifyBody(listOf("abcdef12"), " ").has("text")) + + val b = DriveJson.convertBody(listOf("abcdef12", "ghijkl34"), "xlsx", "table", 40, "2026 Zählerstände.xlsx", keepOriginal = true) + assertEquals(2, b.getAsJsonArray("mediaIds").size()) + assertEquals("xlsx", b.get("target").asString) + assertEquals("table", b.get("kind").asString) + assertEquals(40L, b.get("folderId").asLong) + assertEquals("2026 Zählerstände.xlsx", b.get("name").asString) + assertTrue(b.get("keepOriginal").asBoolean) + assertFalse(DriveJson.convertBody(listOf("abcdef12"), "docx", "printed", null, null, false).has("folderId")) + + val r = DriveJson.converted(json("""{"node":{"id":5,"areaId":"mine:7","name":"a.xlsx"},"original":{"id":6,"areaId":"mine:7","name":"a.jpg"}}"""))!! + assertEquals(5L, r.node.id) + assertEquals(6L, r.original?.id) + assertNull(DriveJson.converted(json("""{"node":{"id":5,"areaId":"mine:7","name":"a.xlsx"}}"""))!!.original) + assertNull(DriveJson.converted(json("""{}"""))) + } + + @Test + fun `labels and the photo search`() { + val b = DriveJson.labelsBody(listOf(" Beach ", "beach", "", "x".repeat(50)) + (1..30).map { "L$it" }) + val labels = b.getAsJsonArray("labels").map { it.asString } + assertEquals("Beach", labels[0]) + assertEquals(40, labels[1].length) + assertEquals(DriveJson.LABELS_MAX, labels.size) + assertEquals("device", b.get("source").asString) + + assertEquals("Strand Meer", DriveJson.photosQuery(q = " Strand Meer ")!!["q"]) + assertFalse(DriveJson.photosQuery(q = " ")!!.containsKey("q")) + assertEquals(DriveJson.PHOTO_Q_MAX, DriveJson.photosQuery(q = "x".repeat(300))!!["q"]!!.length) + + val p = DriveJson.photos(json("""{"items":[{"id":1,"areaId":"mine:7","name":"a.jpg"}],"next":null,"total":38,"terms":[{"label":"Meer","count":20},"Sonnenuntergang"]}"""))!! + assertEquals(38, p.total) + assertEquals(listOf("Meer", "Sonnenuntergang"), p.terms) + val plain = DriveJson.photos(json("""{"items":[]}"""))!! + assertNull(plain.total) + assertTrue(plain.terms.isEmpty()) + assertEquals(listOf("a"), DriveJson.terms(json("""{"chips":["a"]}"""))) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveKiTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveKiTest.kt new file mode 100644 index 00000000..0159feb3 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveKiTest.kt @@ -0,0 +1,181 @@ +package com.gatecontrol.android.drive + +import com.gatecontrol.android.drive.DriveKi.Format +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.LocalDate + +/** Drive 1.14 (AI in Drive): the pure rules of D1–D5. */ +class DriveKiTest { + + private val today = LocalDate.of(2026, 11, 3) + + private fun node(id: Long = 1, name: String = "a.pdf", mime: String? = "application/pdf", dir: Boolean = false, vault: String? = null) = + DNode(id, "mine:7", 2, name, dir, 10, mime, null, 1, null, null, null, vaultId = vault) + + private fun deadline(id: Long, kind: String, date: String, reminder: DReminder? = null, calendar: DCalendarEntry? = null) = + DDeadline(id, kind, date, null, reminder, calendar = calendar) + + // ─── D5 ───────────────────────────────────────────────────────── + + @Test + fun `formats follow the kind - Excel only for a table, nothing of the AI without it`() { + assertEquals(listOf(Format.PDF, Format.DOCX, Format.XLSX, Format.IMAGE), DriveKi.formats("table", ai = true)) + assertEquals(listOf(Format.PDF, Format.DOCX, Format.IMAGE), DriveKi.formats("whiteboard", ai = true)) + assertEquals(listOf(Format.PDF, Format.DOCX, Format.IMAGE), DriveKi.formats("handwriting", ai = true)) + assertEquals(listOf(Format.PDF, Format.DOCX, Format.IMAGE), DriveKi.formats("printed", ai = true)) + assertEquals(listOf(Format.PDF, Format.IMAGE), DriveKi.formats("other", ai = true)) + assertEquals(listOf(Format.PDF, Format.IMAGE), DriveKi.formats("table", ai = false)) + assertEquals(listOf(Format.PDF, Format.IMAGE), DriveKi.formats(null, ai = true)) + } + + @Test + fun `the chip chosen first`() { + assertEquals(Format.XLSX, DriveKi.defaultFormat("table", true, "pdf")) + assertEquals(Format.DOCX, DriveKi.defaultFormat("whiteboard", true, "pdf")) + assertEquals(Format.DOCX, DriveKi.defaultFormat("handwriting", true, "image")) + assertEquals(Format.PDF, DriveKi.defaultFormat("printed", true, "image")) + assertEquals(Format.IMAGE, DriveKi.defaultFormat("other", true, "image")) + assertEquals(Format.PDF, DriveKi.defaultFormat("other", true, null)) + // without the AI a table is a PDF + assertEquals(Format.PDF, DriveKi.defaultFormat("table", false, "pdf")) + } + + @Test + fun `names - cleaned, the title from the first fitting line, the extension of the format`() { + assertEquals("Rechnung 2026", DriveKi.cleanName(" Rechnung/2026 ")) + assertEquals("a b", DriveKi.cleanName("a:*?b")) + assertNull(DriveKi.cleanName(" /?: ")) + assertEquals(80, DriveKi.cleanName("x".repeat(200))!!.length) + + assertEquals("Sprint-Planung KW 45", DriveKi.title(listOf("12", "03.11.2026", "Sprint-Planung KW 45", "Ziele"))) + assertNull(DriveKi.title(listOf("12", "Ab", "1.208,40"))) + + assertEquals("2026-11-03 Sprint-Planung KW 45", DriveKi.scanName("Sprint-Planung KW 45", today, "Scan x")) + assertEquals("Scan x", DriveKi.scanName(null, today, "Scan x")) + + assertEquals("2026 Zählerstände.xlsx", DriveKi.fileName("2026 Zählerstände", Format.XLSX)) + assertEquals("Brief.docx", DriveKi.fileName("Brief.pdf", Format.DOCX)) + assertEquals("v1.2 Plan.pdf", DriveKi.fileName("v1.2 Plan", Format.PDF)) + assertEquals("Scan.jpg", DriveKi.fileName(" ", Format.IMAGE)) + } + + @Test + fun `a table read off the lines, a text is none`() { + fun l(t: String, x: Int, y: Int) = DriveKi.OcrLine(t, x, y, x + 80, y + 20) + val lines = listOf( + l("Datum", 10, 10), l("Zähler", 200, 12), l("Stand (kWh)", 400, 9), + l("01.01.2026", 10, 50), l("Strom", 205, 51), l("41.208", 410, 49), + l("01.02.2026", 12, 90), l("Strom", 200, 92), l("41.497", 405, 90), + l("Summe", 10, 130), l("=C13-C2", 400, 131), + ) + val grid = DriveKi.tableGrid(lines.shuffled(java.util.Random(4))) + assertEquals(listOf("Datum", "Zähler", "Stand (kWh)"), grid[0]) + assertEquals(listOf("01.02.2026", "Strom", "41.497"), grid[2]) + assertEquals(listOf("Summe", "", "=C13-C2"), grid[3]) + assertEquals(2, DriveKi.tableGrid(lines, maxRows = 2).size) + + val text = (0 until 6).map { l("Eine Zeile Text $it", 10, 10 + it * 30) } + assertTrue(DriveKi.tableGrid(text).isEmpty()) + assertEquals(listOf("a", "b"), DriveKi.docPreview("a\n\n b \n", 8)) + assertEquals(1, DriveKi.docPreview("a\nb", 1).size) + } + + // ─── D4 ───────────────────────────────────────────────────────── + + @Test + fun `the filing bar - ready with a suggestion, the payment still ahead and not reminded`() { + val pay = deadline(5, "payment", "2026-11-28") + val i = DInsight("ready", "invoice", null, null, null, null, null, null, null, DSuggestion(40, "Finanzen/Strom", "2026-11-03 Stadtwerke Rechnung.pdf"), listOf(pay, deadline(6, "payment", "2026-10-01"))) + val f = DriveKi.filing(i, today)!! + assertEquals("2026-11-03 Stadtwerke Rechnung.pdf", f.name) + assertEquals(40L, f.folderId) + assertEquals(5L, f.payment?.id) + + assertNull(DriveKi.filing(i.copy(status = "pending"), today)) + assertNull(DriveKi.filing(i.copy(suggestion = null), today)) + assertNull(DriveKi.filing(i.copy(suggestion = DSuggestion(null, null, null)), today)) + // reminded already: no payment line + assertNull(DriveKi.filing(i.copy(deadlines = listOf(pay.copy(reminder = DReminder(null, 3, false)))), today)!!.payment) + assertTrue(DriveKi.FILING_POLL_MS.sum() in 55_000L..65_000L) + } + + @Test + fun `only documents and scans are waited for, never a vault`() { + assertTrue(DriveKi.fileable(node(), scan = false)) + assertFalse(DriveKi.fileable(node(name = "x.jpg", mime = "image/jpeg"), scan = false)) + assertTrue(DriveKi.fileable(node(name = "Scan.jpg", mime = "image/jpeg"), scan = true)) + assertFalse(DriveKi.fileable(node(dir = true), scan = true)) + assertFalse(DriveKi.fileable(node(vault = "v"), scan = true)) + assertFalse(DriveKi.fileable(node(name = "a.mp4", mime = "video/mp4"), scan = false)) + } + + // ─── D2 ───────────────────────────────────────────────────────── + + @Test + fun `new deadlines and the calendar`() { + assertEquals(emptySet(), DriveKi.fresh(listOf(1, 2), null)) + assertEquals(setOf(3L), DriveKi.fresh(listOf(1, 2, 3), setOf(1, 2, 9))) + assertTrue(DriveKi.calendarOpen(deadline(1, "renewal", "2026-11-03"), today)) + assertFalse(DriveKi.calendarOpen(deadline(1, "renewal", "2026-11-02"), today)) + } + + // ─── D1 ───────────────────────────────────────────────────────── + + @Test + fun `a question ends with a question mark`() { + assertTrue(DriveKi.isQuestion("Wann läuft die Kfz-Versicherung ab? ")) + assertFalse(DriveKi.isQuestion("Rechnung")) + assertFalse(DriveKi.isQuestion("?")) + } + + @Test + fun `dates in an answer and the deadline it names`() { + val a = "Die Kfz-Versicherung läuft bis 31.12.2026 und verlängert sich jeweils um ein Jahr. Kündigen kannst du bis 30.11.2026." + assertEquals(setOf(LocalDate.of(2026, 12, 31), LocalDate.of(2026, 11, 30)), DriveKi.datesIn(a, today)) + assertEquals(2, DriveKi.dateSpans(a).size) + assertEquals("31.12.2026", a.substring(DriveKi.dateSpans(a)[0])) + // without a year: this year, or the next when over + assertEquals(setOf(LocalDate.of(2026, 11, 28), LocalDate.of(2027, 1, 5)), DriveKi.datesIn("am 28.11. und am 5.1.", today)) + assertEquals(setOf(LocalDate.of(2026, 12, 1)), DriveKi.datesIn("bis 2026-12-01", today)) + assertTrue(DriveKi.datesIn("Version 1.2 von 3 Seiten, 41.208 kWh", today).none { it.year == 2026 && it.monthValue == 2 && it.dayOfMonth == 41 }) + + val end = deadline(1, "payment", "2026-12-31") + val cancel = deadline(2, "cancellation", "2026-11-30") + val other = deadline(3, "cancellation", "2027-03-01") + assertEquals(2L, DriveKi.matchDeadline(a, listOf(end, cancel, other), today)?.id) + assertEquals(1L, DriveKi.matchDeadline(a, listOf(end, other), today)?.id) + assertNull(DriveKi.matchDeadline(a, listOf(other), today)) + assertNull(DriveKi.matchDeadline("Keine Daten", listOf(end), today)) + } + + // ─── D3 ───────────────────────────────────────────────────────── + + @Test + fun `the labels of a photo - sure ones, surest first, at most 20`() { + val found = listOf("Sky" to 0.9f, "Beach" to 0.95f, "Cat" to 0.3f, "sky" to 0.7f, "" to 1f) + (1..30).map { "L$it" to 0.8f } + val labels = DriveKi.labels(found) + assertEquals("Beach", labels[0]) + assertEquals("Sky", labels[1]) + assertFalse("Cat" in labels) + assertEquals(20, labels.size) + assertEquals(1, labels.count { it.equals("sky", ignoreCase = true) }) + assertEquals(40, DriveKi.labels(listOf("x".repeat(60) to 1f))[0].length) + } + + @Test + fun `the photo search - words, adding and removing a term, the chips`() { + assertEquals(listOf("Strand", "Meer"), DriveKi.words(" Strand + Meer, strand")) + assertEquals("Strand Meer", DriveKi.withTerm("Strand", "Meer")) + assertEquals("Strand", DriveKi.withTerm("Strand", "strand")) + assertEquals("Meer", DriveKi.withoutTerm("Strand Meer", "strand")) + val chips = DriveKi.chips("Strand", listOf("strand", "Meer", "Sonnenuntergang", "2025")) + assertEquals(listOf(DriveKi.Chip("Strand", true), DriveKi.Chip("Meer", false), DriveKi.Chip("Sonnenuntergang", false), DriveKi.Chip("2025", false)), chips) + assertEquals(2, DriveKi.chips("a b c", listOf("d"), max = 2).size) + assertNotNull(DriveKi.chips("", emptyList())) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveSearchablePdfTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveSearchablePdfTest.kt new file mode 100644 index 00000000..5fe75788 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveSearchablePdfTest.kt @@ -0,0 +1,94 @@ +package com.gatecontrol.android.drive + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +/** Drive 1.14 (D5): the searchable PDF made on the phone. */ +class DriveSearchablePdfTest { + + /** A JPEG start: SOI, an APP0 segment, then SOF0 with [h] × [w] and [c] components. */ + private fun jpeg(w: Int, h: Int, c: Int = 3, sof: Int = 0xC0): ByteArray { + val app0 = byteArrayOf(0xFF.toByte(), 0xE0.toByte(), 0, 6, 'J'.code.toByte(), 'F'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte()) + val sofSeg = byteArrayOf( + 0xFF.toByte(), sof.toByte(), 0, 11, 8, + (h shr 8).toByte(), h.toByte(), (w shr 8).toByte(), w.toByte(), c.toByte(), 1, 0x11, 0, + ) + return byteArrayOf(0xFF.toByte(), 0xD8.toByte()) + app0 + sofSeg + byteArrayOf(0xFF.toByte(), 0xD9.toByte()) + } + + @Test + fun `the JPEG's size and colours`() { + assertEquals(DriveSearchablePdf.JpegInfo(1240, 1754, 3), DriveSearchablePdf.jpegInfo(jpeg(1240, 1754))) + assertEquals(DriveSearchablePdf.JpegInfo(800, 600, 1), DriveSearchablePdf.jpegInfo(jpeg(800, 600, c = 1, sof = 0xC2))) + // a DHT (C4) is no frame + assertNull(DriveSearchablePdf.jpegInfo(jpeg(800, 600, sof = 0xC4))) + assertNull(DriveSearchablePdf.jpegInfo(byteArrayOf(0x89.toByte(), 'P'.code.toByte(), 'N'.code.toByte(), 'G'.code.toByte()))) + assertNull(DriveSearchablePdf.jpegInfo(jpeg(800, 600, c = 2))) + } + + @Test + fun `page size - A4 wide, landscape A4 long`() { + val (w, h) = DriveSearchablePdf.pageSize(DriveSearchablePdf.JpegInfo(1000, 2000, 3)) + assertEquals(595.28, w, 0.01) + assertEquals(1190.56, h, 0.01) + assertEquals(841.89, DriveSearchablePdf.pageSize(DriveSearchablePdf.JpegInfo(2000, 1000, 3)).first, 0.01) + } + + @Test + fun `text is escaped in WinAnsi`() { + assertEquals("a\\(b\\)\\\\", String(DriveSearchablePdf.escape("a(b)\\"), Charsets.US_ASCII)) + assertEquals("Z\\344hler \\200", String(DriveSearchablePdf.escape("Zähler €"), Charsets.US_ASCII)) + assertEquals("? ", String(DriveSearchablePdf.escape("漢\n"), Charsets.US_ASCII)) + } + + @Test + fun `the page draws the picture, then the lines invisibly where they stand`() { + val p = DriveSearchablePdf.Page(10, DriveSearchablePdf.JpegInfo(1000, 1000, 3), 500, 500, listOf(DriveKi.OcrLine("Rechnung", 50, 100, 250, 120), DriveKi.OcrLine(" ", 0, 0, 1, 1))) + val c = String(DriveSearchablePdf.content(p, 600.0, 600.0) { it.length * 0.5 }, Charsets.US_ASCII) + assertTrue(c.startsWith("q 600 0 0 600 0 0 cm /Im0 Do Q\n"), c) + assertTrue(c.contains("BT 3 Tr"), c) + // line height 20 px → 24 pt, font 20.4; width 240 pt for 8 × 0.5 × 20.4 = 81.6 → 294.12 % + assertTrue(c.contains("/F1 20.4 Tf 294.12 Tz 1 0 0 1 60 460.8 Tm (Rechnung) Tj"), c) + assertEquals(1, Regex("Tj").findAll(c).count()) + // nothing read: only the picture + val empty = String(DriveSearchablePdf.content(p.copy(lines = emptyList()), 600.0, 600.0), Charsets.US_ASCII) + assertTrue(!empty.contains("BT")) + } + + @Test + fun `a whole PDF - objects, the pictures as they are, a cross-reference table that points right`() { + val pics = listOf(jpeg(100, 200) + ByteArray(50) { 7 }, jpeg(300, 100) + ByteArray(20) { 9 }) + val pages = pics.map { DriveSearchablePdf.Page(it.size.toLong(), DriveSearchablePdf.jpegInfo(it)!!, 100, 200, listOf(DriveKi.OcrLine("Hallo Welt", 10, 10, 90, 30))) } + val out = ByteArrayOutputStream() + DriveSearchablePdf.write(out, pages, open = { ByteArrayInputStream(pics[it]) }) + val bytes = out.toByteArray() + val text = String(bytes, Charsets.ISO_8859_1) + assertTrue(text.startsWith("%PDF-1.4\n")) + assertTrue(text.trimEnd().endsWith("%%EOF")) + assertTrue(text.contains("/Count 2")) + assertTrue(text.contains("/Width 300 /Height 100 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${pics[1].size}")) + assertTrue(text.contains("/MediaBox [0 0 841.89 280.63]"), text.take(2000)) + // the pictures go in unchanged + assertTrue(text.contains(String(pics[0], Charsets.ISO_8859_1))) + // xref: every offset starts its object + val startxref = Regex("startxref\n(\\d+)").find(text)!!.groupValues[1].toInt() + assertTrue(text.substring(startxref).startsWith("xref\n0 11\n")) + val offsets = Regex("(\\d{10}) 00000 n ").findAll(text.substring(startxref)).map { it.groupValues[1].toInt() }.toList() + assertEquals(10, offsets.size) + offsets.forEachIndexed { i, o -> assertTrue(text.substring(o).startsWith("${i + 1} 0 obj"), "object ${i + 1}") } + } + + @Test + fun `a picture shorter than announced is refused`() { + val pic = jpeg(10, 10) + val page = DriveSearchablePdf.Page(pic.size + 100L, DriveSearchablePdf.jpegInfo(pic)!!, 10, 10, emptyList()) + assertThrows(IllegalArgumentException::class.java) { + DriveSearchablePdf.write(ByteArrayOutputStream(), listOf(page), open = { ByteArrayInputStream(pic) }) + } + } +} From d2e073e0c0740ebefcee3f4d0e95bb5bc572758e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:49:02 +0000 Subject: [PATCH 2/2] Drive-KI: an den Stand des Drive-Plugins 1.14.0 angepasst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scans/convert liefert die Bytes: hochladen und mit scans/save ablegen, Whiteboard/Handschrift mit verkleinerten Bildern (images, < 600 KB, bei 413 ohne), höchstens 8 Seiten; Original-Scan bei Bedarf als PDF daneben, Vorgabe aus settings.scanKeepOriginal - Bereichsschalter „Neue Fristen in den Familienkalender“ (PATCH areas/ { deadlinesToCalendar }) - Nachtragen der Bildbegriffe über photos?unlabeled=1, 403/400 still übersprungen; Treffer-Chips aus terms [{ term, label, count }] - ask: 3–300 Zeichen, Frist und Bereiche aus der Antwort - verständliche Meldung bei familie_unavailable Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../com/gatecontrol/android/drive/DriveApi.kt | 32 +++++-- .../gatecontrol/android/drive/DriveJson.kt | 70 +++++++++++++-- .../com/gatecontrol/android/drive/DriveKi.kt | 29 ------ .../android/drive/DriveKiModels.kt | 26 +++++- .../gatecontrol/android/drive/DriveModels.kt | 8 ++ .../gatecontrol/android/drive/DriveScanner.kt | 67 +++++++++++++- .../android/drive/backup/DrivePhotoLabels.kt | 64 ++++--------- .../android/ui/drive/DriveComponents.kt | 5 +- .../gatecontrol/android/ui/drive/DriveKiUi.kt | 27 +++++- .../android/ui/drive/DriveMoreScreens.kt | 18 ++-- .../android/ui/drive/DriveSettingsScreen.kt | 27 ++++++ .../android/ui/drive/DriveViewModels.kt | 89 ++++++++++++++----- app/src/main/res/values-de/strings_drive9.xml | 4 +- app/src/main/res/values/strings_drive9.xml | 4 +- .../android/drive/DriveJsonKiTest.kt | 72 ++++++++++++--- .../gatecontrol/android/drive/DriveKiTest.kt | 17 +--- 16 files changed, 394 insertions(+), 165 deletions(-) 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 34a875d3..e00dd8a1 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt @@ -256,8 +256,8 @@ class DriveApi @Inject constructor( * Photos and videos, newest first; [before] is the cursor of the page before. Without [area]: all I can read. * [camera]: only the camera uploads (Drive 1.10, feature `photosFolder`). */ - suspend fun photos(area: String? = null, before: String? = null, limit: Int = 120, camera: Boolean = false, cls: String? = null, q: String? = null): DResult = - get("photos", DriveJson.photosQuery(area, before, limit, camera, cls = cls, q = q) ?: return bad()) { o -> DriveJson.photos(o)?.let { it.copy(items = DriveLogic.withoutVaults(it.items)) } } + suspend fun photos(area: String? = null, before: String? = null, limit: Int = 120, camera: Boolean = false, cls: String? = null, q: String? = null, unlabeled: Boolean = false): DResult = + get("photos", DriveJson.photosQuery(area, before, limit, camera, cls = cls, q = q, unlabeled = unlabeled) ?: return bad()) { o -> DriveJson.photos(o)?.let { it.copy(items = DriveLogic.withoutVaults(it.items)) } } suspend fun photoMonths(area: String? = null, camera: Boolean = false, cls: String? = null): DResult> = get("photos/months", DriveJson.photosQuery(area, camera = camera, cls = cls) ?: return bad(), DriveJson::months) @@ -407,23 +407,37 @@ class DriveApi @Inject constructor( */ suspend fun ask(q: String, area: String? = null): DResult { val a = area?.let { DriveJson.areaId(it) ?: return bad() } + if (q.trim().length < DriveJson.ASK_MIN) return bad() return call(timeoutMs = AI_TIMEOUT_MS, media = true, request = { pluginPost(DRIVE_PLUGIN, "ask", DriveJson.askBody(q, a), lang()) }, parse = DriveJson::answer) } /** What the scanned pages [mediaIds] are (feature `scanDocs`); the [text] recognised on the phone goes first. */ suspend fun classifyScan(mediaIds: List, text: String?): DResult { - if (mediaIds.isEmpty() || mediaIds.any { DriveJson.mediaId(it) == null }) return bad() + if (mediaIds.isEmpty() || mediaIds.size > DriveJson.SCAN_MEDIA_MAX || mediaIds.any { DriveJson.mediaId(it) == null }) return bad() return call(timeoutMs = AI_TIMEOUT_MS, media = true, request = { pluginPost(DRIVE_PLUGIN, "scans/classify", DriveJson.classifyBody(mediaIds, text), lang()) }, parse = DriveJson::scanKind) } /** - * The scanned pages [mediaIds] as a document [target] (`docx` | `xlsx` | `pdf`) of [kind] in [folderId], named [name]; - * [keepOriginal]: the scan stays next to it. + * The scanned pages [mediaIds] (at most 8) as a document [target] (`docx` | `xlsx`) of [kind], named [name] for + * [folderId]; [text] what the phone read, [images] small JPEGs (base64, ≤ 600 KB together: a whiteboard's photo on + * top, handwriting as the last page). The answer carries the file's bytes: upload them and [saveScan] them. */ - suspend fun convertScan(mediaIds: List, target: String, kind: String, folderId: Long?, name: String?, keepOriginal: Boolean): DResult { - if (mediaIds.isEmpty() || mediaIds.any { DriveJson.mediaId(it) == null } || target !in DriveJson.SCAN_TARGETS) return bad() - val body = DriveJson.convertBody(mediaIds, target, kind.takeIf { it in DriveJson.SCAN_KINDS } ?: "other", folderId, name, keepOriginal) - return call(timeoutMs = CONVERT_TIMEOUT_MS, media = true, request = { pluginPost(DRIVE_PLUGIN, "scans/convert", body, lang()) }, parse = DriveJson::converted) + suspend fun convertScan(mediaIds: List, target: String, kind: String, folderId: Long?, name: String?, text: String?, images: List = emptyList()): DResult { + if (mediaIds.isEmpty() || mediaIds.size > DriveJson.SCAN_MEDIA_MAX || mediaIds.any { DriveJson.mediaId(it) == null } || target !in DriveJson.SCAN_TARGETS) return bad() + val body = DriveJson.convertBody(mediaIds, target, kind.takeIf { it in DriveJson.SCAN_KINDS } ?: "other", folderId, name, text, images) + return call(timeoutMs = CONVERT_TIMEOUT_MS, media = true, request = { pluginPost(DRIVE_PLUGIN, "scans/convert", body, lang()) }, parse = DriveJson::conversion) + } + + /** The uploaded document [mediaId] filed as [name] in [folderId] (a free name), with [text] as its recognised text. */ + suspend fun saveScan(mediaId: String, folderId: Long, name: String, text: String?): DResult { + if (DriveJson.mediaId(mediaId) == null) return bad() + return post("scans/save", DriveJson.saveScanBody(mediaId, folderId, name, text), DriveJson::saved) + } + + /** New deadlines of area [id] into the family calendar on their own (Drive 1.14, write right) → the setting now. */ + suspend fun setAreaDeadlinesToCalendar(id: String, on: Boolean): DResult { + val area = DriveJson.areaId(id) ?: return bad() + return patch("areas/$area", DriveJson.deadlinesToCalendarBody(on), DriveJson::areaDeadlinesToCalendar) } /** What is in photo [id], found on this phone (feature `photoSearch`). */ 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 2c9c5d5c..6056ef83 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt @@ -49,6 +49,7 @@ internal object DriveJson { }?.toMap().orEmpty(), wipes = o.int("wipes")?.coerceAtLeast(0) ?: 0, scanFormat = if (o.obj("settings")?.str("scanFormat") == "image") "image" else "pdf", + scanKeepOriginal = o.obj("settings")?.bool("scanKeepOriginal"), admin = me.bool("admin") ?: false, deviceId = me.str("deviceId")?.takeIf { DEVICE_ID.matches(it) }, ).let { base -> @@ -146,6 +147,7 @@ internal object DriveJson { rootId = root, archived = o.bool("archived") ?: false, docAi = o.bool("docAi"), + deadlinesToCalendar = o.bool("deadlinesToCalendar"), storage = st?.let { DStorage( bytes = it.long("bytes"), quota = it.long("quota"), form = it.str("form")?.take(20), @@ -196,6 +198,7 @@ internal object DriveJson { insight = o.obj("insight")?.let(::insightShort), review = o.obj("review")?.let { DReview(it.int("hint")?.coerceIn(0, 100)) }, work = o.bool("work") == true, + labeled = o.bool("labeled"), ).let { n -> // a node of a vault (Drive 1.9): its name is opaque, the encrypted one comes as nameCipher val vault = vaultId(o.str("vaultId")) @@ -456,6 +459,7 @@ internal object DriveJson { date: String? = null, cls: String? = null, q: String? = null, + unlabeled: Boolean = false, ): Map? = buildMap { if (area != null) put("area", areaId(area) ?: return null) @@ -465,6 +469,7 @@ internal object DriveJson { if (camera) put("camera", "1") if (cls != null) put("class", cls.takeIf { it in PHOTO_CLASSES } ?: return null) if (q != null) q.trim().take(PHOTO_Q_MAX).takeIf { it.isNotEmpty() }?.let { put("q", it) } + if (unlabeled) put("unlabeled", "1") } /** The values of `class` (Drive 1.13). */ @@ -715,6 +720,7 @@ internal object DriveJson { reminder = r?.let { DReminder(it.str("at")?.take(40), it.int("daysBefore")?.coerceIn(0, 90) ?: 0, it.bool("sent") ?: false) }, node = node(o.obj("node")), calendar = o.obj("calendar")?.let { c -> DCalendarEntry(c.idString("eventId")?.take(200), c.str("at")?.take(40)) }, + nodeId = o.long("nodeId")?.takeIf { it > 0 }, ) } @@ -1160,8 +1166,9 @@ internal object DriveJson { if (area != null) addProperty("area", area) } - /** How long a question may be. */ - const val ASK_MAX = 500 + /** How long a question may be (3 … 300 characters). */ + const val ASK_MIN = 3 + const val ASK_MAX = 300 /** `POST ask` → `{ status, answer, sources, terms }`. */ fun answer(o: JsonObject): DAnswer? { @@ -1176,7 +1183,14 @@ internal object DriveJson { quote = s.str("quote")?.trim()?.take(600)?.ifEmpty { null }, ) }.orEmpty().distinctBy { it.nodeId to it.page }.take(8) - return DAnswer(status, o.str("answer")?.trim()?.take(4000)?.ifEmpty { null }, sources, terms(o)) + return DAnswer( + status, + o.str("answer")?.trim()?.take(4000)?.ifEmpty { null }, + sources, + terms(o), + areas = o.arr("areas")?.mapNotNull { e -> runCatching { e.asString }.getOrNull()?.trim()?.take(200)?.ifEmpty { null } }?.take(20).orEmpty(), + deadline = deadline(o.obj("deadline")), + ) } fun classifyBody(mediaIds: List, text: String?) = JsonObject().apply { @@ -1194,21 +1208,61 @@ internal object DriveJson { ) } - fun convertBody(mediaIds: List, target: String, kind: String, folderId: Long?, name: String?, keepOriginal: Boolean) = JsonObject().apply { + /** At most this many pages go to `scans/classify` and `scans/convert`. */ + const val SCAN_MEDIA_MAX = 8 + /** The pictures of `scans/convert` (`images`) together at most this many bytes (else `413 too_large`). */ + const val SCAN_IMAGES_MAX = 600 * 1024 + + fun convertBody(mediaIds: List, target: String, kind: String, folderId: Long?, name: String?, text: String?, images: List) = JsonObject().apply { add("mediaIds", JsonArray().apply { mediaIds.forEach { add(it) } }) addProperty("target", target) addProperty("kind", kind) if (folderId != null) addProperty("folderId", folderId) if (!name.isNullOrBlank()) addProperty("name", name.take(255)) - addProperty("keepOriginal", keepOriginal) + if (!text.isNullOrBlank()) addProperty("text", text.take(TEXT_MAX)) + if (images.isNotEmpty()) add("images", JsonArray().apply { images.forEach { add(it) } }) } - /** `POST scans/convert` → `{ node, original? }`. */ - fun converted(o: JsonObject): DConverted? { + /** `POST scans/convert` → `{ kind, target, title, name, folderId, text, file { name, mime, data } | null, … }`. */ + fun conversion(o: JsonObject): DConversion? { + val target = o.str("target")?.takeIf { it in SCAN_TARGETS } ?: return null + val f = o.obj("file") + val file = f?.let { + val data = it.str("data")?.let { d -> runCatching { java.util.Base64.getMimeDecoder().decode(d.substringAfter("base64,")) }.getOrNull() } + val name = it.str("name")?.trim()?.take(255)?.ifEmpty { null } + if (data == null || data.isEmpty() || name == null) null else DConvertedFile(name, it.str("mime")?.take(200) ?: "application/octet-stream", data) + } + // a file announced but unreadable is no answer + if (f != null && file == null) return null + return DConversion( + kind = o.str("kind")?.takeIf { it in SCAN_KINDS } ?: "other", + target = target, + title = o.str("title")?.trim()?.take(200)?.ifEmpty { null }, + name = o.str("name")?.trim()?.take(255)?.ifEmpty { null }, + folderId = o.long("folderId"), + text = o.str("text")?.take(TEXT_MAX)?.ifBlank { null }, + file = file, + ) + } + + fun saveScanBody(mediaId: String, folderId: Long, name: String, text: String?) = JsonObject().apply { + addProperty("mediaId", mediaId) + addProperty("folderId", folderId) + addProperty("name", name.take(255)) + if (!text.isNullOrBlank()) addProperty("text", text.take(TEXT_MAX)) + } + + /** `POST scans/save` → `{ node, original }`. */ + fun saved(o: JsonObject): DSaved? { val node = node(o.obj("node")) ?: return null - return DConverted(node, node(o.obj("original"))) + return DSaved(node, node(o.obj("original"))) } + fun deadlinesToCalendarBody(on: Boolean) = JsonObject().apply { addProperty("deadlinesToCalendar", on) } + + /** `PATCH areas/` → `{ area { deadlinesToCalendar } }`. */ + fun areaDeadlinesToCalendar(o: JsonObject): Boolean? = o.obj("area")?.bool("deadlinesToCalendar") + /** `PUT nodes//labels { labels, source: 'device' }`: at most [LABELS_MAX] labels of at most [LABEL_MAX] characters. */ fun labelsBody(labels: List) = JsonObject().apply { add( diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveKi.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveKi.kt index 78aa788e..39de46f2 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveKi.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveKi.kt @@ -170,35 +170,6 @@ object DriveKi { fun dateSpans(text: String): List = (DATE_DOTTED.findAll(text) + DATE_ISO.findAll(text)).map { it.range }.sortedBy { it.first }.toList() - /** The days named in [text]; one without a year takes the year of [today] (or the next, when already past). */ - fun datesIn(text: String, today: LocalDate): Set { - val out = HashSet() - DATE_DOTTED.findAll(text).forEach { m -> - val d = m.groupValues[1].toIntOrNull() ?: return@forEach - val mo = m.groupValues[2].toIntOrNull() ?: return@forEach - val y = m.groupValues[3].takeIf { it.isNotEmpty() }?.toIntOrNull()?.let { if (it < 100) 2000 + it else it } - val date = runCatching { LocalDate.of(y ?: today.year, mo, d) }.getOrNull() ?: return@forEach - out += if (y == null && date.isBefore(today)) date.plusYears(1) else date - } - DATE_ISO.findAll(text).forEach { m -> runCatching { LocalDate.parse(m.value) }.getOrNull()?.let { out += it } } - return out - } - - /** - * The deadline of a source the answer speaks of: one whose day the answer names. Still ahead comes first, then - * cancellation and renewal (what the family calendar is for), then the earliest. - */ - fun matchDeadline(answer: String, deadlines: List, today: LocalDate): DDeadline? { - val named = datesIn(answer, today) - if (named.isEmpty()) return null - return deadlines - .filter { d -> runCatching { LocalDate.parse(d.date) }.getOrNull() in named } - .sortedWith( - compareBy({ !calendarOpen(it, today) }, { it.kind !in setOf("cancellation", "renewal") }, { it.date }), - ) - .firstOrNull() - } - // ─── D3: photos by what is in them ───────────────────────────── /** Labels the labeler is at least this sure of go to Drive. */ diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveKiModels.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveKiModels.kt index cdb8e69b..ff899803 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveKiModels.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveKiModels.kt @@ -11,6 +11,10 @@ data class DAnswer( val answer: String?, val sources: List, val terms: List, + /** Where Drive searched (the areas' labels). */ + val areas: List = emptyList(), + /** A deadline of a source whose day the answer names ("Frist in den Familienkalender"); [DDeadline.nodeId] its file. */ + val deadline: DDeadline? = null, ) { val answered: Boolean get() = status == STATUS_ANSWERED && !answer.isNullOrBlank() @@ -29,5 +33,23 @@ data class DSource(val nodeId: Long, val name: String, val path: String?, val pa */ data class DScanKind(val kind: String, val confidence: Double?, val summary: String?) -/** `POST scans/convert`: the new document and, kept, the original scan. */ -data class DConverted(val node: DNode, val original: DNode?) +/** + * `POST scans/convert`: the document Drive made — as bytes ([file]; GateControl lets no plugin write a file of its own, + * the app uploads them and files them with `scans/save`), its [name], its recognised [text]; [file] null: a PDF + * without pictures (the app makes it itself). + */ +data class DConversion( + val kind: String, + val target: String, + val title: String?, + val name: String?, + val folderId: Long?, + val text: String?, + val file: DConvertedFile?, +) + +/** The bytes of a converted scan. */ +class DConvertedFile(val name: String, val mime: String, val data: ByteArray) + +/** `POST scans/save`: the filed document and the scan's file while it stays. */ +data class DSaved(val node: DNode, val original: DNode?) 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 419c7055..082c3391 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt @@ -55,6 +55,8 @@ data class DMe( val admin: Boolean = false, /** This device as Drive knows it (the app's device token; null without the permission `devices`). */ val deviceId: String? = null, + /** Drive 1.14: "Original-Scan behalten" by default (setting *scan_keep_original*); null when the server says nothing. */ + val scanKeepOriginal: Boolean? = null, ) { fun has(feature: String): Boolean = feature in features @@ -160,6 +162,8 @@ data class DArea( val storage: DStorage? = null, /** AI for documents in this area (Drive 1.8, `PATCH areas/ { docAi }`); null while the feature is off. */ val docAi: Boolean? = null, + /** New deadlines into the family calendar on their own (Drive 1.14, with `deadlineCalendar`); null while not offered. */ + val deadlinesToCalendar: Boolean? = null, ) { val writable: Boolean get() = !archived && (role == "write" || role == "manage") } @@ -208,6 +212,8 @@ data class DNode( val review: DReview? = null, /** Drive 1.13: a photo item below my work folder (the small blue bag). */ val work: Boolean = false, + /** Drive 1.14 (`photoSearch`): a phone looked at the photo and sent what is on it; null when the server says nothing. */ + val labeled: Boolean? = null, ) { /** In a vault: nothing of it may go to a preview, a link, the office, the AI or a backup. */ val encrypted: Boolean get() = vaultId != null @@ -431,6 +437,8 @@ data class DDeadline( val node: DNode? = null, /** Drive 1.14 (`deadlineCalendar`): my entry in the family calendar; null when there is none. */ val calendar: DCalendarEntry? = null, + /** Drive 1.14 `ask`: the file of the deadline (the answer has no node). */ + val nodeId: Long? = null, ) /** A deadline's entry in the family calendar (Drive 1.14). */ diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveScanner.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveScanner.kt index 447bee63..1a3d4b25 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveScanner.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveScanner.kt @@ -151,9 +151,70 @@ class DriveScanner @Inject constructor( /** The scan as the pictures of its pages, each with its text (as before Drive 1.14), named after [base]. */ suspend fun savePictures(scan: Pages, texts: List, base: String, folderId: Long): Boolean = save(scan, texts, base, pdf = false, folderId = folderId) - /** A file made here (the searchable PDF) goes up into [folderId] as [name], with its [text]; [onDone] hears the new file. */ - fun uploadMade(file: File, name: String, mime: String, folderId: Long, text: String?, onDone: (DNode?) -> Unit) = - transfers.uploadWritten(file, name, mime, folderId, DriveLogic.ON_CONFLICT_RENAME, text, onDone = onDone, scan = true) + /** + * A file made here (the searchable PDF) goes up into [folderId] as [name], with its [text]; [propose]: it may get the + * filing suggestion (D4) — not the original kept next to a converted document. + */ + fun uploadMade(file: File, name: String, mime: String, folderId: Long, text: String?, propose: Boolean = true) = + transfers.uploadWritten(file, name, mime, folderId, DriveLogic.ON_CONFLICT_RENAME, text, scan = propose) + + /** [data] (a document Drive made, `scans/convert`) as media of Drive → its media id. */ + suspend fun uploadBytes(data: ByteArray, mime: String, api: DriveApi): DResult = withContext(Dispatchers.IO) { + val dir = File(context.cacheDir, "drive-scan").apply { mkdirs() } + val f = File.createTempFile("doc", ".part", dir) + try { + f.writeBytes(data) + api.upload(f, mime) + } finally { + f.delete() + } + } + + /** + * The pages [indices] as small JPEGs (base64) for `scans/convert` `images`: together below [budget] bytes (the + * server takes 600 KB), each at most 1600 px long, the quality lowered until they fit; empty when they do not. + */ + suspend fun smallJpegs(scan: Pages, indices: List, budget: Int = DriveJson.SCAN_IMAGES_MAX - 40 * 1024): List = withContext(Dispatchers.IO) { + val uris = indices.mapNotNull { scan.pages.getOrNull(it) } + if (uris.isEmpty()) return@withContext emptyList() + val each = budget / uris.size + val out = ArrayList() + for (uri in uris) { + val bytes = runCatching { smallJpeg(uri, each) }.getOrNull() ?: return@withContext emptyList() + out += java.util.Base64.getEncoder().encodeToString(bytes) + } + out + } + + private fun smallJpeg(uri: Uri, max: Int): ByteArray? { + val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { android.graphics.BitmapFactory.decodeStream(it, null, bounds) } + var side = 1600 + while (side >= 400) { + var sample = 1 + while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= side) sample *= 2 + val raw = context.contentResolver.openInputStream(uri)?.use { + android.graphics.BitmapFactory.decodeStream(it, null, android.graphics.BitmapFactory.Options().apply { inSampleSize = sample }) + } ?: return null + val long = maxOf(raw.width, raw.height) + val bmp = if (long > side) { + android.graphics.Bitmap.createScaledBitmap(raw, raw.width * side / long, raw.height * side / long, true).also { if (it !== raw) raw.recycle() } + } else { + raw + } + try { + for (q in listOf(80, 65, 50)) { + val o = java.io.ByteArrayOutputStream() + bmp.compress(android.graphics.Bitmap.CompressFormat.JPEG, q, o) + if (o.size() <= max) return o.toByteArray() + } + } finally { + bmp.recycle() + } + side = side * 3 / 4 + } + return null + } /** The scanner's file in the cache, handed over to the upload (which deletes it); null when it cannot be read. */ private fun copy(uri: Uri): File? { diff --git a/app/src/main/java/com/gatecontrol/android/drive/backup/DrivePhotoLabels.kt b/app/src/main/java/com/gatecontrol/android/drive/backup/DrivePhotoLabels.kt index 13596597..6a559045 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/backup/DrivePhotoLabels.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/backup/DrivePhotoLabels.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber -import java.io.File import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -41,8 +40,8 @@ import javax.inject.Singleton * image labeling ([DriveWorkLabeler], the same as for the work photos; no picture leaves the phone for it) — goes to * Drive as words (`PUT nodes//labels`, ML Kit's English labels; Drive knows them in German too). A photo gets them * when the backup sends it ([afterBackup]); photos already in Drive without them (sent before, or from the PC) get them - * later from their preview ([backfill], in the background under the backup's conditions, a few hundred a run). The - * photos this phone labeled are remembered, so none is labeled twice. + * later from their preview ([backfill], in the background under the backup's conditions, a few hundred a run) — Drive + * lists exactly those (`photos?unlabeled=1`, only where one writes), so no photo is labeled twice, by no phone. */ @Singleton class DrivePhotoLabels @Inject constructor( @@ -54,31 +53,16 @@ class DrivePhotoLabels @Inject constructor( ) { private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private val lock = Mutex() - private val file = File(context.filesDir, FILE) - @Volatile private var done: MutableSet? = null private fun owner() = PluginDirectory.ownerKey(setup.getServerUrl(), setup.getApiToken()) - /** The ids of the photos labeled for this login (read once). */ - private fun doneSet(): MutableSet { - done?.let { return it } - val set: MutableSet = java.util.concurrent.ConcurrentHashMap.newKeySet() - if (prefs.getString(KEY_OWNER, null) == owner()) { - runCatching { file.forEachLine { l -> l.trim().toLongOrNull()?.let(set::add) } } - } else { - file.delete() - prefs.edit { - clear() - putString(KEY_OWNER, owner()) - } + /** The prefs of this login (another login starts afresh). */ + private fun mine() { + if (prefs.getString(KEY_OWNER, null) == owner()) return + prefs.edit { + clear() + putString(KEY_OWNER, owner()) } - done = set - return set - } - - private fun remember(id: Long) { - val set = doneSet() - if (set.add(id)) runCatching { file.appendText("$id\n") } } private suspend fun enabled(): Boolean = live.supports(DMe.FEATURE_PHOTO_SEARCH) @@ -90,13 +74,11 @@ class DrivePhotoLabels @Inject constructor( send(node.id, found) } + /** False only when Drive could not be reached (try again later); a reader (403) or no picture (400) is skipped. */ private suspend fun send(id: Long, found: List>): Boolean = lock.withLock { - val labels = DriveKi.labels(found) // nothing sure enough: an empty list says "looked at" all the same - when (val r = api.putLabels(id, labels)) { - is DResult.Ok -> { remember(id); true } - // gone, not mine to change, a vault, no such feature: never again - is DResult.Failed -> { if (r.code in FINAL) remember(id); r.code in FINAL } + when (api.putLabels(id, DriveKi.labels(found))) { + is DResult.Ok, is DResult.Failed -> true else -> false } } @@ -104,18 +86,17 @@ class DrivePhotoLabels @Inject constructor( enum class Outcome { DONE, MORE, RETRY } /** - * Labels up to [budget] photos in Drive that this phone has not labeled yet, from their previews, going on where the - * last run stopped; a whole pass through the photos comes at most once a day. + * Labels up to [budget] photos Drive lists as without labels, from their previews, going on where the last run + * stopped; a whole pass comes at most once a day. */ suspend fun backfill(budget: Int = BUDGET): Outcome { if (!enabled()) return Outcome.DONE - doneSet() + mine() var cursor = prefs.getString(KEY_CURSOR, null) var labeled = 0 var pages = 0 while (labeled < budget && pages < MAX_PAGES) { - val r = api.photos(before = cursor, limit = PAGE) - val page = when (r) { + val page = when (val r = api.photos(before = cursor, limit = PAGE, unlabeled = true)) { is DResult.Ok -> r.value DResult.Offline -> return Outcome.RETRY else -> return Outcome.DONE @@ -123,19 +104,14 @@ class DrivePhotoLabels @Inject constructor( pages++ for (n in page.items) { if (labeled >= budget) break - if (n.id in doneSet() || n.encrypted || !n.preview || DriveLogic.kind(n) != DriveLogic.Kind.IMAGE) continue + if (n.labeled == true || n.encrypted || !n.preview || DriveLogic.kind(n) != DriveLogic.Kind.IMAGE) continue val found = try { labeler.namesOfNode(n) } catch (e: CancellationException) { throw e } catch (e: Exception) { null - } - if (found == null) { - // no preview to read: skipped for good - remember(n.id) - continue - } + } ?: continue if (!send(n.id, found)) return Outcome.RETRY labeled++ } @@ -151,15 +127,14 @@ class DrivePhotoLabels @Inject constructor( return Outcome.MORE } - /** Whether a new pass is due (the last whole one is more than a day ago, or a pass is half done). */ + /** Whether a run is due (a pass half done, or the last whole one more than a day ago). */ fun due(): Boolean { - doneSet() + mine() return prefs.getString(KEY_CURSOR, null) != null || System.currentTimeMillis() - prefs.getLong(KEY_PASS_AT, 0L) > PASS_GAP_MS } companion object { private const val PREFS = "drive_photo_labels" - private const val FILE = "drive_photo_labels.txt" private const val KEY_OWNER = "owner" private const val KEY_CURSOR = "cursor" private const val KEY_PASS_AT = "pass_at" @@ -167,7 +142,6 @@ class DrivePhotoLabels @Inject constructor( private const val MAX_PAGES = 30 const val BUDGET = 200 private const val PASS_GAP_MS = 24 * 3_600_000L - private val FINAL = setOf("not_found", "forbidden", "encrypted", "invalid", "feature_off") } } 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 f7b420d1..03648180 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 @@ -614,8 +614,9 @@ internal object DriveViewPref { private const val KEY_KEEP_ORIGINAL = "scan_keep_original" - /** "Original-Scan behalten" of "Scan prüfen" (Drive 1.14): on until switched off. */ - fun keepOriginal(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_KEEP_ORIGINAL, true) + /** "Original-Scan behalten" of "Scan prüfen" (Drive 1.14) as switched here; null: Drive's setting (`scanKeepOriginal`). */ + fun keepOriginal(context: Context): Boolean? = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .takeIf { it.contains(KEY_KEEP_ORIGINAL) }?.getBoolean(KEY_KEEP_ORIGINAL, true) fun setKeepOriginal(context: Context, on: Boolean) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_KEEP_ORIGINAL, on).apply() diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveKiUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveKiUi.kt index 5c5d5c12..4da19ab1 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveKiUi.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveKiUi.kt @@ -613,6 +613,17 @@ private fun SheetCell(text: String, modifier: Modifier, head: Boolean = false, b // ─── D1: the answer card ──────────────────────────────────────────── +/** "Meine Dateien und Familie" / "A, B and C". */ +@Composable +internal fun joinLabels(labels: List): String { + val and = stringResource(R.string.drive_ki_and) + return when (labels.size) { + 0 -> "" + 1 -> labels[0] + else -> labels.dropLast(1).joinToString(", ") + " $and " + labels.last() + } +} + /** [text] with its dates in bold. */ internal fun boldDates(text: String): AnnotatedString { val b = AnnotatedString.Builder(text) @@ -630,6 +641,7 @@ internal fun AnswerCard( a: DAnswer, areas: String, deadline: DDeadline?, + calendar: Boolean, calendarBusy: Boolean, onCalendar: (DDeadline) -> Unit, onSource: (DSource) -> Unit, @@ -639,6 +651,7 @@ internal fun AnswerCard( GcCard { Text(stringResource(R.string.drive_ki_ask_none), style = MaterialTheme.typography.titleMedium) val terms = a.terms.joinToString(", ") + val areas = joinLabels(a.areas).ifEmpty { areas } Text( if (terms.isEmpty()) stringResource(R.string.drive_ki_ask_where, areas) else stringResource(R.string.drive_ki_ask_searched, terms, areas), style = MaterialTheme.typography.bodySmall, @@ -651,9 +664,19 @@ internal fun AnswerCard( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text(boldDates(a.answer.orEmpty()), style = MaterialTheme.typography.bodyLarge) if (deadline != null) { - if (deadline.calendar != null) { + // the deadline the answer names: its kind, day and what it is + val today = remember { LocalDate.now() } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcChip(stringResource(deadlineLabel(deadline.kind)), tone = deadlineTone(deadline.kind), small = true) + Text( + listOfNotNull(kiDay(deadline.date, weekday = true) ?: deadline.date, deadlineWhen(DriveLogic.daysUntil(deadline.date, today)), deadline.label).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + ) + } + if (calendar && deadline.calendar != null) { KiButton(stringResource(R.string.drive_ki_calendar_done), GcIcons.Calendar, done = true, enabled = false) {} - } else { + } else if (calendar && DriveKi.calendarOpen(deadline, today)) { KiButton(stringResource(R.string.drive_ki_ask_calendar), GcIcons.Calendar, busy = calendarBusy) { onCalendar(deadline) } } } 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 17f85ead..ebb7c3cc 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 @@ -58,6 +58,7 @@ import kotlinx.coroutines.launch private fun AskContent( asked: DriveSearchViewModel.Asked?, areas: List, + calendar: Boolean, calendarBusy: Boolean, onCalendar: (com.gatecontrol.android.drive.DDeadline) -> Unit, onRetry: () -> Unit, @@ -70,8 +71,9 @@ private fun AskContent( is DriveLoad.Error -> DriveErrorBox(a.code, onRetry = onRetry) is DriveLoad.Ready -> AnswerCard( a = a.value, - areas = joinAreas(areas.filter { !it.archived }.map { it.label }), + areas = joinLabels(areas.filter { !it.archived }.map { it.label }), deadline = asked?.deadline, + calendar = calendar, calendarBusy = calendarBusy, onCalendar = onCalendar, onSource = onSource, @@ -83,17 +85,6 @@ private fun AskContent( } } -/** "Meine Dateien und Familie" / "A, B and C". */ -@Composable -private fun joinAreas(labels: List): String { - val and = stringResource(R.string.drive_ki_and) - return when (labels.size) { - 0 -> "" - 1 -> labels[0] - else -> labels.dropLast(1).joinToString(", ") + " $and " + labels.last() - } -} - /** The trash of every area the person writes to: restore or delete for good. */ @Composable fun DriveTrashScreen(onBack: () -> Unit, viewModel: DriveTrashViewModel = hiltViewModel()) { @@ -244,7 +235,7 @@ fun DriveSearchScreen(onBack: () -> Unit, onFolder: (Long) -> Unit, viewModel: D com.gatecontrol.android.ui.components.GcFilterChip(stringResource(R.string.drive_ki_mode_ask), selected = askMode, onClick = { askMode = true viewModel.search("") - if (q.trim().length >= 3) viewModel.ask(q) + if (q.trim().length >= com.gatecontrol.android.drive.DriveJson.ASK_MIN) viewModel.ask(q) }) } } @@ -254,6 +245,7 @@ fun DriveSearchScreen(onBack: () -> Unit, onFolder: (Long) -> Unit, viewModel: D AskContent( asked = asked, areas = areas, + calendar = me?.has(DMe.FEATURE_DEADLINE_CALENDAR) == true, calendarBusy = calendarBusy, onCalendar = viewModel::calendar, onRetry = { viewModel.ask(q) }, diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt index 8c996e48..adb1b1e9 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt @@ -213,6 +213,22 @@ class DriveSettingsViewModel @Inject constructor( } } } + + /** "Neue Fristen in den Familienkalender" of [area] (Drive 1.14, `deadlineCalendar`, the write right). */ + fun setDeadlinesToCalendar(area: DArea, on: Boolean) { + val before = area.deadlinesToCalendar + fun put(value: Boolean?) = _areas.update { list -> list.map { if (it.id == area.id) it.copy(deadlinesToCalendar = value) else it } } + put(on) + viewModelScope.launch { + when (val r = api.setAreaDeadlinesToCalendar(area.id, on)) { + is DResult.Ok -> put(r.value) + else -> { + put(before) + _message.value = r.errorCode() + } + } + } + } } /** @@ -395,6 +411,17 @@ fun DriveSettingsScreen( onCheckedChange = { on -> viewModel.setDocAi(a, on) }, icon = GcIcons.Receipt, ) + // Drive 1.14 (`deadlineCalendar`): new deadlines of the area into the family calendar on their own + if (me?.has(DMe.FEATURE_DEADLINE_CALENDAR) == true && a.docAi == true && a.deadlinesToCalendar != null) { + GcRowDivider() + GcSwitchRow( + label = stringResource(R.string.drive_ki_area_calendar), + description = stringResource(R.string.drive_ki_area_calendar_desc, a.label), + checked = a.deadlinesToCalendar == true, + onCheckedChange = { on -> viewModel.setDeadlinesToCalendar(a, on) }, + icon = GcIcons.Calendar, + ) + } } GcRowDivider() Text( diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt index 86a35644..96b18456 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt @@ -648,8 +648,9 @@ class DriveFolderViewModel @Inject constructor( val here = (_state.value as? DriveLoad.Ready)?.value ?: return if (pages.pages.isEmpty()) return val review = live.me.value?.has(com.gatecontrol.android.drive.DMe.FEATURE_SCAN_DOCS) == true - val ai = review && _area.value?.docAi != false - val keep = DriveViewPref.keepOriginal(context) + // Word and Excel: at most 8 pages go to Drive's AI + val ai = review && _area.value?.docAi != false && pages.pages.size <= com.gatecontrol.android.drive.DriveJson.SCAN_MEDIA_MAX + val keep = DriveViewPref.keepOriginal(context) ?: live.me.value?.scanKeepOriginal ?: true _scan.value = ScanDraft(pages, folderId = here.node.id, folderLabel = label(here, _area.value?.label), review = review, ai = ai, keepOriginal = keep) viewModelScope.launch { val format = api.me().valueOrNull()?.scanFormat @@ -755,7 +756,7 @@ class DriveFolderViewModel @Inject constructor( if (!ok) _message.value = "scan_read" } else { _scan.value = null - scanner.uploadMade(pdf, name, format.mime, d.folderId, text) { } + scanner.uploadMade(pdf, name, format.mime, d.folderId, text) } } else -> { @@ -766,23 +767,70 @@ class DriveFolderViewModel @Inject constructor( _message.value = "failed" return@launch } - when (val r = api.convertScan(ids, target, d.kindNow, d.folderId, name, d.keepOriginal)) { - is DResult.Ok -> { - _scan.value = null - _message.value = "done_scan_doc" - if (r.value.node.parentId == folderId) refresh(quiet = true) - filing.watch(r.value.node, scan = true) - } - else -> { - _scan.update { it?.copy(saving = false) } - _message.value = r.errorCode() - } + val code = convertScan(d, ids, target, name, base, text, reads) + if (code == null) { + _scan.value = null + _message.value = "done_scan_doc" + } else { + _scan.update { it?.copy(saving = false) } + _message.value = code } } } } } + /** + * Word / Excel (Drive 1.14 `scans/convert`): Drive reads the pages and sends the document's bytes back; they go up as + * media and are filed with `scans/save`. A whiteboard's photo and handwritten pages go along as small pictures (too + * large: once more without). "Original-Scan behalten": the scan goes next to it as a searchable PDF. Null when done. + */ + private suspend fun convertScan( + d: ScanDraft, + ids: List, + target: String, + name: String, + base: String, + text: String?, + reads: List?, + ): String? { + val kind = d.kindNow + val pictures = when (kind) { + com.gatecontrol.android.drive.DriveKi.KIND_WHITEBOARD -> listOf(0) + com.gatecontrol.android.drive.DriveKi.KIND_HANDWRITING -> d.scan.pages.indices.take(com.gatecontrol.android.drive.DriveJson.SCAN_MEDIA_MAX).toList() + else -> emptyList() + } + val images = if (pictures.isEmpty()) emptyList() else scanner.smallJpegs(d.scan, pictures) + var r = api.convertScan(ids, target, kind, d.folderId, name, text, images) + if (images.isNotEmpty() && r is DResult.Failed && (r.code == "too_large" || r.http == 413)) { + r = api.convertScan(ids, target, kind, d.folderId, name, text) + } + val c = when (r) { + is DResult.Ok -> r.value + else -> return r.errorCode() ?: "failed" + } + val file = c.file ?: return "invalid_answer" + val media = scanner.uploadBytes(file.data, file.mime, api) + val mediaId = media.valueOrNull() ?: return media.errorCode() ?: "failed" + val saved = when (val s = api.saveScan(mediaId, c.folderId ?: d.folderId, c.name ?: file.name, c.text ?: text)) { + is DResult.Ok -> s.value + else -> return s.errorCode() ?: "failed" + } + if (saved.node.parentId == folderId) refresh(quiet = true) + filing.watch(saved.node, scan = true) + // the scan's pages are fresh uploads here, not a file of Drive: kept, they go next to it as a PDF + if (d.keepOriginal) { + val pdf = scanner.searchablePdf(d.scan, reads.orEmpty()) + val stem = com.gatecontrol.android.drive.DriveKi.cleanName(saved.node.name.substringBeforeLast('.')) ?: base + if (pdf != null) { + scanner.uploadMade(pdf, com.gatecontrol.android.drive.DriveKi.fileName("$stem (Scan)", com.gatecontrol.android.drive.DriveKi.Format.PDF), "application/pdf", saved.node.parentId ?: d.folderId, text, propose = false) + } else { + scanner.savePictures(d.scan, reads?.map { it?.text }.orEmpty(), "$stem (Scan)", saved.node.parentId ?: d.folderId) + } + } + return null + } + // ─── filing right after the upload (Drive 1.14, D4) ───────────── /** The bar "Vorschlag · Ablegen als … in …" of a file just uploaded here. */ @@ -1045,7 +1093,7 @@ class DriveSearchViewModel @Inject constructor( /** Asks Drive [q] (only from my files, never a vault). */ fun ask(q: String) { val t = q.trim() - if (t.length < 3) return + if (t.length < com.gatecontrol.android.drive.DriveJson.ASK_MIN) return if ((_asked.value?.answer is DriveLoad.Loading) && _asked.value?.q == t) return askJob?.cancel() job?.cancel() @@ -1053,13 +1101,8 @@ class DriveSearchViewModel @Inject constructor( askJob = viewModelScope.launch { _asked.value = Asked(t, DriveLoad.Loading) val r = api.ask(t.take(com.gatecontrol.android.drive.DriveJson.ASK_MAX)) - _asked.value = Asked(t, r.toLoad { it }) - val a = r.valueOrNull()?.takeIf { it.answered } ?: return@launch - if (live.current()?.has(com.gatecontrol.android.drive.DMe.FEATURE_DEADLINE_CALENDAR) != true) return@launch - // "Frist in den Familienkalender": only when a deadline of a source is the one the answer names - val deadlines = a.sources.map { it.nodeId }.distinct().take(3).flatMap { id -> api.insight(id).valueOrNull()?.deadlines.orEmpty() } - val d = com.gatecontrol.android.drive.DriveKi.matchDeadline(a.answer.orEmpty(), deadlines, java.time.LocalDate.now()) ?: return@launch - _asked.update { cur -> if (cur?.q == t) cur.copy(deadline = d) else cur } + // the deadline of a source the answer names comes with it + _asked.value = Asked(t, r.toLoad { it }, r.valueOrNull()?.takeIf { it.answered }?.deadline) } } @@ -1076,7 +1119,7 @@ class DriveSearchViewModel @Inject constructor( val r = api.deadlineToCalendar(d.id) _calendarBusy.value = false if (r is DResult.Ok) { - _asked.update { it?.copy(deadline = r.value) } + _asked.update { it?.copy(deadline = r.value.copy(nodeId = d.nodeId, node = r.value.node ?: d.node)) } _message.value = "done_calendar" } else { _message.value = r.errorCode() diff --git a/app/src/main/res/values-de/strings_drive9.xml b/app/src/main/res/values-de/strings_drive9.xml index 9240aa85..1f80bffb 100644 --- a/app/src/main/res/values-de/strings_drive9.xml +++ b/app/src/main/res/values-de/strings_drive9.xml @@ -3,7 +3,7 @@ Als Dokument gespeichert. Im Familienkalender eingetragen. - Der Familienkalender ist nicht erreichbar (Familie ist nicht eingerichtet oder hat abgelehnt). + Der Familienkalender nimmt gerade keine Einträge von Drive an (Familie ist nicht erreichbar oder lässt Drive noch nicht zu). Bitte später noch einmal versuchen. Zu viele Fragen in kurzer Zeit. Bitte später noch einmal. Die KI deines GateControl ist gerade nicht verfügbar. @@ -11,6 +11,8 @@ Garantieende Vertragsverlängerung kündigen bis %1$s + Neue Fristen in den Familienkalender + In „%1$s“ erkannte Fristen kommen von selbst in den Familienkalender neu In den Familienkalender Im Familienkalender diff --git a/app/src/main/res/values/strings_drive9.xml b/app/src/main/res/values/strings_drive9.xml index 496eb4e4..154a2708 100644 --- a/app/src/main/res/values/strings_drive9.xml +++ b/app/src/main/res/values/strings_drive9.xml @@ -3,7 +3,7 @@ Saved as a document. Added to the family calendar. - The family calendar cannot be reached (Familie is not set up or refused). + The family calendar cannot take entries from Drive right now (Familie is not reachable or does not allow Drive yet). Please try again later. Too many questions in a short time. Please try again later. The AI of your GateControl is not available right now. @@ -11,6 +11,8 @@ End of warranty Contract renewal cancel by %1$s + New deadlines to the family calendar + Deadlines recognised in “%1$s” go into the family calendar on their own new To the family calendar In the family calendar diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveJsonKiTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveJsonKiTest.kt index 883cc568..afa24443 100644 --- a/app/src/test/java/com/gatecontrol/android/drive/DriveJsonKiTest.kt +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveJsonKiTest.kt @@ -52,9 +52,17 @@ class DriveJsonKiTest { assertEquals(listOf(12L), a.sources.map { it.nodeId }) assertEquals(DSource(12, "Versicherungsschein Kfz 2026.pdf", "Versicherungen", 1, "Vertragslaufzeit"), a.sources[0]) - val none = DriveJson.answer(json("""{"status":"not_found","answer":null,"sources":[],"terms":["Kfz"]}"""))!! + val none = DriveJson.answer(json("""{"status":"not_found","answer":null,"sources":[],"terms":["Kfz"],"areas":["Meine Dateien","Familie"],"deadline":null}"""))!! assertFalse(none.answered) assertEquals(listOf("Kfz"), none.terms) + assertEquals(listOf("Meine Dateien", "Familie"), none.areas) + assertNull(none.deadline) + val withDeadline = DriveJson.answer( + json("""{"status":"answered","answer":"Bis 30.11.2026.","sources":[{"nodeId":12,"name":"a.pdf","page":null}],"deadline":{"id":9,"kind":"cancellation","date":"2026-11-30","label":"Kfz kündigen","calendar":null,"nodeId":12}}"""), + )!! + assertEquals(9L, withDeadline.deadline?.id) + assertEquals(12L, withDeadline.deadline?.nodeId) + assertNull(withDeadline.sources[0].page) assertNull(DriveJson.answer(json("""{"status":"maybe"}"""))) // answered without words is no answer assertFalse(DriveJson.answer(json("""{"status":"answered","answer":" "}"""))!!.answered) @@ -63,7 +71,7 @@ class DriveJsonKiTest { assertEquals("Wann?", body.get("q").asString) assertEquals("family:3", body.get("area").asString) assertFalse(DriveJson.askBody("x".repeat(900), null).has("area")) - assertEquals(DriveJson.ASK_MAX, DriveJson.askBody("x".repeat(900), null).get("q").asString.length) + assertEquals(300, DriveJson.askBody("x".repeat(900), null).get("q").asString.length) } @Test @@ -78,20 +86,54 @@ class DriveJsonKiTest { assertEquals("Text", c.get("text").asString) assertFalse(DriveJson.classifyBody(listOf("abcdef12"), " ").has("text")) - val b = DriveJson.convertBody(listOf("abcdef12", "ghijkl34"), "xlsx", "table", 40, "2026 Zählerstände.xlsx", keepOriginal = true) + val b = DriveJson.convertBody(listOf("abcdef12", "ghijkl34"), "xlsx", "table", 40, "2026 Zählerstände.xlsx", "Datum Zähler", listOf("AAAA")) assertEquals(2, b.getAsJsonArray("mediaIds").size()) assertEquals("xlsx", b.get("target").asString) assertEquals("table", b.get("kind").asString) assertEquals(40L, b.get("folderId").asLong) assertEquals("2026 Zählerstände.xlsx", b.get("name").asString) - assertTrue(b.get("keepOriginal").asBoolean) - assertFalse(DriveJson.convertBody(listOf("abcdef12"), "docx", "printed", null, null, false).has("folderId")) - - val r = DriveJson.converted(json("""{"node":{"id":5,"areaId":"mine:7","name":"a.xlsx"},"original":{"id":6,"areaId":"mine:7","name":"a.jpg"}}"""))!! - assertEquals(5L, r.node.id) - assertEquals(6L, r.original?.id) - assertNull(DriveJson.converted(json("""{"node":{"id":5,"areaId":"mine:7","name":"a.xlsx"}}"""))!!.original) - assertNull(DriveJson.converted(json("""{}"""))) + assertEquals("Datum Zähler", b.get("text").asString) + assertEquals("AAAA", b.getAsJsonArray("images")[0].asString) + assertFalse(b.has("keepOriginal")) + val plain = DriveJson.convertBody(listOf("abcdef12"), "docx", "printed", null, null, null, emptyList()) + assertFalse(plain.has("folderId") || plain.has("text") || plain.has("images")) + + val data = java.util.Base64.getEncoder().encodeToString("PK-word".toByteArray()) + val conv = DriveJson.conversion( + json("""{"kind":"table","target":"xlsx","title":"Zählerstände","name":"2026-11-03 Zählerstände.xlsx","folderId":40,"folderPath":"Haus","text":"Datum Stand","content":{},"file":{"name":"2026-11-03 Zählerstände.xlsx","mime":"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet","size":7,"sha256":"x","data":"$data"},"source":"media","usage":{"costCents":0.1}}"""), + )!! + assertEquals("2026-11-03 Zählerstände.xlsx", conv.name) + assertEquals(40L, conv.folderId) + assertEquals("PK-word", String(conv.file!!.data)) + assertEquals("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", conv.file!!.mime) + // a PDF without pictures: no file, the app makes it + assertNull(DriveJson.conversion(json("""{"kind":"printed","target":"pdf","text":"Hallo","file":null}"""))!!.file) + // a file that cannot be read is no answer + assertNull(DriveJson.conversion(json("""{"kind":"printed","target":"docx","file":{"name":"a.docx","data":"%%%"}}"""))) + assertNull(DriveJson.conversion(json("""{"kind":"printed"}"""))) + + val save = DriveJson.saveScanBody("abcdef12", 40, "a.xlsx", "Text") + assertEquals("abcdef12", save.get("mediaId").asString) + assertEquals(40L, save.get("folderId").asLong) + assertEquals("a.xlsx", save.get("name").asString) + assertEquals("Text", save.get("text").asString) + val saved = DriveJson.saved(json("""{"node":{"id":5,"areaId":"mine:7","name":"a.xlsx"},"original":null}"""))!! + assertEquals(5L, saved.node.id) + assertNull(saved.original) + assertNull(DriveJson.saved(json("""{}"""))) + } + + @Test + fun `area and me - deadlines into the calendar, keep the original scan`() { + val me = DriveJson.me( + json("""{"me":{"id":7},"licence":{"valid":true},"settings":{"scanKeepOriginal":false},"areas":[{"id":"mine:7","rootId":1,"role":"manage","docAi":true,"deadlinesToCalendar":true}]}"""), + )!! + assertEquals(false, me.scanKeepOriginal) + assertEquals(true, me.areas[0].deadlinesToCalendar) + assertNull(DriveJson.me(json("""{"me":{"id":7}}"""))!!.scanKeepOriginal) + assertNull(DriveJson.area(json("""{"id":"mine:7","rootId":1}"""))!!.deadlinesToCalendar) + assertTrue(DriveJson.deadlinesToCalendarBody(true).get("deadlinesToCalendar").asBoolean) + assertEquals(false, DriveJson.areaDeadlinesToCalendar(json("""{"area":{"id":"mine:7","docAi":true,"deadlinesToCalendar":false}}"""))) } @Test @@ -105,11 +147,15 @@ class DriveJsonKiTest { assertEquals("Strand Meer", DriveJson.photosQuery(q = " Strand Meer ")!!["q"]) assertFalse(DriveJson.photosQuery(q = " ")!!.containsKey("q")) + assertEquals("1", DriveJson.photosQuery(unlabeled = true)!!["unlabeled"]) + assertFalse(DriveJson.photosQuery()!!.containsKey("unlabeled")) + assertEquals(true, DriveJson.node(json("""{"id":1,"areaId":"mine:7","name":"a.jpg","labeled":true}"""))!!.labeled) + assertNull(DriveJson.node(json("""{"id":1,"areaId":"mine:7","name":"a.jpg"}"""))!!.labeled) assertEquals(DriveJson.PHOTO_Q_MAX, DriveJson.photosQuery(q = "x".repeat(300))!!["q"]!!.length) - val p = DriveJson.photos(json("""{"items":[{"id":1,"areaId":"mine:7","name":"a.jpg"}],"next":null,"total":38,"terms":[{"label":"Meer","count":20},"Sonnenuntergang"]}"""))!! + val p = DriveJson.photos(json("""{"items":[{"id":1,"areaId":"mine:7","name":"a.jpg"}],"next":null,"total":38,"terms":[{"term":"meer","label":"sea","count":20},"Sonnenuntergang"]}"""))!! assertEquals(38, p.total) - assertEquals(listOf("Meer", "Sonnenuntergang"), p.terms) + assertEquals(listOf("meer", "Sonnenuntergang"), p.terms) val plain = DriveJson.photos(json("""{"items":[]}"""))!! assertNull(plain.total) assertTrue(plain.terms.isEmpty()) diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveKiTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveKiTest.kt index 0159feb3..c1533aed 100644 --- a/app/src/test/java/com/gatecontrol/android/drive/DriveKiTest.kt +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveKiTest.kt @@ -134,23 +134,12 @@ class DriveKiTest { } @Test - fun `dates in an answer and the deadline it names`() { + fun `dates in an answer are found for bold`() { val a = "Die Kfz-Versicherung läuft bis 31.12.2026 und verlängert sich jeweils um ein Jahr. Kündigen kannst du bis 30.11.2026." - assertEquals(setOf(LocalDate.of(2026, 12, 31), LocalDate.of(2026, 11, 30)), DriveKi.datesIn(a, today)) assertEquals(2, DriveKi.dateSpans(a).size) assertEquals("31.12.2026", a.substring(DriveKi.dateSpans(a)[0])) - // without a year: this year, or the next when over - assertEquals(setOf(LocalDate.of(2026, 11, 28), LocalDate.of(2027, 1, 5)), DriveKi.datesIn("am 28.11. und am 5.1.", today)) - assertEquals(setOf(LocalDate.of(2026, 12, 1)), DriveKi.datesIn("bis 2026-12-01", today)) - assertTrue(DriveKi.datesIn("Version 1.2 von 3 Seiten, 41.208 kWh", today).none { it.year == 2026 && it.monthValue == 2 && it.dayOfMonth == 41 }) - - val end = deadline(1, "payment", "2026-12-31") - val cancel = deadline(2, "cancellation", "2026-11-30") - val other = deadline(3, "cancellation", "2027-03-01") - assertEquals(2L, DriveKi.matchDeadline(a, listOf(end, cancel, other), today)?.id) - assertEquals(1L, DriveKi.matchDeadline(a, listOf(end, other), today)?.id) - assertNull(DriveKi.matchDeadline(a, listOf(other), today)) - assertNull(DriveKi.matchDeadline("Keine Daten", listOf(end), today)) + assertEquals(listOf("28.11.", "5.1.", "2026-12-01"), "am 28.11. und am 5.1. bis 2026-12-01".let { t -> DriveKi.dateSpans(t).map { t.substring(it) } }) + assertTrue(DriveKi.dateSpans("Version 1.2 von 3 Seiten, 41.208 kWh").isEmpty()) } // ─── D3 ─────────────────────────────────────────────────────────