diff --git a/app/src/main/java/org/fairscan/app/ImageRepository.kt b/app/src/main/java/org/fairscan/app/ImageRepository.kt index 9c52053..da816c9 100644 --- a/app/src/main/java/org/fairscan/app/ImageRepository.kt +++ b/app/src/main/java/org/fairscan/app/ImageRepository.kt @@ -22,13 +22,22 @@ import org.fairscan.app.data.Page import java.io.File const val SCAN_DIR_NAME = "scanned_pages" +const val THUMBNAIL_DIR_NAME = "thumbnails" -class ImageRepository(appFilesDir: File, val transformations: ImageTransformations) { +class ImageRepository( + appFilesDir: File, + val transformations: ImageTransformations, + private val thumbnailSizePx: Int, +) { private val scanDir: File = File(appFilesDir, SCAN_DIR_NAME).apply { if (!exists()) mkdirs() } + private val thumbnailDir: File = File(appFilesDir, THUMBNAIL_DIR_NAME).apply { + if (!exists()) mkdirs() + } + private val metadataFile = File(scanDir, "document.json") private var fileNames: MutableList = @@ -73,6 +82,7 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio val fileName = "${System.currentTimeMillis()}.jpg" val file = File(scanDir, fileName) file.writeBytes(bytes) + writeThumbnail(file) fileNames.add(fileName) saveMetadata() } @@ -110,6 +120,23 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio return null } + fun getThumbnail(id: String): ByteArray? { + val thumbFile = getThumbnailFile(id) + if (!thumbFile.exists()) { + val originalFile = File(scanDir, id) + if (!originalFile.exists()) return null + writeThumbnail(originalFile) + } + return if (thumbFile.exists()) thumbFile.readBytes() else null + } + + private fun writeThumbnail(originalFile: File) { + val thumbFile = getThumbnailFile(originalFile.name) + transformations.resize(originalFile, thumbFile, thumbnailSizePx) + } + + private fun getThumbnailFile(id: String): File = File(thumbnailDir, id) + fun movePage(id: String, newIndex: Int) { if (!fileNames.remove(id)) return val safeIndex = newIndex.coerceIn(0, fileNames.size) @@ -118,17 +145,20 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio } fun delete(id: String) { - val file = File(scanDir, id) - file.delete() + File(scanDir, id).delete() + getThumbnailFile(id).delete() fileNames.remove(id) saveMetadata() } fun clear() { fileNames.clear() + thumbnailDir.listFiles()?.forEach { + file -> file.delete() + } scanDir.listFiles()?.forEach { file -> file.delete() } - saveMetadata() + saveMetadata() // "empty" json file } } diff --git a/app/src/main/java/org/fairscan/app/ImageTransformations.kt b/app/src/main/java/org/fairscan/app/ImageTransformations.kt index e3f49fa..71e6b58 100644 --- a/app/src/main/java/org/fairscan/app/ImageTransformations.kt +++ b/app/src/main/java/org/fairscan/app/ImageTransformations.kt @@ -16,8 +16,10 @@ package org.fairscan.app import java.io.File -fun interface ImageTransformations { +interface ImageTransformations { fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) + fun resize(inputFile: File, outputFile: File, maxSize: Int) + } \ No newline at end of file diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index 83985b4..e68b8b9 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -46,6 +46,8 @@ import org.fairscan.app.view.DocumentUiModel import java.io.ByteArrayOutputStream import java.io.File +const val THUMBNAIL_SIZE_DP = 120 + class MainViewModel( private val imageSegmentationService: ImageSegmentationService, private val imageRepository: ImageRepository, @@ -57,9 +59,11 @@ class MainViewModel( fun getFactory(context: Context) = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class, extras: CreationExtras): T { + val density = context.resources.displayMetrics.density + val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt() return MainViewModel( ImageSegmentationService(context), - ImageRepository(context.filesDir, OpenCvTransformations()), + ImageRepository(context.filesDir, OpenCvTransformations(), thumbnailSizePx), PdfFileManager( File(context.cacheDir, "pdfs"), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), @@ -83,12 +87,13 @@ class MainViewModel( _pageIds.map { ids -> DocumentUiModel( pageIds = ids, - imageLoader = ::getBitmap + imageLoader = ::getBitmap, + thumbnailLoader = ::getThumbnail, ) }.stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, - initialValue = DocumentUiModel(persistentListOf(), ::getBitmap) + initialValue = DocumentUiModel(persistentListOf(), ::getBitmap, ::getThumbnail) ) private val _captureState = MutableStateFlow(CaptureState.Idle) @@ -255,6 +260,11 @@ class MainViewModel( return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } } + fun getThumbnail(id: String): Bitmap? { + val bytes = imageRepository.getThumbnail(id) + return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } + } + private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) { val imageIds = imageRepository.imageIds() val jpegs = imageIds.asSequence() diff --git a/app/src/main/java/org/fairscan/app/OpenCvImageTransformations.kt b/app/src/main/java/org/fairscan/app/OpenCvImageTransformations.kt index afd3d9f..bc6a106 100644 --- a/app/src/main/java/org/fairscan/app/OpenCvImageTransformations.kt +++ b/app/src/main/java/org/fairscan/app/OpenCvImageTransformations.kt @@ -14,10 +14,14 @@ */ package org.fairscan.app +import android.graphics.Bitmap +import android.graphics.BitmapFactory import org.opencv.core.Core import org.opencv.core.Mat import org.opencv.imgcodecs.Imgcodecs import java.io.File +import kotlin.math.min +import androidx.core.graphics.scale class OpenCvTransformations : ImageTransformations { override fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) { @@ -37,4 +41,15 @@ class OpenCvTransformations : ImageTransformations { src.release() dst.release() } + + override fun resize(inputFile: File, outputFile: File, maxSize: Int) { + val bitmap = BitmapFactory.decodeFile(inputFile.absolutePath) + val ratio = min(maxSize.toFloat() / bitmap.width, maxSize.toFloat() / bitmap.height) + val newW = (bitmap.width * ratio).toInt() + val newH = (bitmap.height * ratio).toInt() + val scaled = bitmap.scale(newW, newH) + outputFile.outputStream().use { + scaled.compress(Bitmap.CompressFormat.JPEG, 85, it) + } + } } diff --git a/app/src/main/java/org/fairscan/app/view/DocumentUiModel.kt b/app/src/main/java/org/fairscan/app/view/DocumentUiModel.kt index 0f5dad2..c995575 100644 --- a/app/src/main/java/org/fairscan/app/view/DocumentUiModel.kt +++ b/app/src/main/java/org/fairscan/app/view/DocumentUiModel.kt @@ -19,7 +19,8 @@ import kotlinx.collections.immutable.ImmutableList data class DocumentUiModel( val pageIds: ImmutableList, - private val imageLoader: (String) -> Bitmap? + private val imageLoader: (String) -> Bitmap?, + private val thumbnailLoader: (String) -> Bitmap? ) { fun pageCount(): Int { return pageIds.size @@ -36,4 +37,7 @@ data class DocumentUiModel( fun load(index: Int): Bitmap? { return imageLoader(pageIds[index]) } -} \ No newline at end of file + fun loadThumbnail(index: Int): Bitmap? { + return thumbnailLoader(pageIds[index]) + } +} diff --git a/app/src/main/java/org/fairscan/app/view/PageList.kt b/app/src/main/java/org/fairscan/app/view/PageList.kt index a052238..479bfde 100644 --- a/app/src/main/java/org/fairscan/app/view/PageList.kt +++ b/app/src/main/java/org/fairscan/app/view/PageList.kt @@ -49,10 +49,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.fairscan.app.THUMBNAIL_SIZE_DP import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState -const val PAGE_LIST_ELEMENT_SIZE_DP = 120 +const val PAGE_LIST_ELEMENT_SIZE_DP = THUMBNAIL_SIZE_DP data class CommonPageListState( val document: DocumentUiModel, @@ -76,8 +77,7 @@ fun CommonPageList( val content: LazyListScope.() -> Unit = { itemsIndexed(state.document.pageIds, key = { _, item -> item}) { index, item -> ReorderableItem(reorderableLazyListState, key = item) { _ -> - // TODO Use small images rather than big ones - val image = state.document.load(index) + val image = state.document.loadThumbnail(index) if (image != null) { PageThumbnail(image, index, state, Modifier.draggableHandle()) } @@ -103,7 +103,7 @@ fun CommonPageList( if (state.document.isEmpty()) { Box( modifier = Modifier - .height(120.dp) + .height(THUMBNAIL_SIZE_DP.dp) .addPositionCallback(state.onLastItemPosition, LocalDensity.current, 0.5f) ) {} } diff --git a/app/src/main/java/org/fairscan/app/view/PreviewUtils.kt b/app/src/main/java/org/fairscan/app/view/PreviewUtils.kt index c4cac17..b4b8573 100644 --- a/app/src/main/java/org/fairscan/app/view/PreviewUtils.kt +++ b/app/src/main/java/org/fairscan/app/view/PreviewUtils.kt @@ -25,13 +25,14 @@ fun dummyNavigation(): Navigation { } fun fakeDocument(): DocumentUiModel { - return DocumentUiModel(persistentListOf()) { _ -> null } + return DocumentUiModel(persistentListOf(), { _ -> null }, { _ -> null }) } fun fakeDocument(pageIds: ImmutableList, context: Context): DocumentUiModel { - return DocumentUiModel(pageIds) { id -> + val loader = { id:String -> context.assets.open(id).use { input -> BitmapFactory.decodeStream(input) } } + return DocumentUiModel(pageIds, loader, loader) } diff --git a/app/src/test/java/org/fairscan/app/ImageRepositoryTest.kt b/app/src/test/java/org/fairscan/app/ImageRepositoryTest.kt index 7558d90..0b99b68 100644 --- a/app/src/test/java/org/fairscan/app/ImageRepositoryTest.kt +++ b/app/src/test/java/org/fairscan/app/ImageRepositoryTest.kt @@ -35,7 +35,15 @@ class ImageRepositoryTest { } fun repo(): ImageRepository { - return ImageRepository(getFilesDir(), {f1,f2,_->f1.copyTo(f2)}) + val transformations = object : ImageTransformations { + override fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) { + inputFile.copyTo(outputFile) + } + override fun resize(inputFile: File, outputFile: File, maxSize: Int) { + outputFile.writeBytes(byteArrayOf(inputFile.readBytes()[0])) + } + } + return ImageRepository(getFilesDir(), transformations, 200) } @Test @@ -46,6 +54,7 @@ class ImageRepositoryTest { repo.add(bytes) assertThat(repo.imageIds()).hasSize(1) assertThat(repo.getContent(repo.imageIds()[0])).isEqualTo(bytes) + assertThat(repo.getThumbnail(repo.imageIds()[0])).isEqualTo(byteArrayOf(101)) } @Test @@ -60,6 +69,13 @@ class ImageRepositoryTest { assertThat(repo2.imageIds()).isEmpty() } + @Test + fun delete_unknown_id() { + val repo = repo() + repo.delete("x") + assertThat(repo.imageIds()).isEmpty() + } + @Test fun `should find existing files at initialization with no json`() { val scanDir = File(getFilesDir(), SCAN_DIR_NAME) @@ -93,6 +109,12 @@ class ImageRepositoryTest { assertThat(repo1.imageIds()).isNotEmpty() repo1.clear() assertThat(repo1.imageIds()).isEmpty() + assertThat(File(getFilesDir(), SCAN_DIR_NAME) + .listFiles { f -> f.name.endsWith(".jpg") }) + .isEmpty() + assertThat(File(getFilesDir(), THUMBNAIL_DIR_NAME) + .listFiles { f -> f.name.endsWith(".jpg") }) + .isEmpty() val repo2 = repo() assertThat(repo2.imageIds()).isEmpty() } @@ -129,6 +151,7 @@ class ImageRepositoryTest { fun movePage() { val repo = repo() repo.add(byteArrayOf(101)) + Thread.sleep(1L) // to avoid file name clashes repo.add(byteArrayOf(110)) val id0 = repo.imageIds().first() val id1 = repo.imageIds().last()