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