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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<DPhotos> =
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<DPhotos> =
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<List<DMonth>> =
get("photos/months", DriveJson.photosQuery(area, camera = camera, cls = cls) ?: return bad(), DriveJson::months)
Expand Down Expand Up @@ -383,12 +387,62 @@ class DriveApi @Inject constructor(

suspend fun unremind(id: Long): DResult<DDeadline> = 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<DDeadline> = post("deadlines/$id/calendar", JsonObject(), DriveJson::deadlineAnswer)

/** AI for documents in area [id] on or off (`PATCH areas/<id> { docAi }`, the write right) → the setting now. */
suspend fun setAreaDocAi(id: String, on: Boolean): DResult<Boolean> {
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<DAnswer> {
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<String>, text: String?): DResult<DScanKind> {
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<String>, target: String, kind: String, folderId: Long?, name: String?, text: String?, images: List<String> = emptyList()): DResult<DConversion> {
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<DSaved> {
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<Boolean> {
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<String>): DResult<Unit> = 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). */
Expand Down
130 changes: 130 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DriveFiling.kt
Original file line number Diff line number Diff line change
@@ -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<Proposal?>(null)
val proposal: StateFlow<Proposal?> = _proposal.asStateFlow()

private val watching = LinkedHashMap<Long, Job>()

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<Unit>(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<DNode?, String?> {
_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")
}
}
Loading
Loading