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

@@ -62,6 +62,7 @@ dependencies {
implementation(libs.opencv) implementation(libs.opencv)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.assertj)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))

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

View File

@@ -2,7 +2,9 @@ package org.mydomain.myscan
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.pdf.PdfDocument
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@@ -14,16 +16,20 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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 { companion object {
fun getFactory(context: Context) = object : ViewModelProvider.Factory { fun getFactory(context: Context) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T { 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) private val _currentScreen = MutableStateFlow<Screen>(Screen.Camera)
val currentScreen: StateFlow<Screen> = _currentScreen.asStateFlow() val currentScreen: StateFlow<Screen> = _currentScreen.asStateFlow()
// TODO store images on disk private val _pageIds = MutableStateFlow<List<String>>(imageRepository.imageIds())
private val _pages = MutableStateFlow<List<Bitmap>>(listOf()) val pageIds: StateFlow<List<String>> = _pageIds
val pages: StateFlow<List<Bitmap>> = _pages
private var _pageToValidate = MutableStateFlow<Bitmap?>(null) private var _pageToValidate = MutableStateFlow<Bitmap?>(null)
val pageToValidate: StateFlow<Bitmap?> = _pageToValidate.asStateFlow() 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) return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
} }
fun addPage(bitmap: Bitmap) { fun addPage(bitmap: Bitmap, quality: Int = 75) {
_pages.update { list -> list.plus(bitmap) } 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.BitmapFactory
import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument
import androidx.core.graphics.scale import androidx.core.graphics.scale
import java.io.ByteArrayOutputStream
import kotlin.math.max import kotlin.math.max
fun createPdfFromBitmaps (bitmaps: List<Bitmap>): PdfDocument { fun createPdfFromJpegs (jpegs: Sequence<ByteArray>): PdfDocument {
val document = PdfDocument() val document = PdfDocument()
for ((index, bitmap) in bitmaps.map { resizeImage(it) }.withIndex()) { for ((index, jpegBytes) in jpegs.withIndex()) {
val jpegStream = ByteArrayOutputStream() val bitmap = BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size)
bitmap.compress(Bitmap.CompressFormat.JPEG, 72, jpegStream)
val compressedBytes = jpegStream.toByteArray()
val compressedBitmap =
BitmapFactory.decodeByteArray(compressedBytes, 0, compressedBytes.size)
val pageInfo = PdfDocument.PageInfo.Builder(bitmap.width, bitmap.height, index + 1).create() val pageInfo = PdfDocument.PageInfo.Builder(bitmap.width, bitmap.height, index + 1).create()
val page = document.startPage(pageInfo) val page = document.startPage(pageInfo)
page.canvas.drawBitmap(compressedBitmap, 0f, 0f, null) page.canvas.drawBitmap(bitmap, 0f, 0f, null)
document.finishPage(page) document.finishPage(page)
} }
return document return document

View File

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

View File

@@ -12,6 +12,7 @@ camerax = "1.4.2"
litert = "1.2.0" litert = "1.2.0"
opencv = "4.11.0" opencv = "4.11.0"
flowlayout = "0.36.0" flowlayout = "0.36.0"
assertj = "3.27.3"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -41,6 +42,8 @@ litert-support = { group = "com.google.ai.edge.litert", name = "litert-support",
litert-metadata = { group = "com.google.ai.edge.litert", name = "litert-metadata", version.ref = "litert" } litert-metadata = { group = "com.google.ai.edge.litert", name = "litert-metadata", version.ref = "litert" }
opencv = { group="org.opencv", name="opencv", version.ref = "opencv" } opencv = { group="org.opencv", name="opencv", version.ref = "opencv" }
assertj = { group="org.assertj", name="assertj-core", version.ref = "assertj" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }