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 = settingsRepo.settings.stateIn(viewModelScope, SharingStarted.Eagerly, AppSettings()) private val _pages = MutableStateFlow>(emptyList()) val pages: StateFlow> = _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>() 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) = 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().getString(R.string.err_paperless_not_setup) ) } return PaperlessClient(s.paperlessUrl, token, getApplication()).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().getString(R.string.err_enter_url_token) ) } return PaperlessClient(url.trim(), token.trim(), getApplication()).testConnection() } fun clearSession() { _pages.value.forEach { it.sourceFile.delete() } renderCache.values.forEach { it.second.recycle() } renderCache.clear() _pages.value = emptyList() } }