From 80381eef6c5743f8ec6183c04247fbc0cf5f552e Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Wed, 4 Jun 2025 14:39:02 +0200 Subject: [PATCH] Store images on the file system rather than in memory --- app/build.gradle.kts | 1 + .../org/mydomain/myscan/ImageRepository.kt | 36 ++++++++++++ .../java/org/mydomain/myscan/MainActivity.kt | 9 +-- .../java/org/mydomain/myscan/MainViewModel.kt | 39 ++++++++++--- .../java/org/mydomain/myscan/PdfGeneration.kt | 13 ++--- .../mydomain/myscan/view/FinalizeDocument.kt | 7 +-- .../mydomain/myscan/ImageRepositoryTest.kt | 56 +++++++++++++++++++ gradle/libs.versions.toml | 3 + 8 files changed, 138 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/org/mydomain/myscan/ImageRepository.kt create mode 100644 app/src/test/java/org/mydomain/myscan/ImageRepositoryTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e1c66a5..b9b1432 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { implementation(libs.opencv) testImplementation(libs.junit) + testImplementation(libs.assertj) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/org/mydomain/myscan/ImageRepository.kt b/app/src/main/java/org/mydomain/myscan/ImageRepository.kt new file mode 100644 index 0000000..4054dfd --- /dev/null +++ b/app/src/main/java/org/mydomain/myscan/ImageRepository.kt @@ -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 { + 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") + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/mydomain/myscan/MainActivity.kt b/app/src/main/java/org/mydomain/myscan/MainActivity.kt index 9a900a9..0f4206c 100644 --- a/app/src/main/java/org/mydomain/myscan/MainActivity.kt +++ b/app/src/main/java/org/mydomain/myscan/MainActivity.kt @@ -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, + 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() diff --git a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt index cc303a8..212d8df 100644 --- a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt +++ b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt @@ -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 create(modelClass: Class, 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.Camera) val currentScreen: StateFlow = _currentScreen.asStateFlow() - // TODO store images on disk - private val _pages = MutableStateFlow>(listOf()) - val pages: StateFlow> = _pages + private val _pageIds = MutableStateFlow>(imageRepository.imageIds()) + val pageIds: StateFlow> = _pageIds private var _pageToValidate = MutableStateFlow(null) val pageToValidate: StateFlow = _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) + } } diff --git a/app/src/main/java/org/mydomain/myscan/PdfGeneration.kt b/app/src/main/java/org/mydomain/myscan/PdfGeneration.kt index 1890300..e2ac49a 100644 --- a/app/src/main/java/org/mydomain/myscan/PdfGeneration.kt +++ b/app/src/main/java/org/mydomain/myscan/PdfGeneration.kt @@ -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): PdfDocument { +fun createPdfFromJpegs (jpegs: Sequence): 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 diff --git a/app/src/main/java/org/mydomain/myscan/view/FinalizeDocument.kt b/app/src/main/java/org/mydomain/myscan/view/FinalizeDocument.kt index c0ca96e..217bf6f 100644 --- a/app/src/main/java/org/mydomain/myscan/view/FinalizeDocument.kt +++ b/app/src/main/java/org/mydomain/myscan/view/FinalizeDocument.kt @@ -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 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) diff --git a/app/src/test/java/org/mydomain/myscan/ImageRepositoryTest.kt b/app/src/test/java/org/mydomain/myscan/ImageRepositoryTest.kt new file mode 100644 index 0000000..7fb3694 --- /dev/null +++ b/app/src/test/java/org/mydomain/myscan/ImageRepositoryTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cbd5fb4..90f7094 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ camerax = "1.4.2" litert = "1.2.0" opencv = "4.11.0" flowlayout = "0.36.0" +assertj = "3.27.3" [libraries] 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" } opencv = { group="org.opencv", name="opencv", version.ref = "opencv" } +assertj = { group="org.assertj", name="assertj-core", version.ref = "assertj" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }