2 Commits

Author SHA1 Message Date
sam
953efaf168 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>
2026-07-13 15:16:03 +02:00
sam
752d4972bb Capture feedback (shutter sound + flash) and Paperless UX; v0.1.1
All checks were successful
Build APK / build (push) Successful in 5m40s
- Camera: system shutter sound (only when ringer isn't silenced) + brief
  white flash overlay on capture, for clear feedback even on silent.
- Settings: snackbar confirmation when saving.
- Export: explain why the Paperless upload button is disabled when unconfigured.
- Bump versionCode 2 / versionName 0.1.1.

Fixes #1, #2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:49:10 +02:00
5 changed files with 147 additions and 12 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 = 1 versionCode = 3
versionName = "0.1.0" 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()
runCatching {
client.newCall(request).execute().use { resp -> // The POST runs NonCancellable so that "stop waiting" only skips the status poll —
if (resp.isSuccessful) Result.Success // the document is always really queued. post_document returns the task UUID string.
else Result.Failure("Upload mislukt: ${resp.code} ${resp.message}") 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()
} }
} }

View File

@@ -1,12 +1,16 @@
package eu.geyskens.pdfscan.ui.screens package eu.geyskens.pdfscan.ui.screens
import android.Manifest import android.Manifest
import android.media.AudioManager
import android.media.MediaActionSound
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -31,6 +35,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -98,6 +103,22 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
} }
var capturing by remember { mutableStateOf(false) } var capturing by remember { mutableStateOf(false) }
// Capture feedback: system shutter sound + a brief white flash over the preview,
// so there's clear confirmation even when the phone is on silent.
val shutterSound = remember { MediaActionSound().apply { load(MediaActionSound.SHUTTER_CLICK) } }
val audioManager = remember { ContextCompat.getSystemService(context, AudioManager::class.java) }
val flash = remember { Animatable(0f) }
var captureTick by remember { mutableIntStateOf(0) }
androidx.compose.runtime.LaunchedEffect(captureTick) {
if (captureTick > 0) {
flash.snapTo(0.75f)
flash.animateTo(0f, tween(320))
}
}
androidx.compose.runtime.DisposableEffect(Unit) {
onDispose { shutterSound.release() }
}
val previewView = remember { val previewView = remember {
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER } PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
} }
@@ -146,6 +167,11 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
onClick = { onClick = {
if (capturing) return@FilledIconButton if (capturing) return@FilledIconButton
capturing = true capturing = true
// Flash always; play the shutter sound only when the ringer isn't silenced.
if (audioManager?.ringerMode == AudioManager.RINGER_MODE_NORMAL) {
shutterSound.play(MediaActionSound.SHUTTER_CLICK)
}
captureTick++
val file = File(vm.capturesDir(), "cap_${System.currentTimeMillis()}.jpg") val file = File(vm.capturesDir(), "cap_${System.currentTimeMillis()}.jpg")
val options = ImageCapture.OutputFileOptions.Builder(file).build() val options = ImageCapture.OutputFileOptions.Builder(file).build()
imageCapture.takePicture( imageCapture.takePicture(
@@ -183,6 +209,11 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
) )
} }
} }
// Shutter flash overlay (drawn on top, fades out quickly).
if (flash.value > 0f) {
Box(Modifier.fillMaxSize().background(Color.White.copy(alpha = flash.value)))
}
} }
androidx.compose.runtime.DisposableEffect(Unit) { androidx.compose.runtime.DisposableEffect(Unit) {

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
} }
} }
}, },
@@ -155,6 +168,11 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
} }
if (!settings.paperlessUrl.isNotBlank() || !settings.hasToken) { if (!settings.paperlessUrl.isNotBlank() || !settings.hasToken) {
Text(
"Configureer eerst Paperless (server-URL + token) in de instellingen om te kunnen uploaden.",
style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
color = Color(0xFF9A9A9A),
)
OutlinedButton( OutlinedButton(
onClick = { navController.navigate(Routes.SETTINGS) }, onClick = { navController.navigate(Routes.SETTINGS) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -165,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

@@ -21,6 +21,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -51,8 +53,10 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
var token by remember { mutableStateOf(vm.currentToken().orEmpty()) } var token by remember { mutableStateOf(vm.currentToken().orEmpty()) }
var testing by remember { mutableStateOf(false) } var testing by remember { mutableStateOf(false) }
var testResult by remember { mutableStateOf<String?>(null) } var testResult by remember { mutableStateOf<String?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Instellingen") }, title = { Text("Instellingen") },
@@ -107,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
} }
@@ -116,7 +121,12 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
) { Text("Verbinding testen") } ) { Text("Verbinding testen") }
Button( Button(
onClick = { scope.launch { vm.savePaperless(url, token) } }, onClick = {
scope.launch {
vm.savePaperless(url, token)
snackbarHostState.showSnackbar("Instellingen opgeslagen ✓")
}
},
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { Text("Opslaan") } ) { Text("Opslaan") }
} }