Use thumbnails for the list of pages (#39)
This commit is contained in:
@@ -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<String> =
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
@@ -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 <T : ViewModel> create(modelClass: Class<T>, 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>(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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class DocumentUiModel(
|
||||
val pageIds: ImmutableList<String>,
|
||||
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])
|
||||
}
|
||||
fun loadThumbnail(index: Int): Bitmap? {
|
||||
return thumbnailLoader(pageIds[index])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -25,13 +25,14 @@ fun dummyNavigation(): Navigation {
|
||||
}
|
||||
|
||||
fun fakeDocument(): DocumentUiModel {
|
||||
return DocumentUiModel(persistentListOf()) { _ -> null }
|
||||
return DocumentUiModel(persistentListOf(), { _ -> null }, { _ -> null })
|
||||
}
|
||||
|
||||
fun fakeDocument(pageIds: ImmutableList<String>, 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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user