All checks were successful
Build APK / build (push) Successful in 5m55s
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>
152 lines
6.4 KiB
Kotlin
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()
|
|
}
|
|
}
|