Paperless: poll consume task for real upload confirmation; v0.1.2
All checks were successful
Build APK / build (push) Successful in 5m56s

After POST /api/documents/post_document/ the client now polls /api/tasks/
until the document is consumed, so the app shows 'Toegevoegd ✓', 'Duplicaat',
or a failure message instead of just 'queued'. A 'Niet wachten' button lets
the user stop waiting (the POST itself is NonCancellable, so the document is
always really sent). Bump versionCode 3 / versionName 0.1.2.

Fixes #3

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 15:16:03 +02:00
parent 752d4972bb
commit 953efaf168
4 changed files with 101 additions and 11 deletions

View File

@@ -1,12 +1,16 @@
package eu.geyskens.pdfscan.paperless
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.TimeUnit
@@ -28,7 +32,12 @@ class PaperlessClient(
private val root = baseUrl.trim().trimEnd('/')
sealed interface Result {
/** Document was consumed and created in Paperless. */
data object Success : Result
/** Paperless rejected it as a duplicate of an existing document. */
data object Duplicate : Result
/** Upload was accepted but still processing when we stopped waiting. */
data object Pending : Result
data class Failure(val message: String) : Result
}
@@ -51,7 +60,11 @@ class PaperlessClient(
}.getOrElse { Result.Failure(it.message ?: "Verbinding mislukt.") }
}
/** Uploads a PDF to the Paperless consume/inbox pipeline. */
/**
* Uploads a PDF and then waits for Paperless to actually consume it. The upload
* endpoint only queues the document and returns a task UUID; we poll the task so the
* user learns whether it was added, was a duplicate, or failed.
*/
suspend fun upload(pdf: File, title: String): Result = withContext(Dispatchers.IO) {
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
@@ -66,11 +79,70 @@ class PaperlessClient(
.header("Authorization", "Token $token")
.post(body)
.build()
runCatching {
client.newCall(request).execute().use { resp ->
if (resp.isSuccessful) Result.Success
else Result.Failure("Upload mislukt: ${resp.code} ${resp.message}")
// The POST runs NonCancellable so that "stop waiting" only skips the status poll —
// the document is always really queued. post_document returns the task UUID string.
val posted: kotlin.Result<String?> = withContext(NonCancellable) {
runCatching {
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) error("Upload mislukt: ${resp.code} ${resp.message}")
resp.body?.string()?.trim()?.trim('"')?.takeIf { it.isNotBlank() }
}
}
}.getOrElse { Result.Failure(it.message ?: "Upload mislukt.") }
}
val taskId = posted.getOrElse { return@withContext Result.Failure(it.message ?: "Upload mislukt.") }
// Older servers may not return a usable id; then we can only confirm acceptance.
if (taskId == null || taskId.equals("OK", ignoreCase = true)) return@withContext Result.Pending
pollTask(taskId)
}
/** Polls /api/tasks/ until the consume task finishes, or gives up after ~45s. */
private suspend fun pollTask(taskId: String): Result {
val deadline = System.currentTimeMillis() + 45_000
while (System.currentTimeMillis() < deadline) {
val task = runCatching {
val req = Request.Builder()
.url("$root/api/tasks/?task_id=$taskId")
.header("Authorization", "Token $token")
.header("Accept", "application/json")
.get()
.build()
client.newCall(req).execute().use { resp ->
if (resp.isSuccessful) firstTask(resp.body?.string()) else null
}
}.getOrNull()
when (task?.status?.uppercase()) {
"SUCCESS" -> return Result.Success
"FAILURE" -> {
val msg = task.result.orEmpty()
return if (msg.contains("duplicate", ignoreCase = true)) Result.Duplicate
else Result.Failure(msg.ifBlank { "Paperless kon het document niet verwerken." })
}
// PENDING / STARTED / RETRY / unknown → keep waiting.
}
delay(1500)
}
return Result.Pending
}
private data class Task(val status: String?, val result: String?)
/** Parses the first task object from the /api/tasks/ response (a plain array). */
private fun firstTask(json: String?): Task? {
if (json.isNullOrBlank()) return null
return runCatching {
val trimmed = json.trim()
val arr = if (trimmed.startsWith("[")) JSONArray(trimmed)
else JSONObject(trimmed).optJSONArray("results")
if (arr == null || arr.length() == 0) return null
val o = arr.getJSONObject(0)
Task(
status = o.optString("status").takeIf { it.isNotBlank() && it != "null" },
result = o.optString("result").takeIf { it.isNotBlank() && it != "null" },
)
}.getOrNull()
}
}