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

Fixes #5

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:37:46 +02:00

152 lines
6.4 KiB
Kotlin

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(
getApplication<Application>().getString(R.string.err_paperless_not_setup)
)
}
return PaperlessClient(s.paperlessUrl, token, getApplication<Application>()).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(
getApplication<Application>().getString(R.string.err_enter_url_token)
)
}
return PaperlessClient(url.trim(), token.trim(), getApplication<Application>()).testConnection()
}
fun clearSession() {
_pages.value.forEach { it.sourceFile.delete() }
renderCache.values.forEach { it.second.recycle() }
renderCache.clear()
_pages.value = emptyList()
}
}