Paperless: poll consume task for real upload confirmation; v0.1.2
All checks were successful
Build APK / build (push) Successful in 5m56s
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ import eu.geyskens.pdfscan.Routes
|
||||
import eu.geyskens.pdfscan.ScanViewModel
|
||||
import eu.geyskens.pdfscan.paperless.PaperlessClient
|
||||
import eu.geyskens.pdfscan.pdf.PageSizeMode
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -61,6 +63,8 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
|
||||
var fileName by remember { mutableStateOf(defaultName) }
|
||||
var a4 by remember { mutableStateOf(true) }
|
||||
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) }
|
||||
|
||||
fun mode() = if (a4) PageSizeMode.A4 else PageSizeMode.ORIGINAL
|
||||
@@ -129,20 +133,29 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
|
||||
onClick = {
|
||||
if (busy) return@Button
|
||||
busy = true
|
||||
uploading = true
|
||||
status = null
|
||||
scope.launch {
|
||||
uploadJob = scope.launch {
|
||||
try {
|
||||
val pdf = vm.exportPdf(fileName, mode())
|
||||
when (val r = vm.uploadToPaperless(pdf, fileName)) {
|
||||
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 ->
|
||||
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) {
|
||||
status = "Fout: ${e.message}"
|
||||
} finally {
|
||||
busy = false
|
||||
uploading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -170,7 +183,11 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.height(24.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)) }
|
||||
|
||||
@@ -111,6 +111,7 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
|
||||
testResult = when (val r = vm.testPaperless(url, token)) {
|
||||
is PaperlessClient.Result.Success -> "Verbinding OK ✓"
|
||||
is PaperlessClient.Result.Failure -> r.message
|
||||
else -> "Onbekend antwoord van de server."
|
||||
}
|
||||
testing = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user