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:
2026-07-12 22:43:09 +02:00
commit 037550afa9
37 changed files with 2051 additions and 0 deletions

View 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)"
]
}
}

View 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
View 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
View 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
View 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
View 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.** { *; }

View 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>

View 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) }
}
}
}
}
}
}

View 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.")
}
}
}

View 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()
}
}

View 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),
)
}
}

View File

@@ -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 }
}
}

View File

@@ -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.") }
}
}

View 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)
}
}

View 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
}
}

View 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)
}
}

View 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 -> {}
}
}
}

View 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() }
}
}

View 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
}

View 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) })
}

View 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)) }
}
}
}

View 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))
}
}
}

View File

@@ -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") },
)
}
}
}
}

View 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,
)
}

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1F6FEB</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PaperScan</string>
</resources>

View 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>

View 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
View 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
View 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
View 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" }

View 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

View 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
View 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")