4 Commits

Author SHA1 Message Date
sam
898ae76526 Internationalization: string resources + Dutch translation; v0.3.0
All checks were successful
Build APK / build (push) Successful in 5m55s
Move all user-facing strings into res/values/strings.xml (English base) and
add res/values-nl/strings.xml (Dutch). Composables use stringResource;
coroutine-set and non-composable strings (ViewModel, PaperlessClient) resolve
via Context.getString. Mark app_name translatable=false. Bump to 0.3.0.

Fixes #5

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:37:46 +02:00
sam
3a3f18153f Add live edge-detection overlay while aiming; v0.2.0
All checks were successful
Build APK / build (push) Successful in 6m9s
Bind a CameraX ImageAnalysis use case that runs EdgeDetector on downscaled
YUV frames (throttled ~6/s) and draw the detected document quad as a live
green overlay on the preview, mapped through the FILL_CENTER crop. Preview
switched to TextureView (COMPATIBLE) mode so the Compose overlay renders on
top. Bump versionCode 4 / versionName 0.2.0.

Fixes #4

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:08:48 +02:00
sam
3c77e9ab3e Translate the entire repo to English (UI strings, README, CI, comments)
Prep for public distribution. All user-facing strings, the README, the Gitea
workflow step names/comments and the keystore example are now English.
Follow-up i18n (string resources + locale files) is tracked in #5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:52:01 +02:00
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
20 changed files with 533 additions and 150 deletions

BIN
.claude/i18n.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
.claude/overlay1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
.claude/overlay2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
.claude/overlay3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -1,6 +1,6 @@
name: Build APK
# Bouwt een getekende release-APK op elke versietag (v0.1.0, …) en op handmatige trigger.
# Builds a signed release APK on every version tag (v0.1.0, …) and on manual trigger.
on:
push:
tags:
@@ -9,26 +9,26 @@ on:
jobs:
build:
# Dedicated x86_64 runner (LXC op Proxmox) — de globale ARM64-runners hebben deze
# label niet, dus zij pikken deze job niet op.
# Dedicated x86_64 runner (LXC on Proxmox) — the global ARM64 runners don't have
# this label, so they won't pick up this job.
runs-on: paperscan
# Laat het automatische Actions-token een release aanmaken + assets uploaden.
# Let the automatic Actions token create a release + upload assets.
permissions:
contents: write
# Image met Android SDK + JDK 17 + Gradle voorgeïnstalleerd.
# Image with Android SDK + JDK 17 + Gradle preinstalled.
container:
image: mingc/android-build-box:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Herstel signing keystore uit secret
- name: Restore signing keystore from secret
run: |
echo "$SIGNING_KEYSTORE_BASE64" | base64 -d > "$GITHUB_WORKSPACE/release.jks"
env:
SIGNING_KEYSTORE_BASE64: ${{ secrets.SIGNING_KEYSTORE_BASE64 }}
- name: Genereer Gradle wrapper indien nodig
- name: Generate Gradle wrapper if needed
run: |
if [ ! -f gradle/wrapper/gradle-wrapper.jar ]; then
gradle wrapper --gradle-version 8.11.1
@@ -44,7 +44,7 @@ jobs:
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
- name: Publiceer release met APK als download
- name: Publish release with APK as a download
env:
TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
@@ -54,22 +54,22 @@ jobs:
APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
AUTH="Authorization: token $TOKEN"
# Hergebruik een bestaande release voor deze tag, of maak 'm aan.
# Reuse an existing release for this tag, or create it.
REL_ID=$(curl -sS -H "$AUTH" "$API/releases/tags/$TAG" | grep -oP '"id":\s*\K[0-9]+' | head -1 || true)
if [ -z "$REL_ID" ]; then
REL_ID=$(curl -sS -X POST "$API/releases" -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"PaperScan $TAG\",\"body\":\"Automatische build van $TAG.\"}" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"PaperScan $TAG\",\"body\":\"Automated build of $TAG.\"}" \
| grep -oP '"id":\s*\K[0-9]+' | head -1)
fi
echo "Release id: $REL_ID"
# Verwijder een eventueel bestaande asset met dezelfde naam (bij re-run/hertag).
# Remove any existing asset with the same name (on re-run/re-tag).
ASSET_NAME="paperscan-$TAG.apk"
OLD=$(curl -sS -H "$AUTH" "$API/releases/$REL_ID/assets" \
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"$ASSET_NAME\")" | head -1 || true)
if [ -n "$OLD" ]; then curl -sS -X DELETE -H "$AUTH" "$API/releases/$REL_ID/assets/$OLD"; fi
# Upload de getekende APK als download-asset.
# Upload the signed APK as a downloadable asset.
curl -sS -X POST "$API/releases/$REL_ID/assets?name=$ASSET_NAME" \
-H "$AUTH" -H "Content-Type: application/octet-stream" \
--data-binary @"$APK" | grep -oP '"browser_download_url":\s*"\K[^"]+' || true

123
README.md
View File

