Store images on the file system rather than in memory

This commit is contained in:
Pierre-Yves Nicolas
2025-06-04 14:39:02 +02:00
parent aea72aac11
commit 80381eef6c
8 changed files with 138 additions and 26 deletions

View File

@@ -0,0 +1,36 @@
package org.mydomain.myscan
import java.io.File
const val SCAN_DIR_NAME = "scanned_pages"
class ImageRepository(appFilesDir: File) {
private val scanDir: File = File(appFilesDir, SCAN_DIR_NAME).apply {
if (!exists()) mkdirs()
}
val fileNames = scanDir.listFiles()
?.map { f -> f.name }?.toMutableList()
?:mutableListOf()
fun imageIds(): List<String> {
return fileNames.toList()
}
fun add(bytes: ByteArray) {
val fileName = "${System.currentTimeMillis()}.jpg"
val file = File(scanDir, fileName)
file.writeBytes(bytes)
fileNames.add(fileName)
}
fun getContent(id: String): ByteArray {
if (fileNames.contains(id)) {
val file = File(scanDir, id)
return file.readBytes()
}
throw IllegalArgumentException("No image for id: $id")
}
}

View File

@@ -38,7 +38,6 @@ class MainActivity : ComponentActivity() {
setContent {
val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
val liveAnalysisState by viewModel.liveAnalysisState.collectAsStateWithLifecycle()
val pages by viewModel.pages.collectAsStateWithLifecycle()
val context = LocalContext.current
MyScanTheme {
Scaffold { innerPadding ->
@@ -54,7 +53,7 @@ class MainActivity : ComponentActivity() {
FinalizeDocumentScreen (
viewModel,
onBackPressed = { viewModel.navigateTo(Screen.Camera) },
onSavePressed = savePdf(pages, context),
onSavePressed = savePdf(viewModel, context),
// TODO "on share"
)
}
@@ -65,6 +64,7 @@ class MainActivity : ComponentActivity() {
}
}
/*
private fun createPdfAndShare(context: Context): (Bitmap) -> Unit = { bitmap ->
val outputDir = File(cacheDir, "pdfs").apply { mkdirs() }
val outputFile = File(outputDir, "scan_${System.currentTimeMillis()}.pdf")
@@ -94,12 +94,13 @@ class MainActivity : ComponentActivity() {
startActivity(Intent.createChooser(shareIntent, "Share PDF"))
}
}
*/
private fun savePdf(
pages: List<Bitmap>,
viewModel: MainViewModel,
context: Context
): () -> Unit = {
val document = createPdfFromBitmaps(pages)
val document = viewModel.createPdf()
try {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) downloadsDir.mkdirs()

View File

@@ -2,7 +2,9 @@ package org.mydomain.myscan
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.graphics.pdf.PdfDocument
import androidx.camera.core.ImageProxy
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@@ -14,16 +16,20 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
class MainViewModel(private val imageSegmentationService: ImageSegmentationService): ViewModel() {
class MainViewModel(
private val imageSegmentationService: ImageSegmentationService,
private val imageRepository: ImageRepository,
): ViewModel() {
companion object {
fun getFactory(context: Context) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
return MainViewModel(ImageSegmentationService(context)) as T
return MainViewModel(ImageSegmentationService(context), ImageRepository(context.filesDir)) as T
}
}
}
@@ -34,9 +40,8 @@ class MainViewModel(private val imageSegmentationService: ImageSegmentationServi
private val _currentScreen = MutableStateFlow<Screen>(Screen.Camera)
val currentScreen: StateFlow<Screen> = _currentScreen.asStateFlow()
// TODO store images on disk
private val _pages = MutableStateFlow<List<Bitmap>>(listOf())
val pages: StateFlow<List<Bitmap>> = _pages
private val _pageIds = MutableStateFlow<List<String>>(imageRepository.imageIds())
val pageIds: StateFlow<List<String>> = _pageIds
private var _pageToValidate = MutableStateFlow<Bitmap?>(null)
val pageToValidate: StateFlow<Bitmap?> = _pageToValidate.asStateFlow()
@@ -102,9 +107,25 @@ class MainViewModel(private val imageSegmentationService: ImageSegmentationServi
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
}
fun addPage(bitmap: Bitmap) {
_pages.update { list -> list.plus(bitmap) }
fun addPage(bitmap: Bitmap, quality: Int = 75) {
val resized = resizeImage(bitmap)
val outputStream = ByteArrayOutputStream()
resized.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
val jpegBytes = outputStream.toByteArray()
imageRepository.add(jpegBytes)
_pageIds.value = imageRepository.imageIds()
}
fun pageCount(): Int = _pages.value.size
fun pageCount(): Int = pageIds.value.size
fun getBitmap(id: String): Bitmap {
val bytes = imageRepository.getContent(id)
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}
fun createPdf(): PdfDocument {
val jpegs = imageRepository.imageIds().asSequence()
.map { id -> imageRepository.getContent(id) }
return createPdfFromJpegs(jpegs)
}
}

View File

@@ -4,20 +4,15 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.pdf.PdfDocument
import androidx.core.graphics.scale
import java.io.ByteArrayOutputStream
import kotlin.math.max
fun createPdfFromBitmaps (bitmaps: List<Bitmap>): PdfDocument {
fun createPdfFromJpegs (jpegs: Sequence<ByteArray>): PdfDocument {
val document = PdfDocument()
for ((index, bitmap) in bitmaps.map { resizeImage(it) }.withIndex()) {
val jpegStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 72, jpegStream)
val compressedBytes = jpegStream.toByteArray()
val compressedBitmap =
BitmapFactory.decodeByteArray(compressedBytes, 0, compressedBytes.size)
for ((index, jpegBytes) in jpegs.withIndex()) {
val bitmap = BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size)
val pageInfo = PdfDocument.PageInfo.Builder(bitmap.width, bitmap.height, index + 1).create()
val page = document.startPage(pageInfo)
page.canvas.drawBitmap(compressedBitmap, 0f, 0f, null)
page.canvas.drawBitmap(bitmap, 0f, 0f, null)
document.finishPage(page)
}
return document

View File

@@ -1,6 +1,5 @@
package org.mydomain.myscan.view
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
@@ -41,7 +40,7 @@ fun FinalizeDocumentScreen(
onBackPressed: () -> Unit,
onSavePressed: () -> Unit
) {
val pages: List<Bitmap> by viewModel.pages.collectAsStateWithLifecycle()
val pageIds by viewModel.pageIds.collectAsStateWithLifecycle()
Scaffold (
topBar = {
TopAppBar(
@@ -75,10 +74,10 @@ fun FinalizeDocumentScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
pages.forEachIndexed { index, bitmap ->
pageIds.forEachIndexed { index, id ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Image(
bitmap = bitmap.asImageBitmap(),
bitmap = viewModel.getBitmap(id).asImageBitmap(),
contentDescription = "Page ${index + 1}",
modifier = Modifier
.size(160.dp)

View File

@@ -0,0 +1,56 @@
package org.mydomain.myscan
import org.assertj.core.api.Assertions
import org.assertj.core.api.Assertions.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class ImageRepositoryTest {
@get:Rule
var folder: TemporaryFolder = TemporaryFolder()
private var _filesDir: File? = null
fun getFilesDir(): File {
if (_filesDir == null) {
_filesDir = folder.newFolder("files_dir")
}
return _filesDir!!
}
fun repo(): ImageRepository {
return ImageRepository(getFilesDir())
}
@Test
fun add_image() {
val repo = repo()
assertThat(repo.imageIds()).isEmpty()
val bytes = byteArrayOf(101, 102, 103)
repo.add(bytes)
assertThat(repo.imageIds()).hasSize(1)
assertThat(repo.getContent(repo.imageIds()[0])).isEqualTo(bytes)
}
@Test
fun `should find existing files at initialization`() {
val bytes = byteArrayOf(101, 102, 103)
val repo1 = repo()
assertThat(repo1.imageIds()).isEmpty()
repo1.add(bytes)
val repo2 = repo()
assertThat(repo2.imageIds()).hasSize(1)
assertThat(repo2.getContent(repo2.imageIds()[0])).isEqualTo(bytes)
}
@Test
fun `should throw on invalid id`() {
val repo = repo()
assertThat(repo.imageIds()).isEmpty()
Assertions.assertThatThrownBy { repo.getContent("x") }
.isInstanceOf(IllegalArgumentException::class.java)
}
}