diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1f2c1b3..4d9db4a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ android { applicationId = "eu.geyskens.pdfscan" minSdk = 26 targetSdk = 35 - versionCode = 2 - versionName = "0.1.1" + versionCode = 3 + versionName = "0.1.2" // OpenCV native libs are large; ship only the ABIs real phones use. ndk { diff --git a/app/src/main/java/eu/geyskens/pdfscan/paperless/PaperlessClient.kt b/app/src/main/java/eu/geyskens/pdfscan/paperless/PaperlessClient.kt index acd2e52..5b25f8f 100644 --- a/app/src/main/java/eu/geyskens/pdfscan/paperless/PaperlessClient.kt +++ b/app/src/main/java/eu/geyskens/pdfscan/paperless/PaperlessClient.kt @@ -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 = 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() } } diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt index dcddfb5..5bf907c 100644 --- a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt @@ -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(null) } var status by remember { mutableStateOf(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)) } diff --git a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/SettingsScreen.kt b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/SettingsScreen.kt index df7b55b..74b7c57 100644 --- a/app/src/main/java/eu/geyskens/pdfscan/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/eu/geyskens/pdfscan/ui/screens/SettingsScreen.kt @@ -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 }