@@ -1,110 +1,117 @@
# PaperScan
Een lokale, privacy-bewuste documentscanner voor Android. Scant papieren documenten
met de camera, detecteert automatisch de randen, corrigeert het perspectief en bundelt
meerdere pagina's in één PDF. Die PDF kun je delen via Android of rechtstreeks uploaden
naar je zelf-gehoste **Paperless-ngx**.
A local, privacy-conscious document scanner for Android. Scan paper documents with your
camera, automatically detect the edges, correct the perspective, and bundle multiple pages
into a single PDF. That PDF can be shared through Android or uploaded directly to your
self-hosted **Paperless-ngx**.
**Alles gebeurt op het toestel.** Camerabeelden, randdetectie (OpenCV) en PDF-creatie
verlaten je telefoon niet. De enige netwerkverbinding is de optionele upload naar de
Paperless-server die jij zelf configureert. Er is geen AI of cloud-verwerking.
**Everything happens on the device.** Camera frames, edge detection (OpenCV) and PDF
creation never leave your phone. The only network connection is the optional upload to the
Paperless server you configure yourself. There is no AI or cloud processing.
## Functies
## Features
- 📷 Camerapreview met multi-page vastleggen (pagina na pagina toevoegen)
- ✂️ Automatische randdetectie + perspectiefcorrectie (OpenCV), met handmatige hoekcorrectie
- 🎨 Filters per pagina: kleur, grijstinten, zwart-wit (documentmodus)
- 🔄 Pagina's draaien, herordenen en verwijderen
- 📄 Export naar multi-page PDF (A4 of originele grootte)
- ☁️ Directe upload naar Paperless-ngx **of** delen via het Android-deelmenu
- 🔐 API-token versleuteld opgeslagen (Android Keystore / EncryptedSharedPreferences)
- 📷 Camera preview with multi-page capture (add pages one by one)
- ✂️ Automatic edge detection + perspective correction (OpenCV), with manual corner adjustment
- 🎨 Per-page filters: color, grayscale, black & white (document mode)
- 🔄 Rotate, reorder and delete pages
- 📄 Export to a multi-page PDF (A4 or original size)
- ☁️ Direct upload to Paperless-ngx **or** share via the Android share sheet
- 🔊 Capture feedback: shutter sound (only when the ringer isn't silenced) + a brief flash
- 🔐 API token stored encrypted (Android Keystore / EncryptedSharedPreferences)
## Tech
Kotlin · Jetpack Compose · CameraX · OpenCV (`org.opencv:opencv` via Maven) ·
`PdfDocument` · OkHttp · DataStore. minSdk 26 (Android 8), target/compile SDK 35.
## Lokaal bouwen
## Building locally
1. Open het project in **Android Studio** (Ladybug of nieuwer). Dit genereert automatisch
de Gradle wrapper (`gradlew` + `gradle-wrapper.jar`).
2. Sluit een toestel aan of start een emulator en druk op **Run**.
1. Open the project in **Android Studio** (Ladybug or newer). This automatically generates
the Gradle wrapper (`gradlew` + `gradle-wrapper.jar`).
2. Connect a device or start an emulator and press **Run**.
Of vanaf de command line (na `gradle wrapper` één keer):
Or from the command line (after `gradle wrapper` once):
```bash
./gradlew assembleDebug # debug-APK, geen signing nodig
./gradlew assembleRelease # release-APK, vereist signing (zie onder)
./gradlew assembleDebug # debug APK, no signing required
./gradlew assembleRelease # release APK, requires signing (see below)
```
De release-APK verschijnt in `app/build/outputs/apk/release/`.
The release APK ends up in `app/build/outputs/apk/release/`.
## Signing key aanmaken
## Creating a signing key
Nodig voor release-builds (en dus voor updates die elkaar kunnen overschrijven).
Required for release builds (and therefore for updates that can overwrite each other).
```bash
keytool -genkey -v -keystore release.jks -keyalg RSA -keysize 2048 \
-validity 10000 -alias paperscan
```
Bewaar `release.jks` **veilig en buiten git** (staat al in `.gitignore`).
Keep `release.jks` **safe and out of git** (already in `.gitignore`).
Voor lokale release-builds maak je een `keystore.properties` in de projectroot
(zie `keystore.properties.example`):
For local release builds, create a `keystore.properties` in the project root
(see `keystore.properties.example`):
```properties
storeFile=/pad/naar/release.jks
storeFile=/path/to/release.jks
storePassword=...
keyAlias=paperscan
keyPassword=...
```
## Bouwen via Gitea Actions
## Building via Gitea Actions
De workflow `.gitea/workflows/build.yml` bouwt een getekende release-APK bij elke
versietag (`git tag v0.1.0 && git push --tags`) of via een handmatige run.
The workflow `.gitea/workflows/build.yml` builds a signed release APK on every version tag
(`git tag v0.1.0 && git push --tags`) or via a manual run, and publishes it as a **Release**
with the APK attached as a downloadable asset.
Zet in je Gitea-repo onder **Settings → Actions → Secrets** deze secrets klaar:
Add these secrets under **Settings → Actions → Secrets** in your Gitea repo:
| Secret | Inhoud |
| Secret | Contents |
| --- | --- |
| `SIGNING_KEYSTORE_BASE64` | `base64 -w0 release.jks` (de keystore als base64) |
| `SIGNING_STORE_PASSWORD` | wachtwoord van de keystore |
| `SIGNING_KEY_ALIAS` | bv. `paperscan` |
| `SIGNING_KEY_PASSWORD` | wachtwoord van de sleutel |
| `SIGNING_KEYSTORE_BASE64` | `base64 -w0 release.jks` (the keystore as base64) |
| `SIGNING_STORE_PASSWORD` | keystore password |
| `SIGNING_KEY_ALIAS` | e.g. `paperscan` |
| `SIGNING_KEY_PASSWORD` | key password |
De build gebruikt env-vars, dus er komt niets gevoeligs in de repo terecht. De APK
komt als artifact `paperscan-release` beschikbaar bij de workflow-run.
The build uses env vars, so nothing sensitive ends up in the repo.
> Zorg dat je Gitea Actions-runner Docker-images kan trekken (`mingc/android-build-box`).
> The build runs in a container (`mingc/android-build-box`), so the Actions runner must be
> **x86_64** — the Android build tools (aapt2) are x86_64-Linux only and won't run natively
> on an ARM64 runner.
## Paperless-ngx instellen (in de app)
## Setting up Paperless-ngx (in the app)
1. Maak in Paperless een API-token aan: **Instellingen → profiel → API-token**
(of via `/api/token/`).
2. Open in PaperScan het tandwiel → vul **server-URL** (bv. `https://paperless.thuis.lan`)
en het **token** in → **Verbinding testen****Opslaan**.
1. Create an API token in Paperless: **Settings → profile → API token** (or via `/api/token/`).
2. In PaperScan, open the gear icon → enter the **server URL** (e.g. `https://paperless.home.lan`)
and the **token****Test connection****Save**.
Gebruik bij voorkeur **https**; bij `http://` waarschuwt de app dat token en documenten
onversleuteld over het netwerk gaan.
Prefer **https**; with `http://` the app warns that the token and documents travel over the
network unencrypted.
## Projectstructuur
## Project layout
```
app/src/main/java/eu/geyskens/pdfscan/
├── PaperScanApp.kt # Application; laadt OpenCV native libs
├── MainActivity.kt # Compose host + navigatie
├── ScanViewModel.kt # scan-sessie, rendering, export, upload
├── data/ # Page-model, ScanFilter, SettingsRepository
├── PaperScanApp.kt # Application; loads OpenCV native libs
├── MainActivity.kt # Compose host + navigation
├── ScanViewModel.kt # scan session, rendering, export, upload
├── data/ # Page model, ScanFilter, SettingsRepository
├── scan/ # EdgeDetector, ImageProcessor, BitmapIo (OpenCV)
├── pdf/ # PdfBuilder (multi-page PDF)
├── paperless/ # PaperlessClient (OkHttp REST)
├── paperless/ # PaperlessClient (OkHttp REST + task polling)
└── ui/screens/ # Camera, Pages, Crop, Export, Settings
```
## Roadmap / ideeën
## Roadmap
- Live randdetectie-overlay tijdens het richten (nu bij vastleggen)
- OCR-tekstlaag (bv. Tesseract) volledig on-device
- Batch-import van bestaande foto's uit de galerij
Tracked as issues in the repository:
- Live edge-detection overlay while aiming (like Microsoft Lens)
- Internationalization: extract UI strings to resources + add translations
- Batch import scans from the gallery
> OCR is intentionally **not** done on-device: Paperless-ngx already runs OCR (via
> OCRmyPDF/Tesseract) when it consumes the uploaded document.

View File

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

View File

@@ -115,9 +115,11 @@ class ScanViewModel(app: Application) : AndroidViewModel(app) {
val s = settings.value
val token = settingsRepo.getToken()
if (s.paperlessUrl.isBlank() || token.isNullOrBlank()) {
return PaperlessClient.Result.Failure("Paperless is nog niet ingesteld (URL + token).")
return PaperlessClient.Result.Failure(
getApplication<Application>().getString(R.string.err_paperless_not_setup)
)
}
return PaperlessClient(s.paperlessUrl, token).upload(pdf, title)
return PaperlessClient(s.paperlessUrl, token, getApplication<Application>()).upload(pdf, title)
}
// --- Settings pass-throughs (SettingsScreen) ---
@@ -133,9 +135,11 @@ class ScanViewModel(app: Application) : AndroidViewModel(app) {
suspend fun testPaperless(url: String, token: String): PaperlessClient.Result {
if (url.isBlank() || token.isBlank()) {
return PaperlessClient.Result.Failure("Vul eerst URL en token in.")
return PaperlessClient.Result.Failure(
getApplication<Application>().getString(R.string.err_enter_url_token)
)
}
return PaperlessClient(url.trim(), token.trim()).testConnection()
return PaperlessClient(url.trim(), token.trim(), getApplication<Application>()).testConnection()
}
fun clearSession() {

View File

@@ -1,12 +1,18 @@
package eu.geyskens.pdfscan.paperless
import android.content.Context
import eu.geyskens.pdfscan.R
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
@@ -18,6 +24,7 @@ import java.util.concurrent.TimeUnit
class PaperlessClient(
private val baseUrl: String,
private val token: String,
private val context: Context,
) {
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
@@ -28,7 +35,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
}
@@ -44,14 +56,19 @@ class PaperlessClient(
client.newCall(request).execute().use { resp ->
when {
resp.isSuccessful -> Result.Success
resp.code == 401 || resp.code == 403 -> Result.Failure("Ongeldig token (${resp.code}).")
else -> Result.Failure("Server antwoordde met ${resp.code}.")
resp.code == 401 || resp.code == 403 ->
Result.Failure(context.getString(R.string.err_invalid_token, resp.code))
else -> Result.Failure(context.getString(R.string.err_server_responded, resp.code))
}
}
}.getOrElse { Result.Failure(it.message ?: "Verbinding mislukt.") }
}.getOrElse { Result.Failure(it.message ?: context.getString(R.string.err_connection_failed)) }
}
/** 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 +83,74 @@ class PaperlessClient(
.header("Authorization", "Token $token")
.post(body)
.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 {
client.newCall(request).execute().use { resp ->
if (resp.isSuccessful) Result.Success
else Result.Failure("Upload mislukt: ${resp.code} ${resp.message}")
if (!resp.isSuccessful) {
error(context.getString(R.string.err_upload_failed_code, resp.code, resp.message))
}
}.getOrElse { Result.Failure(it.message ?: "Upload mislukt.") }
resp.body?.string()?.trim()?.trim('"')?.takeIf { it.isNotBlank() }
}
}
}
val taskId = posted.getOrElse {
return@withContext Result.Failure(it.message ?: context.getString(R.string.err_upload_failed))
}
// 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 { context.getString(R.string.err_paperless_process) })
}
// 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

@@ -21,7 +21,7 @@ object BitmapIo {
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
val bitmap = BitmapFactory.decodeFile(file.absolutePath, opts)
?: error("Kon afbeelding niet decoderen: ${file.name}")
?: error("Could not decode image: ${file.name}")
val orientation = ExifInterface(file.absolutePath)
.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)

View File

@@ -0,0 +1,65 @@
package eu.geyskens.pdfscan.scan
import android.graphics.PointF
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import org.opencv.core.Core
import org.opencv.core.CvType
import org.opencv.core.Mat
/**
* Runs [EdgeDetector] on live camera frames (throttled) and reports the detected document
* corners, normalized to a display-oriented frame, together with that frame's aspect ratio
* (width / height) so the overlay can map them onto the preview.
*/
class DocumentAnalyzer(
private val onResult: (corners: List<PointF>?, frameAspect: Float) -> Unit,
) : ImageAnalysis.Analyzer {
@Volatile
private var lastRun = 0L
override fun analyze(image: ImageProxy) {
val now = System.currentTimeMillis()
if (now - lastRun < THROTTLE_MS) {
image.close()
return
}
lastRun = now
val result = try {
detect(image)
} catch (e: Throwable) {
android.util.Log.w("DocAnalyzer", "detect failed", e)
null
}
image.close()
if (result != null) onResult(result.first, result.second)
}
private fun detect(image: ImageProxy): Pair<List<PointF>?, Float> {
// The Y plane of the YUV frame is already a grayscale image.
val plane = image.planes[0]
val rowStride = plane.rowStride
val buffer = plane.buffer
val out = ByteArray(image.width * image.height)
for (row in 0 until image.height) {
buffer.position(row * rowStride)
buffer.get(out, row * image.width, image.width)
}
val gray = Mat(image.height, image.width, CvType.CV_8UC1)
gray.put(0, 0, out)
// Rotate the sensor-oriented frame to match how the preview is displayed.
when (image.imageInfo.rotationDegrees) {
90 -> Core.rotate(gray, gray, Core.ROTATE_90_CLOCKWISE)
180 -> Core.rotate(gray, gray, Core.ROTATE_180)
270 -> Core.rotate(gray, gray, Core.ROTATE_90_COUNTERCLOCKWISE)
}
val aspect = gray.cols().toFloat() / gray.rows()
return EdgeDetector.detectFromGray(gray) to aspect // detectFromGray releases gray
}
companion object {
private const val THROTTLE_MS = 150L
}
}

View File

@@ -22,17 +22,25 @@ object EdgeDetector {
fun detect(bitmap: Bitmap): List<PointF>? {
val src = Mat()
Utils.bitmapToMat(bitmap, src)
try {
val scale = PROCESS_WIDTH / src.cols()
val work = Mat()
Imgproc.resize(src, work, Size(PROCESS_WIDTH, src.rows() * scale))
val gray = Mat()
Imgproc.cvtColor(work, gray, Imgproc.COLOR_RGBA2GRAY)
Imgproc.GaussianBlur(gray, gray, Size(5.0, 5.0), 0.0)
Imgproc.cvtColor(src, gray, Imgproc.COLOR_RGBA2GRAY)
src.release()
return detectFromGray(gray)
}
/**
* Detects on a full-resolution single-channel grayscale [gray] Mat (e.g. a camera
* frame's Y plane). Corners are normalized to [gray]'s dimensions. Releases [gray].
*/
fun detectFromGray(gray: Mat): List<PointF>? {
try {
val scale = PROCESS_WIDTH / gray.cols()
val work = Mat()
Imgproc.resize(gray, work, Size(PROCESS_WIDTH, gray.rows() * scale))
Imgproc.GaussianBlur(work, work, Size(5.0, 5.0), 0.0)
val edges = Mat()
Imgproc.Canny(gray, edges, 50.0, 150.0)
Imgproc.Canny(work, edges, 50.0, 150.0)
// Close small gaps so contours form a continuous loop.
val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(5.0, 5.0))
Imgproc.dilate(edges, edges, kernel)
@@ -65,7 +73,7 @@ object EdgeDetector {
}
return best
} finally {
src.release()
gray.release()
}
}

View File

@@ -1,9 +1,13 @@
package eu.geyskens.pdfscan.ui.screens
import android.Manifest
import android.graphics.PointF
import android.media.AudioManager
import android.media.MediaActionSound
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.resolutionselector.AspectRatioStrategy
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
@@ -11,6 +15,7 @@ import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -42,9 +47,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.concurrent.futures.await
@@ -52,8 +59,10 @@ import androidx.core.content.ContextCompat
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import eu.geyskens.pdfscan.R
import eu.geyskens.pdfscan.Routes
import eu.geyskens.pdfscan.ScanViewModel
import eu.geyskens.pdfscan.scan.DocumentAnalyzer
import kotlinx.coroutines.launch
import java.io.File
import java.util.concurrent.Executors
@@ -77,13 +86,12 @@ private fun PermissionRequest(onRequest: () -> Unit) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
androidx.compose.foundation.layout.Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"PaperScan heeft cameratoegang nodig om documenten te scannen. " +
"Er wordt niets naar de cloud gestuurd.",
stringResource(R.string.camera_permission_rationale),
color = Color.White,
textAlign = TextAlign.Center,
)
androidx.compose.foundation.layout.Spacer(Modifier.size(16.dp))
Button(onClick = onRequest) { Text("Toegang geven") }
Button(onClick = onRequest) { Text(stringResource(R.string.grant_access)) }
}
}
}
@@ -96,12 +104,27 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
val pages by vm.pages.collectAsState()
val executor = remember { Executors.newSingleThreadExecutor() }
// Same 4:3 aspect for preview and analysis so the live overlay lines up with the preview.
val resolutionSelector = remember {
ResolutionSelector.Builder()
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY)
.build()
}
val imageCapture = remember {
ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.build()
}
val imageAnalysis = remember {
ImageAnalysis.Builder()
.setResolutionSelector(resolutionSelector)
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
}
var capturing by remember { mutableStateOf(false) }
// Live document corners (normalized, display-oriented) + the analysis frame aspect ratio.
var liveCorners by remember { mutableStateOf<List<PointF>?>(null) }
var frameAspect by remember { mutableStateOf(0f) }
// Capture feedback: system shutter sound + a brief white flash over the preview,
// so there's clear confirmation even when the phone is on silent.
@@ -120,15 +143,27 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
}
val previewView = remember {
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
PreviewView(context).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
// TextureView mode so the Compose overlay draws on top of the preview
// (SurfaceView mode composites on a separate layer that hides the overlay).
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
}
androidx.compose.runtime.LaunchedEffect(Unit) {
val provider = ProcessCameraProvider.getInstance(context).await()
val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) }
val preview = Preview.Builder()
.setResolutionSelector(resolutionSelector)
.build()
.also { it.setSurfaceProvider(previewView.surfaceProvider) }
imageAnalysis.setAnalyzer(executor, DocumentAnalyzer { corners, aspect ->
liveCorners = corners
frameAspect = aspect
})
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture, imageAnalysis
)
}
@@ -138,12 +173,33 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
modifier = Modifier.fillMaxSize(),
)
// Live edge-detection overlay, mapped onto the FILL_CENTER-cropped preview.
Canvas(Modifier.fillMaxSize()) {
val corners = liveCorners
if (corners != null && corners.size == 4 && frameAspect > 0f) {
val viewAspect = size.width / size.height
val dispW: Float
val dispH: Float
if (frameAspect > viewAspect) {
dispH = size.height; dispW = size.height * frameAspect
} else {
dispW = size.width; dispH = size.width / frameAspect
}
val offX = (size.width - dispW) / 2f
val offY = (size.height - dispH) / 2f
val pts = corners.map { Offset(offX + it.x * dispW, offY + it.y * dispH) }
for (i in pts.indices) {
drawLine(Color(0xFF00E676), pts[i], pts[(i + 1) % 4], strokeWidth = 5f)
}
}
}
// Top bar: settings.
IconButton(
onClick = { navController.navigate(Routes.SETTINGS) },
modifier = Modifier.align(Alignment.TopEnd).statusBarsPadding().padding(8.dp),
) {
Icon(Icons.Filled.Settings, contentDescription = "Instellingen", tint = Color.White)
Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings), tint = Color.White)
}
// Bottom controls.
@@ -204,7 +260,7 @@ private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.
) {
Icon(
Icons.Filled.Check,
contentDescription = "Klaar",
contentDescription = stringResource(R.string.done),
tint = if (pages.isEmpty()) Color.Gray else Color.White,
)
}

