Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 953efaf168 | |||
| 752d4972bb |
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)) }
|
||||||
|
|||||||
@@ -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") }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user