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
|
import java.io.File
|
||||||
|
|
||||||
const val SCAN_DIR_NAME = "scanned_pages"
|
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 {
|
private val scanDir: File = File(appFilesDir, SCAN_DIR_NAME).apply {
|
||||||
if (!exists()) mkdirs()
|
if (!exists()) mkdirs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val thumbnailDir: File = File(appFilesDir, THUMBNAIL_DIR_NAME).apply {
|
||||||
|
if (!exists()) mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
private val metadataFile = File(scanDir, "document.json")
|
private val metadataFile = File(scanDir, "document.json")
|
||||||
|
|
||||||
private var fileNames: MutableList<String> =
|
private var fileNames: MutableList<String> =
|
||||||
@@ -73,6 +82,7 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio
|
|||||||
val fileName = "${System.currentTimeMillis()}.jpg"
|
val fileName = "${System.currentTimeMillis()}.jpg"
|
||||||
val file = File(scanDir, fileName)
|
val file = File(scanDir, fileName)
|
||||||
file.writeBytes(bytes)
|
file.writeBytes(bytes)
|
||||||
|
writeThumbnail(file)
|
||||||
fileNames.add(fileName)
|
fileNames.add(fileName)
|
||||||
saveMetadata()
|
saveMetadata()
|
||||||
}
|
}
|
||||||
@@ -110,6 +120,23 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio
|
|||||||
return null
|
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) {
|
fun movePage(id: String, newIndex: Int) {
|
||||||
if (!fileNames.remove(id)) return
|
if (!fileNames.remove(id)) return
|
||||||
val safeIndex = newIndex.coerceIn(0, fileNames.size)
|
val safeIndex = newIndex.coerceIn(0, fileNames.size)
|
||||||
@@ -118,17 +145,20 @@ class ImageRepository(appFilesDir: File, val transformations: ImageTransformatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun delete(id: String) {
|
fun delete(id: String) {
|
||||||
val file = File(scanDir, id)
|
File(scanDir, id).delete()
|
||||||
file.delete()
|
getThumbnailFile(id).delete()
|
||||||
fileNames.remove(id)
|
fileNames.remove(id)
|
||||||
saveMetadata()
|
saveMetadata()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
fileNames.clear()
|
fileNames.clear()
|
||||||
|
thumbnailDir.listFiles()?.forEach {
|
||||||
|
file -> file.delete()
|
||||||
|
}
|
||||||
scanDir.listFiles()?.forEach {
|
scanDir.listFiles()?.forEach {
|
||||||
file -> file.delete()
|
file -> file.delete()
|
||||||
}
|
}
|
||||||
saveMetadata()
|
saveMetadata() // "empty" json file
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ package org.fairscan.app
|
|||||||
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
fun interface ImageTransformations {
|
interface ImageTransformations {
|
||||||
|
|
||||||
fun rotate(inputFile: File, outputFile: File, clockwise: Boolean)
|
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.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
const val THUMBNAIL_SIZE_DP = 120
|
||||||
|
|
||||||
class MainViewModel(
|
class MainViewModel(
|
||||||
private val imageSegmentationService: ImageSegmentationService,
|
private val imageSegmentationService: ImageSegmentationService,
|
||||||
private val imageRepository: ImageRepository,
|
private val imageRepository: ImageRepository,
|
||||||
@@ -57,9 +59,11 @@ class MainViewModel(
|
|||||||
fun getFactory(context: Context) = object : ViewModelProvider.Factory {
|
fun getFactory(context: Context) = object : ViewModelProvider.Factory {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@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 {
|
||||||
|
val density = context.resources.displayMetrics.density
|
||||||
|
val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
|
||||||
return MainViewModel(
|
return MainViewModel(
|
||||||
ImageSegmentationService(context),
|
ImageSegmentationService(context),
|
||||||
ImageRepository(context.filesDir, OpenCvTransformations()),
|
ImageRepository(context.filesDir, OpenCvTransformations(), thumbnailSizePx),
|
||||||
PdfFileManager(
|
PdfFileManager(
|
||||||
File(context.cacheDir, "pdfs"),
|
File(context.cacheDir, "pdfs"),
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||||
@@ -83,12 +87,13 @@ class MainViewModel(
|
|||||||
_pageIds.map { ids ->
|
_pageIds.map { ids ->
|
||||||
DocumentUiModel(
|
DocumentUiModel(
|
||||||
pageIds = ids,
|
pageIds = ids,
|
||||||
imageLoader = ::getBitmap
|
imageLoader = ::getBitmap,
|
||||||
|
thumbnailLoader = ::getThumbnail,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.Eagerly,
|
started = SharingStarted.Eagerly,
|
||||||
initialValue = DocumentUiModel(persistentListOf(), ::getBitmap)
|
initialValue = DocumentUiModel(persistentListOf(), ::getBitmap, ::getThumbnail)
|
||||||
)
|
)
|
||||||
|
|
||||||
private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle)
|
private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle)
|
||||||
@@ -255,6 +260,11 @@ class MainViewModel(
|
|||||||
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
|
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) {
|
private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) {
|
||||||
val imageIds = imageRepository.imageIds()
|
val imageIds = imageRepository.imageIds()
|
||||||
val jpegs = imageIds.asSequence()
|
val jpegs = imageIds.asSequence()
|
||||||
|
|||||||
@@ -14,10 +14,14 @@
|
|||||||
*/
|
*/
|
||||||
package org.fairscan.app
|
package org.fairscan.app
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import org.opencv.core.Core
|
import org.opencv.core.Core
|
||||||
import org.opencv.core.Mat
|
import org.opencv.core.Mat
|
||||||
import org.opencv.imgcodecs.Imgcodecs
|
import org.opencv.imgcodecs.Imgcodecs
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import kotlin.math.min
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
|
||||||
class OpenCvTransformations : ImageTransformations {
|
class OpenCvTransformations : ImageTransformations {
|
||||||
override fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) {
|
override fun rotate(inputFile: File, outputFile: File, clockwise: Boolean) {
|
||||||
@@ -37,4 +41,15 @@ class OpenCvTransformations : ImageTransformations {
|
|||||||
src.release()
|
src.release()
|
||||||
dst.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(
|
data class DocumentUiModel(
|
||||||
val pageIds: ImmutableList<String>,
|
val pageIds: ImmutableList<String>,
|
||||||
private val imageLoader: (String) -> Bitmap?
|
private val imageLoader: (String) -> Bitmap?,
|
||||||
|
private val thumbnailLoader: (String) -> Bitmap?
|
||||||
) {
|
) {
|
||||||
fun pageCount(): Int {
|
fun pageCount(): Int {
|
||||||
return pageIds.size
|
return pageIds.size
|
||||||
@@ -36,4 +37,7 @@ data class DocumentUiModel(
|
|||||||
fun load(index: Int): Bitmap? {
|
fun load(index: Int): Bitmap? {
|
||||||
return imageLoader(pageIds[index])
|
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.Density
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.fairscan.app.THUMBNAIL_SIZE_DP
|
||||||
import sh.calvin.reorderable.ReorderableItem
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
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(
|
data class CommonPageListState(
|
||||||
val document: DocumentUiModel,
|
val document: DocumentUiModel,
|
||||||
@@ -76,8 +77,7 @@ fun CommonPageList(
|
|||||||
val content: LazyListScope.() -> Unit = {
|
val content: LazyListScope.() -> Unit = {
|
||||||
itemsIndexed(state.document.pageIds, key = { _, item -> item}) { index, item ->
|
itemsIndexed(state.document.pageIds, key = { _, item -> item}) { index, item ->
|
||||||
ReorderableItem(reorderableLazyListState, key = item) { _ ->
|
ReorderableItem(reorderableLazyListState, key = item) { _ ->
|
||||||
// TODO Use small images rather than big ones
|
val image = state.document.loadThumbnail(index)
|
||||||
val image = state.document.load(index)
|
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
PageThumbnail(image, index, state, Modifier.draggableHandle())
|
PageThumbnail(image, index, state, Modifier.draggableHandle())
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ fun CommonPageList(
|
|||||||
if (state.document.isEmpty()) {
|
if (state.document.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(120.dp)
|
.height(THUMBNAIL_SIZE_DP.dp)
|
||||||
.addPositionCallback(state.onLastItemPosition, LocalDensity.current, 0.5f)
|
.addPositionCallback(state.onLastItemPosition, LocalDensity.current, 0.5f)
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,13 +25,14 @@ fun dummyNavigation(): Navigation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun fakeDocument(): DocumentUiModel {
|
fun fakeDocument(): DocumentUiModel {
|
||||||
return DocumentUiModel(persistentListOf()) { _ -> null }
|
return DocumentUiModel(persistentListOf(), { _ -> null }, { _ -> null })
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
|
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
|
||||||
return DocumentUiModel(pageIds) { id ->
|
val loader = { id:String ->
|
||||||
context.assets.open(id).use { input ->
|
context.assets.open(id).use { input ->
|
||||||
BitmapFactory.decodeStream(input)
|
BitmapFactory.decodeStream(input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return DocumentUiModel(pageIds, loader, loader)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,15 @@ class ImageRepositoryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun repo(): ImageRepository {
|
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
|
@Test
|
||||||
@@ -46,6 +54,7 @@ class ImageRepositoryTest {
|
|||||||
repo.add(bytes)
|
repo.add(bytes)
|
||||||
assertThat(repo.imageIds()).hasSize(1)
|
assertThat(repo.imageIds()).hasSize(1)
|
||||||
assertThat(repo.getContent(repo.imageIds()[0])).isEqualTo(bytes)
|
assertThat(repo.getContent(repo.imageIds()[0])).isEqualTo(bytes)
|
||||||
|
assertThat(repo.getThumbnail(repo.imageIds()[0])).isEqualTo(byteArrayOf(101))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -60,6 +69,13 @@ class ImageRepositoryTest {
|
|||||||
assertThat(repo2.imageIds()).isEmpty()
|
assertThat(repo2.imageIds()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun delete_unknown_id() {
|
||||||
|
val repo = repo()
|
||||||
|
repo.delete("x")
|
||||||
|
assertThat(repo.imageIds()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `should find existing files at initialization with no json`() {
|
fun `should find existing files at initialization with no json`() {
|
||||||
val scanDir = File(getFilesDir(), SCAN_DIR_NAME)
|
val scanDir = File(getFilesDir(), SCAN_DIR_NAME)
|
||||||
@@ -93,6 +109,12 @@ class ImageRepositoryTest {
|
|||||||
assertThat(repo1.imageIds()).isNotEmpty()
|
assertThat(repo1.imageIds()).isNotEmpty()
|
||||||
repo1.clear()
|
repo1.clear()
|
||||||
assertThat(repo1.imageIds()).isEmpty()
|
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()
|
val repo2 = repo()
|
||||||
assertThat(repo2.imageIds()).isEmpty()
|
assertThat(repo2.imageIds()).isEmpty()
|
||||||
}
|
}
|
||||||
@@ -129,6 +151,7 @@ class ImageRepositoryTest {
|
|||||||
fun movePage() {
|
fun movePage() {
|
||||||
val repo = repo()
|
val repo = repo()
|
||||||
repo.add(byteArrayOf(101))
|
repo.add(byteArrayOf(101))
|
||||||
|
Thread.sleep(1L) // to avoid file name clashes
|
||||||
repo.add(byteArrayOf(110))
|
repo.add(byteArrayOf(110))
|
||||||
val id0 = repo.imageIds().first()
|
val id0 = repo.imageIds().first()
|
||||||
val id1 = repo.imageIds().last()
|
val id1 = repo.imageIds().last()
|
||||||
|
|||||||
Reference in New Issue
Block a user