Browse Source
* Avoid scaling code duplication * Move AndroidImageStorage into separate package * Implement storing metadata on Android * Move file extensions to separate classpull/2941/head
Ivan Matkov
2 years ago
committed by
GitHub
7 changed files with 259 additions and 195 deletions
@ -1,63 +0,0 @@
|
||||
package example.imageviewer |
||||
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList |
||||
import androidx.compose.ui.graphics.ImageBitmap |
||||
import androidx.compose.ui.graphics.asAndroidBitmap |
||||
import androidx.compose.ui.graphics.asImageBitmap |
||||
import androidx.core.graphics.scale |
||||
import example.imageviewer.model.PictureData |
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlinx.coroutines.launch |
||||
|
||||
private const val maxStorableImageSizePx = 2000 |
||||
private const val storableThumbnailSizePx = 200 |
||||
|
||||
class AndroidImageStorage( |
||||
private val pictures: SnapshotStateList<PictureData>, |
||||
private val ioScope: CoroutineScope |
||||
) : ImageStorage { |
||||
val largeImages = mutableMapOf<PictureData.Camera, ImageBitmap>() |
||||
val thumbnails = mutableMapOf<PictureData.Camera, ImageBitmap>() |
||||
override fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage) { |
||||
if (image.imageBitmap.width == 0 || image.imageBitmap.height == 0) { |
||||
return |
||||
} |
||||
ioScope.launch { |
||||
val androidBitmap = image.imageBitmap.asAndroidBitmap() |
||||
|
||||
val targetScale = maxOf( |
||||
maxStorableImageSizePx.toFloat() / androidBitmap.width, |
||||
maxStorableImageSizePx.toFloat() / androidBitmap.height |
||||
) |
||||
largeImages[picture] = |
||||
if (targetScale < 1.0) { |
||||
androidBitmap.scale( |
||||
width = (androidBitmap.width * targetScale).toInt(), |
||||
height = (androidBitmap.height * targetScale).toInt() |
||||
).asImageBitmap() |
||||
} else { |
||||
image.imageBitmap |
||||
} |
||||
|
||||
val targetThumbnailScale = maxOf( |
||||
storableThumbnailSizePx.toFloat() / androidBitmap.width, |
||||
storableThumbnailSizePx.toFloat() / androidBitmap.height |
||||
) |
||||
thumbnails[picture] = androidBitmap.scale( |
||||
width = (androidBitmap.width * targetThumbnailScale).toInt(), |
||||
height = (androidBitmap.height * targetThumbnailScale).toInt() |
||||
).asImageBitmap() |
||||
|
||||
pictures.add(0, picture) |
||||
} |
||||
} |
||||
|
||||
override suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap { |
||||
return thumbnails[picture]!! |
||||
} |
||||
|
||||
override suspend fun getImage(picture: PictureData.Camera): ImageBitmap { |
||||
return largeImages[picture]!! |
||||
} |
||||
|
||||
} |
@ -0,0 +1,107 @@
|
||||
package example.imageviewer.storage |
||||
|
||||
import android.content.Context |
||||
import android.graphics.Bitmap |
||||
import androidx.compose.runtime.snapshots.SnapshotStateList |
||||
import androidx.compose.ui.graphics.ImageBitmap |
||||
import androidx.compose.ui.graphics.asAndroidBitmap |
||||
import androidx.compose.ui.graphics.asImageBitmap |
||||
import androidx.core.graphics.scale |
||||
import example.imageviewer.ImageStorage |
||||
import example.imageviewer.PlatformStorableImage |
||||
import example.imageviewer.model.PictureData |
||||
import example.imageviewer.toAndroidBitmap |
||||
import example.imageviewer.toImageBitmap |
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlinx.coroutines.launch |
||||
import kotlinx.coroutines.withContext |
||||
import kotlinx.serialization.decodeFromString |
||||
import kotlinx.serialization.encodeToString |
||||
import kotlinx.serialization.json.Json |
||||
import java.io.File |
||||
|
||||
private const val maxStorableImageSizePx = 2000 |
||||
private const val storableThumbnailSizePx = 200 |
||||
private const val jpegCompressionQuality = 60 |
||||
|
||||
class AndroidImageStorage( |
||||
private val pictures: SnapshotStateList<PictureData>, |
||||
private val ioScope: CoroutineScope, |
||||
context: Context |
||||
) : ImageStorage { |
||||
private val savePictureDir = File(context.filesDir, "takenPhotos") |
||||
|
||||
private val PictureData.Camera.jpgFile get() = File(savePictureDir, "$id.jpg") |
||||
private val PictureData.Camera.thumbnailJpgFile get() = File(savePictureDir, "$id-thumbnail.jpg") |
||||
private val PictureData.Camera.jsonFile get() = File(savePictureDir, "$id.json") |
||||
|
||||
init { |
||||
if (savePictureDir.isDirectory) { |
||||
val files = savePictureDir.listFiles { _, name: String -> |
||||
name.endsWith(".json") |
||||
} ?: emptyArray() |
||||
pictures.addAll( |
||||
index = 0, |
||||
elements = files.map { |
||||
it.readText().toCameraMetadata() |
||||
}.sortedByDescending { |
||||
it.timeStampSeconds |
||||
} |
||||
) |
||||
} else { |
||||
savePictureDir.mkdirs() |
||||
} |
||||
} |
||||
|
||||
override fun saveImage(pictureData: PictureData.Camera, image: PlatformStorableImage) { |
||||
if (image.imageBitmap.width == 0 || image.imageBitmap.height == 0) { |
||||
return |
||||
} |
||||
ioScope.launch { |
||||
with(image.imageBitmap) { |
||||
pictureData.jpgFile.writeJpeg(fitInto(maxStorableImageSizePx)) |
||||
pictureData.thumbnailJpgFile.writeJpeg(fitInto(storableThumbnailSizePx)) |
||||
|
||||
} |
||||
pictures.add(0, pictureData) |
||||
pictureData.jsonFile.writeText(pictureData.toJson()) |
||||
} |
||||
} |
||||
|
||||
override suspend fun getThumbnail(pictureData: PictureData.Camera): ImageBitmap = |
||||
withContext(ioScope.coroutineContext) { |
||||
pictureData.thumbnailJpgFile.readBytes().toImageBitmap() |
||||
} |
||||
|
||||
override suspend fun getImage(pictureData: PictureData.Camera): ImageBitmap = |
||||
withContext(ioScope.coroutineContext) { |
||||
pictureData.jpgFile.readBytes().toImageBitmap() |
||||
} |
||||
} |
||||
|
||||
private fun ImageBitmap.fitInto(px: Int): ImageBitmap { |
||||
val targetScale = maxOf( |
||||
px.toFloat() / width, |
||||
px.toFloat() / height |
||||
) |
||||
return if (targetScale < 1.0) { |
||||
asAndroidBitmap().scale( |
||||
width = (width * targetScale).toInt(), |
||||
height = (height * targetScale).toInt() |
||||
).asImageBitmap() |
||||
} else { |
||||
this |
||||
} |
||||
} |
||||
|
||||
private fun PictureData.Camera.toJson(): String = |
||||
Json.Default.encodeToString(this) |
||||
|
||||
private fun String.toCameraMetadata(): PictureData.Camera = |
||||
Json.Default.decodeFromString(this) |
||||
|
||||
private fun File.writeJpeg(image: ImageBitmap, compressionQuality: Int = jpegCompressionQuality) { |
||||
outputStream().use { |
||||
image.asAndroidBitmap().compress(Bitmap.CompressFormat.JPEG, compressionQuality, it) |
||||
} |
||||
} |
@ -0,0 +1,78 @@
|
||||
package example.imageviewer.storage |
||||
|
||||
import kotlinx.cinterop.* |
||||
import kotlinx.coroutines.yield |
||||
import platform.Foundation.* |
||||
import platform.UIKit.UIImage |
||||
import platform.UIKit.UIImageJPEGRepresentation |
||||
import platform.posix.memcpy |
||||
|
||||
|
||||
val NSFileManager.DocumentDirectory get() = URLForDirectory( |
||||
directory = NSDocumentDirectory, |
||||
inDomain = NSUserDomainMask, |
||||
create = true, |
||||
appropriateForURL = null, |
||||
error = null |
||||
)!! |
||||
|
||||
// Mimic to java's File class |
||||
@Suppress("FunctionName") |
||||
fun File(dir: NSURL, child: String) = |
||||
dir.URLByAppendingPathComponent(child)!! |
||||
|
||||
val NSURL.isDirectory: Boolean |
||||
get() { |
||||
return memScoped { |
||||
val isDirectory = alloc<BooleanVar>() |
||||
val fileExists = NSFileManager.defaultManager.fileExistsAtPath(path!!, isDirectory.ptr) |
||||
fileExists && isDirectory.value |
||||
} |
||||
} |
||||
|
||||
fun NSURL.mkdirs() { |
||||
NSFileManager.defaultManager.createDirectoryAtURL(this, true, null, null) |
||||
} |
||||
|
||||
fun NSURL.listFiles(filter: (NSURL, String) -> Boolean) = |
||||
NSFileManager.defaultManager.contentsOfDirectoryAtPath(path!!, null) |
||||
?.map { it.toString() } |
||||
?.filter { filter(this, it) } |
||||
?.map { File(this, it) } |
||||
?.toTypedArray() |
||||
|
||||
suspend fun NSURL.readData(): NSData { |
||||
while (true) { |
||||
val data = NSData.dataWithContentsOfURL(this) |
||||
if (data != null) |
||||
return data |
||||
yield() |
||||
} |
||||
} |
||||
|
||||
suspend fun NSURL.readBytes(): ByteArray = |
||||
with(readData()) { |
||||
ByteArray(length.toInt()).apply { |
||||
usePinned { |
||||
memcpy(it.addressOf(0), bytes, length) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun NSURL.readText(): String = |
||||
NSString.stringWithContentsOfURL( |
||||
url = this, |
||||
encoding = NSUTF8StringEncoding, |
||||
error = null, |
||||
) as String |
||||
|
||||
fun NSURL.writeText(text: String) { |
||||
(text as NSString).writeToURL( |
||||
url = this, |
||||
atomically = true, |
||||
encoding = NSUTF8StringEncoding, |
||||
error = null |
||||
) |
||||
} |
||||
|
||||
|
Loading…
Reference in new issue