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:
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user