View File

@@ -34,9 +34,11 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import eu.geyskens.pdfscan.R
import eu.geyskens.pdfscan.ScanViewModel
import eu.geyskens.pdfscan.data.ScanFilter
import androidx.compose.foundation.Canvas
@@ -135,9 +137,9 @@ fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String)
Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterOption("Kleur", filter == ScanFilter.COLOR) { filter = ScanFilter.COLOR }
FilterOption("Grijs", filter == ScanFilter.GRAYSCALE) { filter = ScanFilter.GRAYSCALE }
FilterOption("Zwart-wit", filter == ScanFilter.BLACK_WHITE) { filter = ScanFilter.BLACK_WHITE }
FilterOption(stringResource(R.string.filter_color), filter == ScanFilter.COLOR) { filter = ScanFilter.COLOR }
FilterOption(stringResource(R.string.filter_grayscale), filter == ScanFilter.GRAYSCALE) { filter = ScanFilter.GRAYSCALE }
FilterOption(stringResource(R.string.filter_bw), filter == ScanFilter.BLACK_WHITE) { filter = ScanFilter.BLACK_WHITE }
}
Row(
@@ -145,7 +147,7 @@ fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String)
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(onClick = { vm.rotate(pageId) }, modifier = Modifier.weight(1f)) {
Text("Draaien")
Text(stringResource(R.string.rotate))
}
OutlinedButton(
onClick = {
@@ -153,7 +155,7 @@ fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String)
full.forEachIndexed { i, p -> corners[i].value = PointF(p.x, p.y) }
},
modifier = Modifier.weight(1f),
) { Text("Herstel") }
) { Text(stringResource(R.string.reset)) }
Button(
onClick = {
vm.updateCorners(pageId, corners.map { PointF(it.value.x, it.value.y) })
@@ -161,7 +163,7 @@ fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String)
navController.popBackStack()
},
modifier = Modifier.weight(1f),
) { Text("Toepassen") }
) { Text(stringResource(R.string.apply)) }
}
}
}

