Initial commit: PaperScan — lokale Android documentscanner voor Paperless-ngx
Native Kotlin/Compose app met CameraX + OpenCV randdetectie/perspectiefcorrectie, multi-page PDF-export, en Paperless-ngx upload (of Android deelmenu). Alles lokaal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(java -version)",
|
||||||
|
"WebFetch(domain:repo1.maven.org)",
|
||||||
|
"Bash(curl -s https://repo1.maven.org/maven2/org/opencv/opencv/maven-metadata.xml)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
46
.gitea/workflows/build.yml
Normal file
46
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: Build APK
|
||||||
|
|
||||||
|
# Bouwt een getekende release-APK op elke versietag (v0.1.0, …) en op handmatige trigger.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Image met Android SDK + JDK 17 + Gradle voorgeïnstalleerd.
|
||||||
|
container:
|
||||||
|
image: mingc/android-build-box:latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Herstel signing keystore uit 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
|
||||||
|
run: |
|
||||||
|
if [ ! -f gradle/wrapper/gradle-wrapper.jar ]; then
|
||||||
|
gradle wrapper --gradle-version 8.11.1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build release APK
|
||||||
|
run: |
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew --no-daemon assembleRelease
|
||||||
|
env:
|
||||||
|
SIGNING_STORE_FILE: ${{ github.workspace }}/release.jks
|
||||||
|
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
|
||||||
|
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
|
||||||
|
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Upload APK als artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: paperscan-release
|
||||||
|
path: app/build/outputs/apk/release/*.apk
|
||||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
/local.properties
|
||||||
|
/.idea/
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/app/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Signing material must never be committed.
|
||||||
|
keystore.properties
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
110
README.md
Normal file
110
README.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# 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**.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## Functies
|
||||||
|
|
||||||
|
- 📷 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)
|
||||||
|
|
||||||
|
## Tech
|
||||||
|
|
||||||
|
Kotlin · Jetpack Compose · CameraX · OpenCV (`org.opencv:opencv` via Maven) ·
|
||||||
|
`PdfDocument` · OkHttp · DataStore. minSdk 26 (Android 8), target/compile SDK 35.
|
||||||
|
|
||||||
|
## Lokaal bouwen
|
||||||
|
|
||||||
|
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**.
|
||||||
|
|
||||||
|
Of vanaf de command line (na `gradle wrapper` één keer):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug # debug-APK, geen signing nodig
|
||||||
|
./gradlew assembleRelease # release-APK, vereist signing (zie onder)
|
||||||
|
```
|
||||||
|
|
||||||
|
De release-APK verschijnt in `app/build/outputs/apk/release/`.
|
||||||
|
|
||||||
|
## Signing key aanmaken
|
||||||
|
|
||||||
|
Nodig voor release-builds (en dus voor updates die elkaar kunnen overschrijven).
|
||||||
|
|
||||||
|
```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`).
|
||||||
|
|
||||||
|
Voor lokale release-builds maak je een `keystore.properties` in de projectroot
|
||||||
|
(zie `keystore.properties.example`):
|
||||||
|
|
||||||
|
```properties
|
||||||
|
storeFile=/pad/naar/release.jks
|
||||||
|
storePassword=...
|
||||||
|
keyAlias=paperscan
|
||||||
|
keyPassword=...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bouwen 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.
|
||||||
|
|
||||||
|
Zet in je Gitea-repo onder **Settings → Actions → Secrets** deze secrets klaar:
|
||||||
|
|
||||||
|
| Secret | Inhoud |
|
||||||
|
| --- | --- |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
> Zorg dat je Gitea Actions-runner Docker-images kan trekken (`mingc/android-build-box`).
|
||||||
|
|
||||||
|
## Paperless-ngx instellen (in de 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**.
|
||||||
|
|
||||||
|
Gebruik bij voorkeur **https**; bij `http://` waarschuwt de app dat token en documenten
|
||||||
|
onversleuteld over het netwerk gaan.
|
||||||
|
|
||||||
|
## Projectstructuur
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
├── scan/ # EdgeDetector, ImageProcessor, BitmapIo (OpenCV)
|
||||||
|
├── pdf/ # PdfBuilder (multi-page PDF)
|
||||||
|
├── paperless/ # PaperlessClient (OkHttp REST)
|
||||||
|
└── ui/screens/ # Camera, Pages, Crop, Export, Settings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap / ideeën
|
||||||
|
|
||||||
|
- 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
|
||||||
110
app/build.gradle.kts
Normal file
110
app/build.gradle.kts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release signing is driven either by a local keystore.properties (developer machine)
|
||||||
|
// or by environment variables (Gitea Actions CI). Nothing secret is committed.
|
||||||
|
val keystorePropsFile = rootProject.file("keystore.properties")
|
||||||
|
val keystoreProps = Properties().apply {
|
||||||
|
if (keystorePropsFile.exists()) load(keystorePropsFile.inputStream())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signingValue(key: String, env: String): String? =
|
||||||
|
keystoreProps.getProperty(key) ?: System.getenv(env)
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "eu.geyskens.pdfscan"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "eu.geyskens.pdfscan"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
|
||||||
|
// OpenCV native libs are large; ship only the ABIs real phones use.
|
||||||
|
ndk {
|
||||||
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val storePath = signingValue("storeFile", "SIGNING_STORE_FILE")
|
||||||
|
signingConfigs {
|
||||||
|
if (storePath != null) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(storePath)
|
||||||
|
storePassword = signingValue("storePassword", "SIGNING_STORE_PASSWORD")
|
||||||
|
keyAlias = signingValue("keyAlias", "SIGNING_KEY_ALIAS")
|
||||||
|
keyPassword = signingValue("keyPassword", "SIGNING_KEY_PASSWORD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
signingConfig = signingConfigs.findByName("release")
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
|
||||||
|
implementation(libs.androidx.camera.core)
|
||||||
|
implementation(libs.androidx.camera.camera2)
|
||||||
|
implementation(libs.androidx.camera.lifecycle)
|
||||||
|
implementation(libs.androidx.camera.view)
|
||||||
|
|
||||||
|
implementation(libs.opencv)
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.androidx.security.crypto)
|
||||||
|
implementation(libs.accompanist.permissions)
|
||||||
|
implementation(libs.androidx.exifinterface)
|
||||||
|
implementation(libs.androidx.concurrent.futures.ktx)
|
||||||
|
}
|
||||||
8
app/proguard-rules.pro
vendored
Normal file
8
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# OpenCV loads native classes via JNI; keep its Java surface intact.
|
||||||
|
-keep class org.opencv.** { *; }
|
||||||
|
-dontwarn org.opencv.**
|
||||||
|
|
||||||
|
# OkHttp / Okio
|
||||||
|
-dontwarn okhttp3.**
|
||||||
|
-dontwarn okio.**
|
||||||
|
-keep class okhttp3.** { *; }
|
||||||
42
app/src/main/AndroidManifest.xml
Normal file
42
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera.any" android:required="true" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<!-- Only used to reach a self-hosted Paperless-ngx instance the user configures. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".PaperScanApp"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.PaperScan"
|
||||||
|
tools:targetApi="31">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.PaperScan">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
62
app/src/main/java/eu/geyskens/pdfscan/MainActivity.kt
Normal file
62
app/src/main/java/eu/geyskens/pdfscan/MainActivity.kt
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package eu.geyskens.pdfscan
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import eu.geyskens.pdfscan.ui.screens.CameraScreen
|
||||||
|
import eu.geyskens.pdfscan.ui.screens.CropScreen
|
||||||
|
import eu.geyskens.pdfscan.ui.screens.ExportScreen
|
||||||
|
import eu.geyskens.pdfscan.ui.screens.PagesScreen
|
||||||
|
import eu.geyskens.pdfscan.ui.screens.SettingsScreen
|
||||||
|
import eu.geyskens.pdfscan.ui.theme.PaperScanTheme
|
||||||
|
|
||||||
|
object Routes {
|
||||||
|
const val CAMERA = "camera"
|
||||||
|
const val PAGES = "pages"
|
||||||
|
const val CROP = "crop/{pageId}"
|
||||||
|
const val EXPORT = "export"
|
||||||
|
const val SETTINGS = "settings"
|
||||||
|
fun crop(pageId: String) = "crop/$pageId"
|
||||||
|
}
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
PaperScanTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
) {
|
||||||
|
// Activity-scoped so every destination shares one scan session.
|
||||||
|
val vm: ScanViewModel = viewModel()
|
||||||
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
NavHost(navController = navController, startDestination = Routes.CAMERA) {
|
||||||
|
composable(Routes.CAMERA) { CameraScreen(vm, navController) }
|
||||||
|
composable(Routes.PAGES) { PagesScreen(vm, navController) }
|
||||||
|
composable(Routes.CROP) { backStack ->
|
||||||
|
CropScreen(
|
||||||
|
vm = vm,
|
||||||
|
navController = navController,
|
||||||
|
pageId = backStack.arguments?.getString("pageId").orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.EXPORT) { ExportScreen(vm, navController) }
|
||||||
|
composable(Routes.SETTINGS) { SettingsScreen(vm, navController) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/src/main/java/eu/geyskens/pdfscan/PaperScanApp.kt
Normal file
15
app/src/main/java/eu/geyskens/pdfscan/PaperScanApp.kt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package eu.geyskens.pdfscan
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import org.opencv.android.OpenCVLoader
|
||||||
|
|
||||||
|
class PaperScanApp : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
// The org.opencv:opencv Maven artifact bundles the native libs; initLocal loads them.
|
||||||
|
if (!OpenCVLoader.initLocal()) {
|
||||||
|
Log.e("PaperScan", "OpenCV native init failed; edge detection will fall back to full-frame.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
147
app/src/main/java/eu/geyskens/pdfscan/ScanViewModel.kt
Normal file
147
app/src/main/java/eu/geyskens/pdfscan/ScanViewModel.kt
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package eu.geyskens.pdfscan
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.PointF
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import eu.geyskens.pdfscan.data.AppSettings
|
||||||
|
import eu.geyskens.pdfscan.data.Page
|
||||||
|
import eu.geyskens.pdfscan.data.ScanFilter
|
||||||
|
import eu.geyskens.pdfscan.data.SettingsRepository
|
||||||
|
import eu.geyskens.pdfscan.paperless.PaperlessClient
|
||||||
|
import eu.geyskens.pdfscan.pdf.PageSizeMode
|
||||||
|
import eu.geyskens.pdfscan.pdf.PdfBuilder
|
||||||
|
import eu.geyskens.pdfscan.scan.BitmapIo
|
||||||
|
import eu.geyskens.pdfscan.scan.EdgeDetector
|
||||||
|
import eu.geyskens.pdfscan.scan.ImageProcessor
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Owns the in-progress scan session: the list of pages, their edits, and export. */
|
||||||
|
class ScanViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
|
private val settingsRepo = SettingsRepository(app)
|
||||||
|
val settings: StateFlow<AppSettings> =
|
||||||
|
settingsRepo.settings.stateIn(viewModelScope, SharingStarted.Eagerly, AppSettings())
|
||||||
|
|
||||||
|
private val _pages = MutableStateFlow<List<Page>>(emptyList())
|
||||||
|
val pages: StateFlow<List<Page>> = _pages.asStateFlow()
|
||||||
|
|
||||||
|
private val capturesDir = File(app.cacheDir, "captures").apply { mkdirs() }
|
||||||
|
private val exportsDir = File(app.cacheDir, "exports").apply { mkdirs() }
|
||||||
|
|
||||||
|
// Rendered-page cache keyed by page id, invalidated when the page's edit hash changes.
|
||||||
|
private val renderCache = HashMap<String, Pair<Int, Bitmap>>()
|
||||||
|
|
||||||
|
fun capturesDir(): File = capturesDir
|
||||||
|
|
||||||
|
/** Called after CameraX writes a capture; runs edge detection and appends a page. */
|
||||||
|
suspend fun onImageCaptured(file: File) = withContext(Dispatchers.Default) {
|
||||||
|
val bitmap = BitmapIo.loadOriented(file)
|
||||||
|
val corners = EdgeDetector.detect(bitmap) ?: Page.fullFrameCorners()
|
||||||
|
bitmap.recycle()
|
||||||
|
val page = Page(
|
||||||
|
sourceFile = file,
|
||||||
|
corners = corners,
|
||||||
|
filter = settings.value.defaultFilter,
|
||||||
|
)
|
||||||
|
_pages.value = _pages.value + page
|
||||||
|
}
|
||||||
|
|
||||||
|
fun page(id: String): Page? = _pages.value.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
/** Loads the raw, EXIF-oriented capture (not perspective-corrected) for the crop overlay. */
|
||||||
|
suspend fun loadSource(page: Page): Bitmap =
|
||||||
|
withContext(Dispatchers.Default) { BitmapIo.loadOriented(page.sourceFile) }
|
||||||
|
|
||||||
|
fun updateCorners(id: String, corners: List<PointF>) = mutate(id) { it.copy(corners = corners) }
|
||||||
|
fun setFilter(id: String, filter: ScanFilter) = mutate(id) { it.copy(filter = filter) }
|
||||||
|
fun rotate(id: String) = mutate(id) { it.copy(rotationDegrees = (it.rotationDegrees + 90) % 360) }
|
||||||
|
fun resetCorners(id: String) = mutate(id) { it.copy(corners = Page.fullFrameCorners()) }
|
||||||
|
|
||||||
|
fun delete(id: String) {
|
||||||
|
_pages.value.firstOrNull { it.id == id }?.sourceFile?.delete()
|
||||||
|
// Don't recycle here: a leaving composition may still be drawing this bitmap.
|
||||||
|
renderCache.remove(id)
|
||||||
|
_pages.value = _pages.value.filterNot { it.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun move(from: Int, to: Int) {
|
||||||
|
val list = _pages.value.toMutableList()
|
||||||
|
if (from in list.indices && to in list.indices) {
|
||||||
|
list.add(to, list.removeAt(from))
|
||||||
|
_pages.value = list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun mutate(id: String, transform: (Page) -> Page) {
|
||||||
|
_pages.value = _pages.value.map { if (it.id == id) transform(it) else it }
|
||||||
|
// Drop the stale render but let GC reclaim it; recycling now can hit a live draw.
|
||||||
|
renderCache.remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a page to a deskewed, filtered bitmap. Cached until the page is edited. */
|
||||||
|
suspend fun render(page: Page): Bitmap = withContext(Dispatchers.Default) {
|
||||||
|
val hash = editHash(page)
|
||||||
|
renderCache[page.id]?.let { (h, bmp) -> if (h == hash && !bmp.isRecycled) return@withContext bmp }
|
||||||
|
val source = BitmapIo.loadOriented(page.sourceFile)
|
||||||
|
val result = ImageProcessor.process(source, page.corners, page.rotationDegrees, page.filter)
|
||||||
|
source.recycle()
|
||||||
|
renderCache[page.id] = hash to result
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun editHash(page: Page): Int =
|
||||||
|
listOf(page.corners, page.rotationDegrees, page.filter, page.sourceFile.path).hashCode()
|
||||||
|
|
||||||
|
/** Builds the multi-page PDF into the shareable exports dir and returns it. */
|
||||||
|
suspend fun exportPdf(fileName: String, mode: PageSizeMode = PageSizeMode.A4): File =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val safe = fileName.ifBlank { "scan" }.replace(Regex("[^A-Za-z0-9-_ ]"), "_")
|
||||||
|
val rendered = _pages.value.map { render(it) }
|
||||||
|
val out = File(exportsDir, "$safe.pdf")
|
||||||
|
PdfBuilder.build(rendered, out, mode)
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun uploadToPaperless(pdf: File, title: String): PaperlessClient.Result {
|
||||||
|
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(s.paperlessUrl, token).upload(pdf, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Settings pass-throughs (SettingsScreen) ---
|
||||||
|
|
||||||
|
fun currentToken(): String? = settingsRepo.getToken()
|
||||||
|
|
||||||
|
suspend fun savePaperless(url: String, token: String) {
|
||||||
|
settingsRepo.setPaperlessUrl(url.trim())
|
||||||
|
settingsRepo.setToken(token.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultFilter(filter: ScanFilter) = settingsRepo.setDefaultFilter(filter)
|
||||||
|
|
||||||
|
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(url.trim(), token.trim()).testConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSession() {
|
||||||
|
_pages.value.forEach { it.sourceFile.delete() }
|
||||||
|
renderCache.values.forEach { it.second.recycle() }
|
||||||
|
renderCache.clear()
|
||||||
|
_pages.value = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/src/main/java/eu/geyskens/pdfscan/data/Page.kt
Normal file
34
app/src/main/java/eu/geyskens/pdfscan/data/Page.kt
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package eu.geyskens.pdfscan.data
|
||||||
|
|
||||||
|
import android.graphics.PointF
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** How a page is rendered before it goes into the PDF. */
|
||||||
|
enum class ScanFilter { COLOR, GRAYSCALE, BLACK_WHITE }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single scanned page.
|
||||||
|
*
|
||||||
|
* [corners] are the four document corners in the coordinate space of the source
|
||||||
|
* bitmap, normalized to 0..1, ordered top-left, top-right, bottom-right, bottom-left.
|
||||||
|
* Storing them normalized keeps them valid regardless of any downscaling we do for
|
||||||
|
* on-screen preview versus full-resolution export.
|
||||||
|
*/
|
||||||
|
data class Page(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val sourceFile: File,
|
||||||
|
val corners: List<PointF>,
|
||||||
|
val rotationDegrees: Int = 0,
|
||||||
|
val filter: ScanFilter = ScanFilter.COLOR,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** Corners covering the whole frame — the fallback when detection finds nothing. */
|
||||||
|
fun fullFrameCorners(): List<PointF> = listOf(
|
||||||
|
PointF(0f, 0f),
|
||||||
|
PointF(1f, 0f),
|
||||||
|
PointF(1f, 1f),
|
||||||
|
PointF(0f, 1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package eu.geyskens.pdfscan.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
data class AppSettings(
|
||||||
|
val paperlessUrl: String = "",
|
||||||
|
val hasToken: Boolean = false,
|
||||||
|
val defaultFilter: ScanFilter = ScanFilter.COLOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists app settings. Non-secret values live in DataStore; the Paperless API token
|
||||||
|
* is kept in an AES-256 EncryptedSharedPreferences file backed by the Android Keystore,
|
||||||
|
* so it is never stored in plaintext on the device.
|
||||||
|
*/
|
||||||
|
class SettingsRepository(private val context: Context) {
|
||||||
|
|
||||||
|
private object Keys {
|
||||||
|
val URL = stringPreferencesKey("paperless_url")
|
||||||
|
val FILTER = stringPreferencesKey("default_filter")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val securePrefs by lazy {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"secure_settings",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val settings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||||
|
AppSettings(
|
||||||
|
paperlessUrl = prefs[Keys.URL].orEmpty(),
|
||||||
|
hasToken = !getToken().isNullOrBlank(),
|
||||||
|
defaultFilter = prefs[Keys.FILTER]?.let { runCatching { ScanFilter.valueOf(it) }.getOrNull() }
|
||||||
|
?: ScanFilter.COLOR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getToken(): String? = securePrefs.getString("paperless_token", null)
|
||||||
|
|
||||||
|
fun setToken(token: String) {
|
||||||
|
securePrefs.edit().putString("paperless_token", token).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setPaperlessUrl(url: String) {
|
||||||
|
context.dataStore.edit { it[Keys.URL] = url }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultFilter(filter: ScanFilter) {
|
||||||
|
context.dataStore.edit { it[Keys.FILTER] = filter.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package eu.geyskens.pdfscan.paperless
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Paperless-ngx REST client. Talks only to the user-configured, self-hosted
|
||||||
|
* instance. The token is sent as an `Authorization: Token …` header over the connection
|
||||||
|
* the user set up (HTTPS strongly recommended — see SettingsScreen guidance).
|
||||||
|
*/
|
||||||
|
class PaperlessClient(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val token: String,
|
||||||
|
) {
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val root = baseUrl.trim().trimEnd('/')
|
||||||
|
|
||||||
|
sealed interface Result {
|
||||||
|
data object Success : Result
|
||||||
|
data class Failure(val message: String) : Result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verifies the URL + token by hitting an authenticated endpoint. */
|
||||||
|
suspend fun testConnection(): Result = withContext(Dispatchers.IO) {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$root/api/documents/?page_size=1")
|
||||||
|
.header("Authorization", "Token $token")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
runCatching {
|
||||||
|
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}.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrElse { Result.Failure(it.message ?: "Verbinding mislukt.") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uploads a PDF to the Paperless consume/inbox pipeline. */
|
||||||
|
suspend fun upload(pdf: File, title: String): Result = withContext(Dispatchers.IO) {
|
||||||
|
val body = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart("title", title)
|
||||||
|
.addFormDataPart(
|
||||||
|
"document", "$title.pdf",
|
||||||
|
pdf.asRequestBody("application/pdf".toMediaType())
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$root/api/documents/post_document/")
|
||||||
|
.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}")
|
||||||
|
}
|
||||||
|
}.getOrElse { Result.Failure(it.message ?: "Upload mislukt.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/src/main/java/eu/geyskens/pdfscan/pdf/PdfBuilder.kt
Normal file
60
app/src/main/java/eu/geyskens/pdfscan/pdf/PdfBuilder.kt
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package eu.geyskens.pdfscan.pdf
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.pdf.PdfDocument
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
/** How each PDF page is sized relative to its scanned image. */
|
||||||
|
enum class PageSizeMode { A4, ORIGINAL }
|
||||||
|
|
||||||
|
object PdfBuilder {
|
||||||
|
|
||||||
|
// A4 in PostScript points (1/72"): 210 x 297 mm.
|
||||||
|
private const val A4_WIDTH_PT = 595
|
||||||
|
private const val A4_HEIGHT_PT = 842
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes [pages] into a single multi-page PDF at [output].
|
||||||
|
* With [PageSizeMode.A4] each image is centered and scaled to fit an A4 page
|
||||||
|
* (portrait or landscape chosen per image); with ORIGINAL the page matches the image.
|
||||||
|
*/
|
||||||
|
fun build(pages: List<Bitmap>, output: File, mode: PageSizeMode = PageSizeMode.A4) {
|
||||||
|
require(pages.isNotEmpty()) { "Cannot build a PDF with no pages." }
|
||||||
|
val doc = PdfDocument()
|
||||||
|
val paint = Paint(Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG)
|
||||||
|
try {
|
||||||
|
pages.forEachIndexed { index, bitmap ->
|
||||||
|
val (pageW, pageH) = when (mode) {
|
||||||
|
PageSizeMode.ORIGINAL -> bitmap.width to bitmap.height
|
||||||
|
PageSizeMode.A4 ->
|
||||||
|
if (bitmap.width >= bitmap.height) A4_HEIGHT_PT to A4_WIDTH_PT // landscape
|
||||||
|
else A4_WIDTH_PT to A4_HEIGHT_PT
|
||||||
|
}
|
||||||
|
val info = PdfDocument.PageInfo.Builder(pageW, pageH, index + 1).create()
|
||||||
|
val page = doc.startPage(info)
|
||||||
|
val canvas = page.canvas
|
||||||
|
canvas.drawColor(Color.WHITE)
|
||||||
|
canvas.drawBitmap(bitmap, null, fitRect(bitmap, pageW, pageH), paint)
|
||||||
|
doc.finishPage(page)
|
||||||
|
}
|
||||||
|
FileOutputStream(output).use { doc.writeTo(it) }
|
||||||
|
} finally {
|
||||||
|
doc.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Largest centered rect inside the page that preserves the bitmap's aspect ratio. */
|
||||||
|
private fun fitRect(bitmap: Bitmap, pageW: Int, pageH: Int): Rect {
|
||||||
|
val scale = minOf(pageW.toFloat() / bitmap.width, pageH.toFloat() / bitmap.height)
|
||||||
|
val w = (bitmap.width * scale).toInt()
|
||||||
|
val h = (bitmap.height * scale).toInt()
|
||||||
|
val left = (pageW - w) / 2
|
||||||
|
val top = (pageH - h) / 2
|
||||||
|
return Rect(left, top, left + w, top + h)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/src/main/java/eu/geyskens/pdfscan/scan/BitmapIo.kt
Normal file
45
app/src/main/java/eu/geyskens/pdfscan/scan/BitmapIo.kt
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package eu.geyskens.pdfscan.scan
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Loads captured JPEGs, honoring EXIF orientation and capping resolution for memory safety. */
|
||||||
|
object BitmapIo {
|
||||||
|
|
||||||
|
private const val MAX_DIMENSION = 2600
|
||||||
|
|
||||||
|
fun loadOriented(file: File): Bitmap {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||||
|
|
||||||
|
var sample = 1
|
||||||
|
val longest = maxOf(bounds.outWidth, bounds.outHeight)
|
||||||
|
while (longest / sample > MAX_DIMENSION) sample *= 2
|
||||||
|
|
||||||
|
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||||
|
val bitmap = BitmapFactory.decodeFile(file.absolutePath, opts)
|
||||||
|
?: error("Kon afbeelding niet decoderen: ${file.name}")
|
||||||
|
|
||||||
|
val orientation = ExifInterface(file.absolutePath)
|
||||||
|
.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||||
|
return applyExifOrientation(bitmap, orientation)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
|
||||||
|
val matrix = Matrix()
|
||||||
|
when (orientation) {
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
|
||||||
|
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
|
||||||
|
else -> return bitmap
|
||||||
|
}
|
||||||
|
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||||
|
if (rotated != bitmap) bitmap.recycle()
|
||||||
|
return rotated
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/src/main/java/eu/geyskens/pdfscan/scan/EdgeDetector.kt
Normal file
81
app/src/main/java/eu/geyskens/pdfscan/scan/EdgeDetector.kt
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package eu.geyskens.pdfscan.scan
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.PointF
|
||||||
|
import org.opencv.android.Utils
|
||||||
|
import org.opencv.core.Mat
|
||||||
|
import org.opencv.core.MatOfPoint
|
||||||
|
import org.opencv.core.MatOfPoint2f
|
||||||
|
import org.opencv.core.Size
|
||||||
|
import org.opencv.imgproc.Imgproc
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the largest document-like quadrilateral in an image using OpenCV.
|
||||||
|
* All heavy work runs on a downscaled copy; corners are returned normalized
|
||||||
|
* to 0..1 so callers can map them onto any resolution.
|
||||||
|
*/
|
||||||
|
object EdgeDetector {
|
||||||
|
|
||||||
|
private const val PROCESS_WIDTH = 500.0
|
||||||
|
|
||||||
|
/** @return four normalized corners (TL, TR, BR, BL), or null if nothing convincing was found. */
|
||||||
|
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)
|
||||||
|
|
||||||
|
val edges = Mat()
|
||||||
|
Imgproc.Canny(gray, 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)
|
||||||
|
|
||||||
|
val contours = ArrayList<MatOfPoint>()
|
||||||
|
Imgproc.findContours(
|
||||||
|
edges, contours, Mat(),
|
||||||
|
Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val frameArea = work.rows() * work.cols()
|
||||||
|
var best: List<PointF>? = null
|
||||||
|
var bestArea = frameArea * 0.15 // ignore quads smaller than 15% of the frame
|
||||||
|
|
||||||
|
for (contour in contours) {
|
||||||
|
val c2f = MatOfPoint2f(*contour.toArray())
|
||||||
|
val peri = Imgproc.arcLength(c2f, true)
|
||||||
|
val approx = MatOfPoint2f()
|
||||||
|
Imgproc.approxPolyDP(c2f, approx, 0.02 * peri, true)
|
||||||
|
|
||||||
|
if (approx.total() == 4L && Imgproc.isContourConvex(MatOfPoint(*approx.toArray()))) {
|
||||||
|
val area = Imgproc.contourArea(approx)
|
||||||
|
if (area > bestArea) {
|
||||||
|
bestArea = area
|
||||||
|
best = orderCorners(approx.toArray().map {
|
||||||
|
PointF((it.x / work.cols()).toFloat(), (it.y / work.rows()).toFloat())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
} finally {
|
||||||
|
src.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort four points into TL, TR, BR, BL by their x+y / x-y sums. */
|
||||||
|
fun orderCorners(points: List<PointF>): List<PointF> {
|
||||||
|
require(points.size == 4)
|
||||||
|
val tl = points.minByOrNull { it.x + it.y }!!
|
||||||
|
val br = points.maxByOrNull { it.x + it.y }!!
|
||||||
|
val tr = points.maxByOrNull { it.x - it.y }!!
|
||||||
|
val bl = points.minByOrNull { it.x - it.y }!!
|
||||||
|
return listOf(tl, tr, br, bl)
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/src/main/java/eu/geyskens/pdfscan/scan/ImageProcessor.kt
Normal file
105
app/src/main/java/eu/geyskens/pdfscan/scan/ImageProcessor.kt
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package eu.geyskens.pdfscan.scan
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.PointF
|
||||||
|
import eu.geyskens.pdfscan.data.ScanFilter
|
||||||
|
import org.opencv.android.Utils
|
||||||
|
import org.opencv.core.Mat
|
||||||
|
import org.opencv.core.MatOfPoint2f
|
||||||
|
import org.opencv.core.Point
|
||||||
|
import org.opencv.core.Size
|
||||||
|
import org.opencv.imgproc.Imgproc
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a raw capture into a clean, deskewed page: perspective-corrects to the
|
||||||
|
* given corners, rotates, then applies the chosen look. Fully on-device.
|
||||||
|
*/
|
||||||
|
object ImageProcessor {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param corners normalized (0..1) TL, TR, BR, BL in [source] space.
|
||||||
|
* @param rotationDegrees multiple of 90 applied after warping.
|
||||||
|
*/
|
||||||
|
fun process(
|
||||||
|
source: Bitmap,
|
||||||
|
corners: List<PointF>,
|
||||||
|
rotationDegrees: Int,
|
||||||
|
filter: ScanFilter,
|
||||||
|
): Bitmap {
|
||||||
|
val src = Mat()
|
||||||
|
Utils.bitmapToMat(source, src)
|
||||||
|
try {
|
||||||
|
val w = src.cols().toDouble()
|
||||||
|
val h = src.rows().toDouble()
|
||||||
|
val pts = corners.map { Point(it.x * w, it.y * h) }
|
||||||
|
val (tl, tr, br, bl) = pts
|
||||||
|
|
||||||
|
val widthTop = hypot(tr.x - tl.x, tr.y - tl.y)
|
||||||
|
val widthBottom = hypot(br.x - bl.x, br.y - bl.y)
|
||||||
|
val heightLeft = hypot(bl.x - tl.x, bl.y - tl.y)
|
||||||
|
val heightRight = hypot(br.x - tr.x, br.y - tr.y)
|
||||||
|
val outW = max(widthTop, widthBottom).toInt().coerceAtLeast(1)
|
||||||
|
val outH = max(heightLeft, heightRight).toInt().coerceAtLeast(1)
|
||||||
|
|
||||||
|
val srcQuad = MatOfPoint2f(tl, tr, br, bl)
|
||||||
|
val dstQuad = MatOfPoint2f(
|
||||||
|
Point(0.0, 0.0),
|
||||||
|
Point(outW - 1.0, 0.0),
|
||||||
|
Point(outW - 1.0, outH - 1.0),
|
||||||
|
Point(0.0, outH - 1.0),
|
||||||
|
)
|
||||||
|
val transform = Imgproc.getPerspectiveTransform(srcQuad, dstQuad)
|
||||||
|
val warped = Mat()
|
||||||
|
Imgproc.warpPerspective(src, warped, transform, Size(outW.toDouble(), outH.toDouble()))
|
||||||
|
|
||||||
|
applyFilter(warped, filter)
|
||||||
|
rotate(warped, rotationDegrees)
|
||||||
|
|
||||||
|
val out = Bitmap.createBitmap(warped.cols(), warped.rows(), Bitmap.Config.ARGB_8888)
|
||||||
|
Utils.matToBitmap(warped, out)
|
||||||
|
warped.release()
|
||||||
|
return out
|
||||||
|
} finally {
|
||||||
|
src.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFilter(mat: Mat, filter: ScanFilter) {
|
||||||
|
when (filter) {
|
||||||
|
ScanFilter.COLOR -> {
|
||||||
|
// Gentle contrast/brightness lift so paper reads as white.
|
||||||
|
mat.convertTo(mat, -1, 1.15, 8.0)
|
||||||
|
}
|
||||||
|
ScanFilter.GRAYSCALE -> {
|
||||||
|
val gray = Mat()
|
||||||
|
Imgproc.cvtColor(mat, gray, Imgproc.COLOR_RGBA2GRAY)
|
||||||
|
Imgproc.cvtColor(gray, mat, Imgproc.COLOR_GRAY2RGBA)
|
||||||
|
gray.release()
|
||||||
|
}
|
||||||
|
ScanFilter.BLACK_WHITE -> {
|
||||||
|
val gray = Mat()
|
||||||
|
Imgproc.cvtColor(mat, gray, Imgproc.COLOR_RGBA2GRAY)
|
||||||
|
val bw = Mat()
|
||||||
|
Imgproc.adaptiveThreshold(
|
||||||
|
gray, bw, 255.0,
|
||||||
|
Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, Imgproc.THRESH_BINARY,
|
||||||
|
15, 10.0
|
||||||
|
)
|
||||||
|
Imgproc.cvtColor(bw, mat, Imgproc.COLOR_GRAY2RGBA)
|
||||||
|
gray.release()
|
||||||
|
bw.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rotate(mat: Mat, degrees: Int) {
|
||||||
|
when (((degrees % 360) + 360) % 360) {
|
||||||
|
90 -> org.opencv.core.Core.rotate(mat, mat, org.opencv.core.Core.ROTATE_90_CLOCKWISE)
|
||||||
|
180 -> org.opencv.core.Core.rotate(mat, mat, org.opencv.core.Core.ROTATE_180)
|
||||||
|
270 -> org.opencv.core.Core.rotate(mat, mat, org.opencv.core.Core.ROTATE_90_COUNTERCLOCKWISE)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
188
app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt
Normal file
188
app/src/main/java/eu/geyskens/pdfscan/ui/screens/CameraScreen.kt
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package eu.geyskens.pdfscan.ui.screens
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageCapture
|
||||||
|
import androidx.camera.core.ImageCaptureException
|
||||||
|
import androidx.camera.core.Preview
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.camera.view.PreviewView
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.platform.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.concurrent.futures.await
|
||||||
|
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.Routes
|
||||||
|
import eu.geyskens.pdfscan.ScanViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@Composable
|
||||||
|
fun CameraScreen(vm: ScanViewModel, navController: androidx.navigation.NavController) {
|
||||||
|
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||||
|
if (cameraPermission.status.isGranted) {
|
||||||
|
CameraContent(vm, navController)
|
||||||
|
} else {
|
||||||
|
PermissionRequest(onRequest = { cameraPermission.launchPermissionRequest() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
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.",
|
||||||
|
color = Color.White,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
androidx.compose.foundation.layout.Spacer(Modifier.size(16.dp))
|
||||||
|
Button(onClick = onRequest) { Text("Toegang geven") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CameraContent(vm: ScanViewModel, navController: androidx.navigation.NavController) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val pages by vm.pages.collectAsState()
|
||||||
|
|
||||||
|
val executor = remember { Executors.newSingleThreadExecutor() }
|
||||||
|
val imageCapture = remember {
|
||||||
|
ImageCapture.Builder()
|
||||||
|
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
var capturing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val previewView = remember {
|
||||||
|
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||||
|
val provider = ProcessCameraProvider.getInstance(context).await()
|
||||||
|
val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) }
|
||||||
|
provider.unbindAll()
|
||||||
|
provider.bindToLifecycle(
|
||||||
|
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageCapture
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
androidx.compose.ui.viewinterop.AndroidView(
|
||||||
|
factory = { previewView },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Top bar: settings.
|
||||||
|
IconButton(
|
||||||
|
onClick = { navController.navigate(Routes.SETTINGS) },
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Settings, contentDescription = "Instellingen", tint = Color.White)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom controls.
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(Color(0x66000000))
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Page count.
|
||||||
|
Box(Modifier.size(56.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text("${pages.size}", color = Color.White, style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutter.
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = {
|
||||||
|
if (capturing) return@FilledIconButton
|
||||||
|
capturing = true
|
||||||
|
val file = File(vm.capturesDir(), "cap_${System.currentTimeMillis()}.jpg")
|
||||||
|
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||||
|
imageCapture.takePicture(
|
||||||
|
options,
|
||||||
|
ContextCompat.getMainExecutor(context),
|
||||||
|
object : ImageCapture.OnImageSavedCallback {
|
||||||
|
override fun onImageSaved(result: ImageCapture.OutputFileResults) {
|
||||||
|
scope.launch {
|
||||||
|
vm.onImageCaptured(file)
|
||||||
|
capturing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(exception: ImageCaptureException) {
|
||||||
|
capturing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(76.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
) {
|
||||||
|
if (capturing) CircularProgressIndicator(Modifier.size(28.dp), color = Color.White)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done -> pages.
|
||||||
|
IconButton(
|
||||||
|
onClick = { if (pages.isNotEmpty()) navController.navigate(Routes.PAGES) },
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Check,
|
||||||
|
contentDescription = "Klaar",
|
||||||
|
tint = if (pages.isEmpty()) Color.Gray else Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.runtime.DisposableEffect(Unit) {
|
||||||
|
onDispose { executor.shutdown() }
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/src/main/java/eu/geyskens/pdfscan/ui/screens/Common.kt
Normal file
23
app/src/main/java/eu/geyskens/pdfscan/ui/screens/Common.kt
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package eu.geyskens.pdfscan.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import eu.geyskens.pdfscan.ScanViewModel
|
||||||
|
import eu.geyskens.pdfscan.data.Page
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders [page] to a deskewed, filtered bitmap off the main thread and exposes it as
|
||||||
|
* Compose state. Re-renders whenever the page's corners, rotation or filter change.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberRendered(vm: ScanViewModel, page: Page): State<Bitmap?> {
|
||||||
|
val state = remember(page.id) { mutableStateOf<Bitmap?>(null) }
|
||||||
|
LaunchedEffect(page.id, page.corners, page.rotationDegrees, page.filter) {
|
||||||
|
state.value = vm.render(page)
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
165
app/src/main/java/eu/geyskens/pdfscan/ui/screens/CropScreen.kt
Normal file
165
app/src/main/java/eu/geyskens/pdfscan/ui/screens/CropScreen.kt
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package eu.geyskens.pdfscan.ui.screens
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.PointF
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.graphics.asImageBitmap
|
||||||
|
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.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import eu.geyskens.pdfscan.ScanViewModel
|
||||||
|
import eu.geyskens.pdfscan.data.ScanFilter
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.ui.geometry.Offset as GeomOffset
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CropScreen(vm: ScanViewModel, navController: NavController, pageId: String) {
|
||||||
|
val page = remember(pageId) { vm.page(pageId) }
|
||||||
|
if (page == null) {
|
||||||
|
navController.popBackStack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var source by remember(pageId) { mutableStateOf<Bitmap?>(null) }
|
||||||
|
androidx.compose.runtime.LaunchedEffect(pageId) { source = vm.loadSource(page) }
|
||||||
|
|
||||||
|
// Local editable copy of the four normalized corners (TL, TR, BR, BL).
|
||||||
|
val corners = remember(pageId) {
|
||||||
|
page.corners.map { mutableStateOf(PointF(it.x, it.y)) }.toMutableList()
|
||||||
|
}
|
||||||
|
var filter by remember(pageId) { mutableStateOf(page.filter) }
|
||||||
|
var boxSize by remember { mutableStateOf(IntSize.Zero) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().background(Color.Black)) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onSizeChanged { boxSize = it },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val bmp = source
|
||||||
|
if (bmp != null && boxSize != IntSize.Zero) {
|
||||||
|
val scale = minOf(
|
||||||
|
boxSize.width.toFloat() / bmp.width,
|
||||||
|
boxSize.height.toFloat() / bmp.height,
|
||||||
|
)
|
||||||
|
val dispW = bmp.width * scale
|
||||||
|
val dispH = bmp.height * scale
|
||||||
|
val offX = (boxSize.width - dispW) / 2f
|
||||||
|
val offY = (boxSize.height - dispH) / 2f
|
||||||
|
|
||||||
|
fun toScreen(p: PointF) = GeomOffset(offX + p.x * dispW, offY + p.y * dispH)
|
||||||
|
|
||||||
|
Image(
|
||||||
|
bitmap = bmp.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Quad outline.
|
||||||
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
|
val pts = corners.map { toScreen(it.value) }
|
||||||
|
for (i in pts.indices) {
|
||||||
|
val a = pts[i]
|
||||||
|
val b = pts[(i + 1) % pts.size]
|
||||||
|
drawLine(Color(0xFF388BFD), a, b, strokeWidth = 4f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draggable handles.
|
||||||
|
corners.forEachIndexed { i, cornerState ->
|
||||||
|
val screen = toScreen(cornerState.value)
|
||||||
|
val handlePx = with(density) { 32.dp.toPx() }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(
|
||||||
|
x = with(density) { (screen.x - handlePx / 2).toDp() },
|
||||||
|
y = with(density) { (screen.y - handlePx / 2).toDp() },
|
||||||
|
)
|
||||||
|
.size(32.dp)
|
||||||
|
.background(Color(0xAA388BFD), CircleShape)
|
||||||
|
.pointerInput(pageId, i, dispW, dispH) {
|
||||||
|
detectDragGestures { change, drag ->
|
||||||
|
change.consume()
|
||||||
|
val cur = cornerState.value
|
||||||
|
val nx = (cur.x + drag.x / dispW).coerceIn(0f, 1f)
|
||||||
|
val ny = (cur.y + drag.y / dispH).coerceIn(0f, 1f)
|
||||||
|
cornerState.value = PointF(nx, ny)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter chips.
|
||||||
|
Row(
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = { vm.rotate(pageId) }, modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Draaien")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
val full = eu.geyskens.pdfscan.data.Page.fullFrameCorners()
|
||||||
|
full.forEachIndexed { i, p -> corners[i].value = PointF(p.x, p.y) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) { Text("Herstel") }
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
vm.updateCorners(pageId, corners.map { PointF(it.value.x, it.value.y) })
|
||||||
|
vm.setFilter(pageId, filter)
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) { Text("Toepassen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterOption(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
|
||||||
|
}
|
||||||
174
app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt
Normal file
174
app/src/main/java/eu/geyskens/pdfscan/ui/screens/ExportScreen.kt
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package eu.geyskens.pdfscan.ui.screens
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material.icons.filled.CloudUpload
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.unit.dp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
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.launch
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ExportScreen(vm: ScanViewModel, navController: NavController) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val settings by vm.settings.collectAsState()
|
||||||
|
val pages by vm.pages.collectAsState()
|
||||||
|
|
||||||
|
val defaultName = remember {
|
||||||
|
"scan_" + SimpleDateFormat("yyyyMMdd_HHmm", Locale.getDefault()).format(Date())
|
||||||
|
}
|
||||||
|
var fileName by remember { mutableStateOf(defaultName) }
|
||||||
|
var a4 by remember { mutableStateOf(true) }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
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)") }) },
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(padding).padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = fileName,
|
||||||
|
onValueChange = { fileName = it },
|
||||||
|
label = { Text("Bestandsnaam") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||||
|
SegmentedButton(
|
||||||
|
selected = a4,
|
||||||
|
onClick = { a4 = true },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(0, 2),
|
||||||
|
) { Text("A4-pagina") }
|
||||||
|
SegmentedButton(
|
||||||
|
selected = !a4,
|
||||||
|
onClick = { a4 = false },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(1, 2),
|
||||||
|
) { Text("Originele grootte") }
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (busy) return@Button
|
||||||
|
busy = true
|
||||||
|
status = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val pdf = vm.exportPdf(fileName, mode())
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context, "${context.packageName}.fileprovider", pdf
|
||||||
|
)
|
||||||
|
val share = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "application/pdf"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(Intent.createChooser(share, "PDF delen"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
status = "Fout bij exporteren: ${e.message}"
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Share, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Delen / opslaan")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (busy) return@Button
|
||||||
|
busy = true
|
||||||
|
status = null
|
||||||
|
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 ✓"
|
||||||
|
is PaperlessClient.Result.Failure ->
|
||||||
|
status = r.message
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
status = "Fout: ${e.message}"
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !busy && settings.paperlessUrl.isNotBlank() && settings.hasToken,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.CloudUpload, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Upload naar Paperless")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.paperlessUrl.isNotBlank() || !settings.hasToken) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { navController.navigate(Routes.SETTINGS) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Paperless instellen") }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (busy) {
|
||||||
|
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(Modifier.height(24.dp))
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text("Bezig…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status?.let { Text(it, color = Color(0xFF1F6FEB)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
app/src/main/java/eu/geyskens/pdfscan/ui/screens/PagesScreen.kt
Normal file
145
app/src/main/java/eu/geyskens/pdfscan/ui/screens/PagesScreen.kt
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package eu.geyskens.pdfscan.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.PictureAsPdf
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
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.unit.dp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import eu.geyskens.pdfscan.Routes
|
||||||
|
import eu.geyskens.pdfscan.ScanViewModel
|
||||||
|
import eu.geyskens.pdfscan.data.Page
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PagesScreen(vm: ScanViewModel, navController: NavController) {
|
||||||
|
val pages by vm.pages.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Pagina's (${pages.size})") },
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { if (pages.isNotEmpty()) navController.navigate(Routes.EXPORT) },
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.PictureAsPdf, contentDescription = "Exporteer PDF")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { navController.navigate(Routes.CAMERA) },
|
||||||
|
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||||
|
text = { Text("Pagina toevoegen") },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 12.dp),
|
||||||
|
) {
|
||||||
|
items(pages, key = { it.id }) { page ->
|
||||||
|
val index = pages.indexOf(page)
|
||||||
|
PageRow(
|
||||||
|
vm = vm,
|
||||||
|
page = page,
|
||||||
|
index = index,
|
||||||
|
isFirst = index == 0,
|
||||||
|
isLast = index == pages.lastIndex,
|
||||||
|
onOpen = { navController.navigate(Routes.crop(page.id)) },
|
||||||
|
onDelete = { vm.delete(page.id) },
|
||||||
|
onUp = { vm.move(index, index - 1) },
|
||||||
|
onDown = { vm.move(index, index + 1) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PageRow(
|
||||||
|
vm: ScanViewModel,
|
||||||
|
page: Page,
|
||||||
|
index: Int,
|
||||||
|
isFirst: Boolean,
|
||||||
|
isLast: Boolean,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
onUp: () -> Unit,
|
||||||
|
onDown: () -> Unit,
|
||||||
|
) {
|
||||||
|
val bitmap by rememberRendered(vm, page)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 6.dp)
|
||||||
|
.clickable(onClick = onOpen),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 80.dp, height = 100.dp)
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.background(Color(0xFFE0E0E0)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
bitmap?.let {
|
||||||
|
Image(
|
||||||
|
bitmap = it.asImageBitmap(),
|
||||||
|
contentDescription = "Pagina ${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))
|
||||||
|
Column {
|
||||||
|
IconButton(onClick = onUp, enabled = !isFirst) {
|
||||||
|
Icon(Icons.Filled.ArrowUpward, contentDescription = "Omhoog")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDown, enabled = !isLast) {
|
||||||
|
Icon(Icons.Filled.ArrowDownward, contentDescription = "Omlaag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(Icons.Filled.Delete, contentDescription = "Verwijderen", tint = Color(0xFFD32F2F))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package eu.geyskens.pdfscan.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import eu.geyskens.pdfscan.ScanViewModel
|
||||||
|
import eu.geyskens.pdfscan.data.ScanFilter
|
||||||
|
import eu.geyskens.pdfscan.paperless.PaperlessClient
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(vm: ScanViewModel, navController: NavController) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val settings by vm.settings.collectAsState()
|
||||||
|
|
||||||
|
var url by remember { mutableStateOf(settings.paperlessUrl) }
|
||||||
|
var token by remember { mutableStateOf(vm.currentToken().orEmpty()) }
|
||||||
|
var testing by remember { mutableStateOf(false) }
|
||||||
|
var testResult by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Instellingen") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Terug")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text("Paperless-ngx", style = androidx.compose.material3.MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = url,
|
||||||
|
onValueChange = { url = it; testResult = null },
|
||||||
|
label = { Text("Server-URL (bv. https://paperless.thuis.lan)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = token,
|
||||||
|
onValueChange = { token = it; testResult = null },
|
||||||
|
label = { Text("API-token") },
|
||||||
|
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.",
|
||||||
|
color = Color(0xFFB26A00),
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
testing = true
|
||||||
|
testResult = null
|
||||||
|
scope.launch {
|
||||||
|
testResult = when (val r = vm.testPaperless(url, token)) {
|
||||||
|
is PaperlessClient.Result.Success -> "Verbinding OK ✓"
|
||||||
|
is PaperlessClient.Result.Failure -> r.message
|
||||||
|
}
|
||||||
|
testing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !testing,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) { Text("Verbinding testen") }
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { scope.launch { vm.savePaperless(url, token) } },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) { Text("Opslaan") }
|
||||||
|
}
|
||||||
|
if (testing) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(Modifier.width(20.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Testen…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
testResult?.let { Text(it, color = Color(0xFF1F6FEB)) }
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Standaardfilter",
|
||||||
|
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") },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = settings.defaultFilter == ScanFilter.GRAYSCALE,
|
||||||
|
onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.GRAYSCALE) } },
|
||||||
|
label = { Text("Grijs") },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = settings.defaultFilter == ScanFilter.BLACK_WHITE,
|
||||||
|
onClick = { scope.launch { vm.setDefaultFilter(ScanFilter.BLACK_WHITE) } },
|
||||||
|
label = { Text("Zwart-wit") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/src/main/java/eu/geyskens/pdfscan/ui/theme/Theme.kt
Normal file
25
app/src/main/java/eu/geyskens/pdfscan/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package eu.geyskens.pdfscan.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
private val Blue = Color(0xFF1F6FEB)
|
||||||
|
private val BlueDark = Color(0xFF388BFD)
|
||||||
|
|
||||||
|
private val LightColors = lightColorScheme(primary = Blue, secondary = Blue)
|
||||||
|
private val DarkColors = darkColorScheme(primary = BlueDark, secondary = BlueDark)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PaperScanTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
10
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
10
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<!-- Simple document-with-corners glyph, centered in the adaptive safe zone. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M38,34h32a4,4 0 0 1 4,4v32a4,4 0 0 1 -4,4h-32a4,4 0 0 1 -4,-4v-32a4,4 0 0 1 4,-4zM40,40v6h4v-2h2v-4zM68,40h-6v4h2v2h4zM40,68h6v-4h-2v-2h-4zM68,68v-6h-4v2h-2v4z" />
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
6
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#1F6FEB</color>
|
||||||
|
</resources>
|
||||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">PaperScan</string>
|
||||||
|
</resources>
|
||||||
7
app/src/main/res/values/themes.xml
Normal file
7
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.PaperScan" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/black</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/black</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
5
app/src/main/res/xml/file_paths.xml
Normal file
5
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<!-- Exported PDFs live in the app cache and are shared via FileProvider. -->
|
||||||
|
<cache-path name="shared_pdfs" path="exports/" />
|
||||||
|
</paths>
|
||||||
6
build.gradle.kts
Normal file
6
build.gradle.kts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Top-level build file. Plugin versions declared here, applied per-module.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
7
gradle.properties
Normal file
7
gradle.properties
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx3072m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
50
gradle/libs.versions.toml
Normal file
50
gradle/libs.versions.toml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.7.3"
|
||||||
|
kotlin = "2.1.0"
|
||||||
|
coreKtx = "1.15.0"
|
||||||
|
lifecycle = "2.8.7"
|
||||||
|
activityCompose = "1.9.3"
|
||||||
|
composeBom = "2024.12.01"
|
||||||
|
navigationCompose = "2.8.5"
|
||||||
|
camerax = "1.4.1"
|
||||||
|
opencv = "4.11.0"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
datastore = "1.1.1"
|
||||||
|
securityCrypto = "1.1.0-alpha06"
|
||||||
|
accompanistPermissions = "0.36.0"
|
||||||
|
exifinterface = "1.3.7"
|
||||||
|
concurrentFutures = "1.2.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
|
||||||
|
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
|
||||||
|
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
|
||||||
|
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
|
||||||
|
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
|
||||||
|
|
||||||
|
opencv = { group = "org.opencv", name = "opencv", version.ref = "opencv" }
|
||||||
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
|
||||||
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||||
|
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" }
|
||||||
|
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
|
||||||
|
androidx-concurrent-futures-ktx = { group = "androidx.concurrent", name = "concurrent-futures-ktx", version.ref = "concurrentFutures" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
5
keystore.properties.example
Normal file
5
keystore.properties.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Kopieer naar keystore.properties (staat in .gitignore) voor lokale release-builds.
|
||||||
|
storeFile=/absoluut/pad/naar/release.jks
|
||||||
|
storePassword=CHANGEME
|
||||||
|
keyAlias=paperscan
|
||||||
|
keyPassword=CHANGEME
|
||||||
24
settings.gradle.kts
Normal file
24
settings.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "PaperScan"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user