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

@@ -24,8 +24,8 @@ android {
applicationId = "eu.geyskens.pdfscan" applicationId = "eu.geyskens.pdfscan"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 2 versionCode = 3
versionName = "0.1.1" versionName = "0.1.2"
// OpenCV native libs are large; ship only the ABIs real phones use. // OpenCV native libs are large; ship only the ABIs real phones use.
ndk { ndk {

View File

@@ -1,12 +1,16 @@
package eu.geyskens.pdfscan.paperless package eu.geyskens.pdfscan.paperless
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -28,7 +32,12 @@ class PaperlessClient(
private val root = baseUrl.trim().trimEnd('/') private val root = baseUrl.trim().trimEnd('/')
sealed interface Result { sealed interface Result {
/** Document was consumed and created in Paperless. */
data object Success : Result 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 data class Failure(val message: String) : Result
} }
@@ -51,7 +60,11 @@ class PaperlessClient(
}.getOrElse { Result.Failure(it.message ?: "Verbinding mislukt.") } }.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) { suspend fun upload(pdf: File, title: String): Result = withContext(Dispatchers.IO) {
val body = MultipartBody.Builder() val body = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
@@ -66,11 +79,70 @@ class PaperlessClient(
.header("Authorization", "Token $token") .header("Authorization", "Token $token")
.post(body) .post(body)
.build() .build()
// 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 { runCatching {
client.newCall(request).execute().use { resp -> client.newCall(request).execute().use { resp ->
if (resp.isSuccessful) Result.Success if (!resp.isSuccessful) error("Upload mislukt: ${resp.code} ${resp.message}")
else Result.Failure("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()
} }
} }

View File

@@ -42,6 +42,8 @@ import eu.geyskens.pdfscan.Routes
import eu.geyskens.pdfscan.ScanViewModel import eu.geyskens.pdfscan.ScanViewModel
import eu.geyskens.pdfscan.paperless.PaperlessClient import eu.geyskens.pdfscan.paperless.PaperlessClient
import eu.geyskens.pdfscan.pdf.PageSizeMode import eu.geyskens.pdfscan.pdf.PageSizeMode
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -61,6 +63,8 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
var fileName by remember { mutableStateOf(defaultName) } var fileName by remember { mutableStateOf(defaultName) }
var a4 by remember { mutableStateOf(true) } var a4 by remember { mutableStateOf(true) }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var uploading by remember { mutableStateOf(false) }
var uploadJob by remember { mutableStateOf<Job?>(null) }
var status by remember { mutableStateOf<String?>(null) } var status by remember { mutableStateOf<String?>(null) }
fun mode() = if (a4) PageSizeMode.A4 else PageSizeMode.ORIGINAL fun mode() = if (a4) PageSizeMode.A4 else PageSizeMode.ORIGINAL
@@ -129,20 +133,29 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
onClick = { onClick = {
if (busy) return@Button if (busy) return@Button
busy = true busy = true
uploading = true
status = null status = null
scope.launch { uploadJob = scope.launch {
try { try {
val pdf = vm.exportPdf(fileName, mode()) val pdf = vm.exportPdf(fileName, mode())
when (val r = vm.uploadToPaperless(pdf, fileName)) { when (val r = vm.uploadToPaperless(pdf, fileName)) {
is PaperlessClient.Result.Success -> is PaperlessClient.Result.Success ->
status = "Geüpload naar Paperless ✓" status = "Toegevoegd aan Paperless ✓"
is PaperlessClient.Result.Duplicate ->
status = "Al aanwezig in Paperless (duplicaat) — niet opnieuw toegevoegd."
is PaperlessClient.Result.Pending ->
status = "Geüpload — Paperless is het nog aan het verwerken…"
is PaperlessClient.Result.Failure -> is PaperlessClient.Result.Failure ->
status = r.message status = r.message
} }
} catch (e: CancellationException) {
// User tapped "Niet wachten": the document was already sent.
status = "Geüpload — Paperless verwerkt het verder op de achtergrond."
} catch (e: Exception) { } catch (e: Exception) {
status = "Fout: ${e.message}" status = "Fout: ${e.message}"
} finally { } finally {
busy = false busy = false
uploading = false
} }
} }
}, },
@@ -170,7 +183,11 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.height(24.dp)) CircularProgressIndicator(Modifier.height(24.dp))
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Text("Bezig…") Text(if (uploading) "Uploaden…" else "Bezig…")
if (uploading) {
Spacer(Modifier.width(12.dp))
OutlinedButton(onClick = { uploadJob?.cancel() }) { Text("Niet wachten") }
}
} }
} }
status?.let { Text(it, color = Color(0xFF1F6FEB)) } status?.let { Text(it, color = Color(0xFF1F6FEB)) }

View File

@@ -111,6 +111,7 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
testResult = when (val r = vm.testPaperless(url, token)) { testResult = when (val r = vm.testPaperless(url, token)) {
is PaperlessClient.Result.Success -> "Verbinding OK ✓" is PaperlessClient.Result.Success -> "Verbinding OK ✓"
is PaperlessClient.Result.Failure -> r.message is PaperlessClient.Result.Failure -> r.message
else -> "Onbekend antwoord van de server."
} }
testing = false testing = false
} }