Store images on the file system rather than in memory
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
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 {
|
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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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"
|
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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user