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..e00dd8a1 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, 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) @@ -383,12 +387,62 @@ 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() } + 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.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] (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?, 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`). */ + 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..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")) @@ -426,7 +429,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 +458,8 @@ internal object DriveJson { camera: Boolean = false, date: String? = null, cls: String? = null, + q: String? = null, + unlabeled: Boolean = false, ): Map? = buildMap { if (area != null) put("area", areaId(area) ?: return null) @@ -463,6 +468,8 @@ 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) } + if (unlabeled) put("unlabeled", "1") } /** The values of `class` (Drive 1.13). */ @@ -671,7 +678,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 +719,12 @@ 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)) }, + nodeId = o.long("nodeId")?.takeIf { it > 0 }, ) } - /** `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 +1135,145 @@ 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 (3 … 300 characters). */ + const val ASK_MIN = 3 + const val ASK_MAX = 300 + + /** `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), + 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 { + 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 }, + ) + } + + /** 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)) + if (!text.isNullOrBlank()) addProperty("text", text.take(TEXT_MAX)) + if (images.isNotEmpty()) add("images", JsonArray().apply { images.forEach { add(it) } }) + } + + /** `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 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( + "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..39de46f2 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveKi.kt @@ -0,0 +1,204 @@ +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() + + // ─── 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..ff899803 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveKiModels.kt @@ -0,0 +1,55 @@ +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, + /** 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() + + 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 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 837fca30..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 @@ -102,6 +104,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" } } @@ -152,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") } @@ -200,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 @@ -287,7 +301,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 +427,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 +435,15 @@ 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, + /** 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). */ +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..1a3d4b25 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,130 @@ 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]; [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? { 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..6a559045 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/backup/DrivePhotoLabels.kt @@ -0,0 +1,203 @@ +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.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) — 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( + @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 fun owner() = PluginDirectory.ownerKey(setup.getServerUrl(), setup.getApiToken()) + + /** 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()) + } + } + + 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) + } + + /** 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 { + // nothing sure enough: an empty list says "looked at" all the same + when (api.putLabels(id, DriveKi.labels(found))) { + is DResult.Ok, is DResult.Failed -> true + else -> false + } + } + + enum class Outcome { DONE, MORE, RETRY } + + /** + * 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 + mine() + var cursor = prefs.getString(KEY_CURSOR, null) + var labeled = 0 + var pages = 0 + while (labeled < budget && pages < MAX_PAGES) { + 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 + } + pages++ + for (n in page.items) { + if (labeled >= budget) break + 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 + } ?: 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 run is due (a pass half done, or the last whole one more than a day ago). */ + fun due(): Boolean { + 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 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 + } +} + +@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..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 @@ -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,26 @@ 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) 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() + } + + 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..4da19ab1 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveKiUi.kt @@ -0,0 +1,725 @@ +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 ──────────────────────────────────────────── + +/** "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) + 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?, + calendar: Boolean, + 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(", ") + 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, + 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) { + // 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 if (calendar && DriveKi.calendarOpen(deadline, today)) { + 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..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 @@ -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,38 @@ 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, + calendar: Boolean, + 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 = joinLabels(areas.filter { !it.archived }.map { it.label }), + deadline = asked?.deadline, + calendar = calendar, + 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) + } +} + /** The trash of every area the person writes to: restore or delete for good. */ @Composable fun DriveTrashScreen(onBack: () -> Unit, viewModel: DriveTrashViewModel = hiltViewModel()) { @@ -125,6 +159,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 +207,58 @@ 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 >= com.gatecontrol.android.drive.DriveJson.ASK_MIN) viewModel.ask(q) + }) + } + } } BulkBusyCard(busy, viewModel.bulk::cancelBusy) - when (val s = state) { + if (asking) { + AskContent( + asked = asked, + areas = areas, + calendar = me?.has(DMe.FEATURE_DEADLINE_CALENDAR) == true, + 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/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 0195dd2c..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 @@ -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,95 @@ 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 + // 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 - _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 } + } + 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 job = viewModelScope.async { scanner.recognize(pages.pages) } - recognizing = job + 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 +728,127 @@ 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 + } + 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. */ + 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 +1077,56 @@ 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 < com.gatecontrol.android.drive.DriveJson.ASK_MIN) 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)) + // the deadline of a source the answer names comes with it + _asked.value = Asked(t, r.toLoad { it }, r.valueOrNull()?.takeIf { it.answered }?.deadline) + } + } + + /** 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.copy(nodeId = d.nodeId, node = r.value.node ?: d.node)) } + _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..1f80bffb --- /dev/null +++ b/app/src/main/res/values-de/strings_drive9.xml @@ -0,0 +1,98 @@ + + + + Als Dokument gespeichert. + Im Familienkalender eingetragen. + 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. + + Aus deinen Dokumenten erkannt · nur Dateien, die du lesen darfst + 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 + 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..154a2708 --- /dev/null +++ b/app/src/main/res/values/strings_drive9.xml @@ -0,0 +1,98 @@ + + + + Saved as a document. + Added to the family calendar. + 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. + + Recognised in your documents · only files you may read + 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 + 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..afa24443 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveJsonKiTest.kt @@ -0,0 +1,164 @@ +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"],"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) + + 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(300, 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", "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) + 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 + 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("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":[{"term":"meer","label":"sea","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..c1533aed --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveKiTest.kt @@ -0,0 +1,170 @@ +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 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(2, DriveKi.dateSpans(a).size) + assertEquals("31.12.2026", a.substring(DriveKi.dateSpans(a)[0])) + 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 ───────────────────────────────────────────────────────── + + @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) }) + } + } +}