Store images on the file system rather than in memory
This commit is contained in:
@@ -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))
|
||||
|
||||
36
app/src/main/java/org/mydomain/myscan/ImageRepository.kt
Normal file
36
app/src/main/java/org/mydomain/myscan/ImageRepository.kt
Normal 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")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
56
app/src/test/java/org/mydomain/myscan/ImageRepositoryTest.kt
Normal file
56
app/src/test/java/org/mydomain/myscan/ImageRepositoryTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user