View File

@@ -36,12 +36,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import eu.geyskens.pdfscan.R
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,12 +65,14 @@ 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
Scaffold(
topBar = { TopAppBar(title = { Text("Exporteren (${pages.size} pagina's)") }) },
topBar = { TopAppBar(title = { Text(stringResource(R.string.export_title, pages.size)) }) },
) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).padding(16.dp),
@@ -75,7 +81,7 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
OutlinedTextField(
value = fileName,
onValueChange = { fileName = it },
label = { Text("Bestandsnaam") },
label = { Text(stringResource(R.string.file_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -85,12 +91,12 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
selected = a4,
onClick = { a4 = true },
shape = SegmentedButtonDefaults.itemShape(0, 2),
) { Text("A4-pagina") }
) { Text(stringResource(R.string.page_size_a4)) }
SegmentedButton(
selected = !a4,
onClick = { a4 = false },
shape = SegmentedButtonDefaults.itemShape(1, 2),
) { Text("Originele grootte") }
) { Text(stringResource(R.string.page_size_original)) }
}
Button(
@@ -109,9 +115,9 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(share, "PDF delen"))
context.startActivity(Intent.createChooser(share, context.getString(R.string.share_pdf)))
} catch (e: Exception) {
status = "Fout bij exporteren: ${e.message}"
status = context.getString(R.string.export_failed, e.message ?: "")
} finally {
busy = false
}
@@ -122,27 +128,36 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
) {
Icon(Icons.Filled.Share, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Delen / opslaan")
Text(stringResource(R.string.share_save))
}
Button(
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 = context.getString(R.string.upload_added)
is PaperlessClient.Result.Duplicate ->
status = context.getString(R.string.upload_duplicate)
is PaperlessClient.Result.Pending ->
status = context.getString(R.string.upload_pending)
is PaperlessClient.Result.Failure ->
status = r.message
}
} catch (e: CancellationException) {
// User tapped "Don't wait": the document was already sent.
status = context.getString(R.string.upload_background)
} catch (e: Exception) {
status = "Fout: ${e.message}"
status = context.getString(R.string.error_generic, e.message ?: "")
} finally {
busy = false
uploading = false
}
}
},
@@ -151,26 +166,30 @@ fun ExportScreen(vm: ScanViewModel, navController: NavController) {
) {
Icon(Icons.Filled.CloudUpload, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Upload naar Paperless")
Text(stringResource(R.string.upload_to_paperless))
}
if (!settings.paperlessUrl.isNotBlank() || !settings.hasToken) {
Text(
"Configureer eerst Paperless (server-URL + token) in de instellingen om te kunnen uploaden.",
stringResource(R.string.paperless_not_configured),
style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
color = Color(0xFF9A9A9A),
)
OutlinedButton(
onClick = { navController.navigate(Routes.SETTINGS) },
modifier = Modifier.fillMaxWidth(),
) { Text("Paperless instellen") }
) { Text(stringResource(R.string.set_up_paperless)) }
}
if (busy) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.height(24.dp))
Spacer(Modifier.width(12.dp))
Text("Bezig…")
Text(if (uploading) stringResource(R.string.uploading) else stringResource(R.string.working))
if (uploading) {
Spacer(Modifier.width(12.dp))
OutlinedButton(onClick = { uploadJob?.cancel() }) { Text(stringResource(R.string.dont_wait)) }
}
}
}
status?.let { Text(it, color = Color(0xFF1F6FEB)) }

View File

@@ -39,8 +39,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import eu.geyskens.pdfscan.R
import eu.geyskens.pdfscan.Routes
import eu.geyskens.pdfscan.ScanViewModel
import eu.geyskens.pdfscan.data.Page
@@ -53,12 +55,12 @@ fun PagesScreen(vm: ScanViewModel, navController: NavController) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Pagina's (${pages.size})") },
title = { Text(stringResource(R.string.pages_title, pages.size)) },
actions = {
IconButton(
onClick = { if (pages.isNotEmpty()) navController.navigate(Routes.EXPORT) },
) {
Icon(Icons.Filled.PictureAsPdf, contentDescription = "Exporteer PDF")
Icon(Icons.Filled.PictureAsPdf, contentDescription = stringResource(R.string.export_pdf))
}
},
)
@@ -67,7 +69,7 @@ fun PagesScreen(vm: ScanViewModel, navController: NavController) {
ExtendedFloatingActionButton(
onClick = { navController.navigate(Routes.CAMERA) },
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text("Pagina toevoegen") },
text = { Text(stringResource(R.string.add_page)) },
)
},
) { padding ->
@@ -122,24 +124,24 @@ private fun PageRow(
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Pagina ${index + 1}",
contentDescription = stringResource(R.string.page_number, index + 1),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
Spacer(Modifier.width(12.dp))
Text("Pagina ${index + 1}", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Text(stringResource(R.string.page_number, index + 1), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Column {
IconButton(onClick = onUp, enabled = !isFirst) {
Icon(Icons.Filled.ArrowUpward, contentDescription = "Omhoog")
Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_up))
}
IconButton(onClick = onDown, enabled = !isLast) {
Icon(Icons.Filled.ArrowDownward, contentDescription = "Omlaag")
Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_down))
}
}
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = "Verwijderen", tint = Color(0xFFD32F2F))
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete), tint = Color(0xFFD32F2F))
}
}
}

View File

@@ -35,9 +35,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import eu.geyskens.pdfscan.R
import eu.geyskens.pdfscan.ScanViewModel
import eu.geyskens.pdfscan.data.ScanFilter
import eu.geyskens.pdfscan.paperless.PaperlessClient
@@ -47,6 +50,7 @@ import kotlinx.coroutines.launch
@Composable
fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val settings by vm.settings.collectAsState()
var url by remember { mutableStateOf(settings.paperlessUrl) }
@@ -59,10 +63,10 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Instellingen") },
title = { Text(stringResource(R.string.settings)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Terug")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
)
@@ -76,27 +80,26 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Paperless-ngx", style = androidx.compose.material3.MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.paperless_header), style = androidx.compose.material3.MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = url,
onValueChange = { url = it; testResult = null },
label = { Text("Server-URL (bv. https://paperless.thuis.lan)") },
label = { Text(stringResource(R.string.server_url_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = token,
onValueChange = { token = it; testResult = null },
label = { Text("API-token") },
label = { Text(stringResource(R.string.api_token_label)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
if (url.startsWith("http://")) {
Text(
"⚠ Je gebruikt http:// — je token en documenten gaan onversleuteld over het " +
"netwerk. Gebruik https:// als het even kan.",
stringResource(R.string.http_warning),
color = Color(0xFFB26A00),
style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
)
@@ -109,54 +112,55 @@ fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
testResult = null
scope.launch {
testResult = when (val r = vm.testPaperless(url, token)) {
is PaperlessClient.Result.Success -> "Verbinding OK ✓"
is PaperlessClient.Result.Success -> context.getString(R.string.connection_ok)
is PaperlessClient.Result.Failure -> r.message
else -> context.getString(R.string.unexpected_response)
}
testing = false
}
},
enabled = !testing,
modifier = Modifier.weight(1f),
) { Text("Verbinding testen") }
) { Text(stringResource(R.string.test_connection)) }
Button(
onClick = {
scope.launch {
vm.savePaperless(url, token)
snackbarHostState.showSnackbar("Instellingen opgeslagen ✓")
snackbarHostState.showSnackbar(context.getString(R.string.settings_saved))
}
},
modifier = Modifier.weight(1f),
) { Text("Opslaan") }
) { Text(stringResource(R.string.save)) }
}
if (testing) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.width(20.dp))
Spacer(Modifier.width(8.dp))
Text("Testen…")
Text(stringResource(R.string.testing))
}
}
testResult?.let { Text(it, color = Color(0xFF1F6FEB)) }
Text(
"Standaardfilter",
stringResource(R.string.default_filter),
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = settings.defaultFilter == ScanFilter.COLOR,
onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.COLOR) } },
label = { Text("Kleur") },
label = { Text(stringResource(R.string.filter_color)) },
)
FilterChip(
selected = settings.defaultFilter == ScanFilter.GRAYSCALE,
onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.GRAYSCALE) } },
label = { Text("Grijs") },
label = { Text(stringResource(R.string.filter_grayscale)) },
)
FilterChip(
selected = settings.defaultFilter == ScanFilter.BLACK_WHITE,
onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.BLACK_WHITE) } },
label = { Text("Zwart-wit") },
label = { Text(stringResource(R.string.filter_bw)) },
)
}
}

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Camera -->
<string name="camera_permission_rationale">PaperScan heeft cameratoegang nodig om documenten te scannen. Er wordt niets naar de cloud gestuurd.</string>
<string name="grant_access">Toegang geven</string>
<string name="settings">Instellingen</string>
<string name="done">Klaar</string>
<!-- Pages -->
<string name="pages_title">Pagina\'s (%1$d)</string>
<string name="add_page">Pagina toevoegen</string>
<string name="export_pdf">Exporteer PDF</string>
<string name="page_number">Pagina %1$d</string>
<string name="move_up">Omhoog</string>
<string name="move_down">Omlaag</string>
<string name="delete">Verwijderen</string>
<!-- Crop -->
<string name="filter_color">Kleur</string>
<string name="filter_grayscale">Grijs</string>
<string name="filter_bw">Zwart-wit</string>
<string name="rotate">Draaien</string>
<string name="reset">Herstel</string>
<string name="apply">Toepassen</string>
<!-- Export -->
<string name="export_title">Exporteren (%1$d pagina\'s)</string>
<string name="file_name">Bestandsnaam</string>
<string name="page_size_a4">A4-pagina</string>
<string name="page_size_original">Originele grootte</string>
<string name="share_save">Delen / opslaan</string>
<string name="share_pdf">PDF delen</string>
<string name="upload_to_paperless">Upload naar Paperless</string>
<string name="set_up_paperless">Paperless instellen</string>
<string name="paperless_not_configured">Configureer eerst Paperless (server-URL + token) in de instellingen om te kunnen uploaden.</string>
<string name="uploading">Uploaden…</string>
<string name="working">Bezig…</string>
<string name="dont_wait">Niet wachten</string>
<string name="export_failed">Fout bij exporteren: %1$s</string>
<string name="error_generic">Fout: %1$s</string>
<string name="upload_added">Toegevoegd aan Paperless ✓</string>
<string name="upload_duplicate">Al aanwezig in Paperless (duplicaat) — niet opnieuw toegevoegd.</string>
<string name="upload_pending">Geüpload — Paperless is het nog aan het verwerken…</string>
<string name="upload_background">Geüpload — Paperless verwerkt het verder op de achtergrond.</string>
<!-- Settings -->
<string name="back">Terug</string>
<string name="paperless_header">Paperless-ngx</string>
<string name="server_url_label">Server-URL (bv. https://paperless.thuis.lan)</string>
<string name="api_token_label">API-token</string>
<string name="http_warning">⚠ Je gebruikt http:// — je token en documenten gaan onversleuteld over het netwerk. Gebruik https:// als het even kan.</string>
<string name="test_connection">Verbinding testen</string>
<string name="save">Opslaan</string>
<string name="testing">Testen…</string>
<string name="settings_saved">Instellingen opgeslagen ✓</string>
<string name="connection_ok">Verbinding OK ✓</string>
<string name="unexpected_response">Onbekend antwoord van de server.</string>
<string name="default_filter">Standaardfilter</string>
<!-- Paperless client / errors -->
<string name="err_invalid_token">Ongeldig token (%1$d).</string>
<string name="err_server_responded">Server antwoordde met %1$d.</string>
<string name="err_connection_failed">Verbinding mislukt.</string>
<string name="err_upload_failed_code">Upload mislukt: %1$d %2$s</string>
<string name="err_upload_failed">Upload mislukt.</string>
<string name="err_paperless_process">Paperless kon het document niet verwerken.</string>
<string name="err_paperless_not_setup">Paperless is nog niet ingesteld (URL + token).</string>
<string name="err_enter_url_token">Vul eerst URL en token in.</string>
</resources>

View File

@@ -1,4 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PaperScan</string>
<string name="app_name" translatable="false">PaperScan</string>
<!-- Camera -->
<string name="camera_permission_rationale">PaperScan needs camera access to scan documents. Nothing is sent to the cloud.</string>
<string name="grant_access">Grant access</string>
<string name="settings">Settings</string>
<string name="done">Done</string>
<!-- Pages -->
<string name="pages_title">Pages (%1$d)</string>
<string name="add_page">Add page</string>
<string name="export_pdf">Export PDF</string>
<string name="page_number">Page %1$d</string>
<string name="move_up">Move up</string>
<string name="move_down">Move down</string>
<string name="delete">Delete</string>
<!-- Crop -->
<string name="filter_color">Color</string>
<string name="filter_grayscale">Grayscale</string>
<string name="filter_bw">Black &amp; white</string>
<string name="rotate">Rotate</string>
<string name="reset">Reset</string>
<string name="apply">Apply</string>
<!-- Export -->
<string name="export_title">Export (%1$d pages)</string>
<string name="file_name">File name</string>
<string name="page_size_a4">A4 page</string>
<string name="page_size_original">Original size</string>
<string name="share_save">Share / save</string>
<string name="share_pdf">Share PDF</string>
<string name="upload_to_paperless">Upload to Paperless</string>
<string name="set_up_paperless">Set up Paperless</string>
<string name="paperless_not_configured">Configure Paperless (server URL + token) in settings first to upload.</string>
<string name="uploading">Uploading…</string>
<string name="working">Working…</string>
<string name="dont_wait">Don\'t wait</string>
<string name="export_failed">Export failed: %1$s</string>
<string name="error_generic">Error: %1$s</string>
<string name="upload_added">Added to Paperless ✓</string>
<string name="upload_duplicate">Already in Paperless (duplicate) — not added again.</string>
<string name="upload_pending">Uploaded — Paperless is still processing…</string>
<string name="upload_background">Uploaded — Paperless will finish processing in the background.</string>
<!-- Settings -->
<string name="back">Back</string>
<string name="paperless_header">Paperless-ngx</string>
<string name="server_url_label">Server URL (e.g. https://paperless.home.lan)</string>
<string name="api_token_label">API token</string>
<string name="http_warning">⚠ You\'re using http:// — your token and documents travel unencrypted over the network. Use https:// when you can.</string>
<string name="test_connection">Test connection</string>
<string name="save">Save</string>
<string name="testing">Testing…</string>
<string name="settings_saved">Settings saved ✓</string>
<string name="connection_ok">Connection OK ✓</string>
<string name="unexpected_response">Unexpected response from the server.</string>
<string name="default_filter">Default filter</string>
<!-- Paperless client / errors -->
<string name="err_invalid_token">Invalid token (%1$d).</string>
<string name="err_server_responded">Server responded with %1$d.</string>
<string name="err_connection_failed">Connection failed.</string>
<string name="err_upload_failed_code">Upload failed: %1$d %2$s</string>
<string name="err_upload_failed">Upload failed.</string>
<string name="err_paperless_process">Paperless could not process the document.</string>
<string name="err_paperless_not_setup">Paperless isn\'t set up yet (URL + token).</string>
<string name="err_enter_url_token">Enter a URL and token first.</string>
</resources>

View File

@@ -1,5 +1,5 @@
# Kopieer naar keystore.properties (staat in .gitignore) voor lokale release-builds.
storeFile=/absoluut/pad/naar/release.jks
# Copy to keystore.properties (gitignored) for local release builds.
storeFile=/absolute/path/to/release.jks
storePassword=CHANGEME
keyAlias=paperscan
keyPassword=CHANGEME