Browse Source

ImageViewer example: Implement storing metadata on Android (#2938)

* Avoid scaling code duplication

* Move AndroidImageStorage into separate package

* Implement storing metadata on Android

* Move file extensions to separate class
pull/2941/head
Ivan Matkov 2 years ago committed by GitHub
parent
commit
23472c1ee1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 63
      experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/AndroidImageStorage.kt
  2. 107
      experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/storage/AndroidImageStorage.kt
  3. 3
      experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ImageViewer.android.kt
  4. 6
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/Dependencies.kt
  5. 62
      experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/DesktopImageStorage.kt
  6. 78
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/storage/FileExtensions.kt
  7. 135
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/storage/IosImageStorage.ios.kt

63
experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/AndroidImageStorage.kt

@ -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]!!
}
}

107
experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/storage/AndroidImageStorage.kt

@ -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)
}
}

3
experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ImageViewer.android.kt

@ -7,6 +7,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import example.imageviewer.*
import example.imageviewer.storage.AndroidImageStorage
import example.imageviewer.style.ImageViewerTheme
import kotlinx.coroutines.CoroutineScope
@ -26,5 +27,5 @@ private fun getDependencies(context: Context, ioScope: CoroutineScope) = object
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
}
}
override val imageStorage: ImageStorage = AndroidImageStorage(pictures, ioScope)
override val imageStorage: ImageStorage = AndroidImageStorage(pictures, ioScope, context)
}

6
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/Dependencies.kt

@ -69,9 +69,9 @@ interface ImageProvider {
}
interface ImageStorage {
fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage)
suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap
suspend fun getImage(picture: PictureData.Camera): ImageBitmap
fun saveImage(pictureData: PictureData.Camera, image: PlatformStorableImage)
suspend fun getThumbnail(pictureData: PictureData.Camera): ImageBitmap
suspend fun getImage(pictureData: PictureData.Camera): ImageBitmap
}
internal val LocalLocalization = staticCompositionLocalOf<Localization> {

62
experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/DesktopImageStorage.kt

@ -2,6 +2,7 @@ package example.imageviewer
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toAwtImage
import androidx.compose.ui.graphics.toComposeImageBitmap
import example.imageviewer.filter.scaleBitmapAspectRatio
@ -16,50 +17,41 @@ class DesktopImageStorage(
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) {
private val largeImages = mutableMapOf<PictureData.Camera, ImageBitmap>()
private val thumbnails = mutableMapOf<PictureData.Camera, ImageBitmap>()
override fun saveImage(pictureData: PictureData.Camera, image: PlatformStorableImage) {
if (image.imageBitmap.width == 0 || image.imageBitmap.height == 0) {
return
}
ioScope.launch {
val awtImage = image.imageBitmap.toAwtImage()
val targetScale = maxOf(
maxStorableImageSizePx.toFloat() / awtImage.width,
maxStorableImageSizePx.toFloat() / awtImage.height
)
largeImages[picture] =
if (targetScale < 1.0) {
scaleBitmapAspectRatio(
awtImage,
width = (awtImage.width * targetScale).toInt(),
height = (awtImage.height * targetScale).toInt(),
).toComposeImageBitmap()
} else {
image.imageBitmap
}
val targetThumbnailScale = maxOf(
storableThumbnailSizePx.toFloat() / awtImage.width,
storableThumbnailSizePx.toFloat() / awtImage.height
)
thumbnails[picture] = scaleBitmapAspectRatio(
awtImage,
width = (awtImage.width * targetThumbnailScale).toInt(),
height = (awtImage.height * targetThumbnailScale).toInt(),
).toComposeImageBitmap()
pictures.add(0, picture)
largeImages[pictureData] = image.imageBitmap.fitInto(maxStorableImageSizePx)
thumbnails[pictureData] = image.imageBitmap.fitInto(storableThumbnailSizePx)
pictures.add(0, pictureData)
}
}
override suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap {
return thumbnails[picture]!!
override suspend fun getThumbnail(pictureData: PictureData.Camera): ImageBitmap {
return thumbnails[pictureData]!!
}
override suspend fun getImage(picture: PictureData.Camera): ImageBitmap {
return largeImages[picture]!!
override suspend fun getImage(pictureData: PictureData.Camera): ImageBitmap {
return largeImages[pictureData]!!
}
}
private fun ImageBitmap.fitInto(px: Int): ImageBitmap {
val targetScale = maxOf(
px.toFloat() / width,
px.toFloat() / height
)
return if (targetScale < 1.0) {
scaleBitmapAspectRatio(
toAwtImage(),
width = (width * targetScale).toInt(),
height = (height * targetScale).toInt()
).toComposeImageBitmap()
} else {
this
}
}

78
experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/storage/FileExtensions.kt

@ -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
)
}

135
experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/storage/IosImageStorage.ios.kt

@ -6,14 +6,12 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
import example.imageviewer.ImageStorage
import example.imageviewer.PlatformStorableImage
import example.imageviewer.model.PictureData
import example.imageviewer.toImageBitmap
import kotlinx.cinterop.CValue
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.useContents
import kotlinx.cinterop.usePinned
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@ -27,113 +25,63 @@ import platform.posix.memcpy
private const val maxStorableImageSizePx = 1200
private const val storableThumbnailSizePx = 180
private const val jpegCompressionQuality = 60
class IosImageStorage(
private val pictures: SnapshotStateList<PictureData>,
private val ioScope: CoroutineScope
) : ImageStorage {
private val savePictureDir = File(NSFileManager.defaultManager.DocumentDirectory, "ImageViewer/takenPhotos/")
private val fileManager = NSFileManager.defaultManager
private val savePictureDir = fileManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
create = true,
appropriateForURL = null,
error = null
)!!.URLByAppendingPathComponent("ImageViewer/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 {
val directoryContent = fileManager.contentsOfDirectoryAtPath(savePictureDir.path!!, null)
if (directoryContent != null) {
if (savePictureDir.isDirectory) {
val files = savePictureDir.listFiles { _, name: String ->
name.endsWith(".json")
} ?: emptyArray()
pictures.addAll(
index = 0,
elements = directoryContent.map { it.toString() }
.filter { it.endsWith(".json") }
elements = files
.map {
val jsonStr = readStringFromFile(it)
Json.Default.decodeFromString<PictureData.Camera>(jsonStr)
it.readText().toCameraMetadata()
}.sortedByDescending {
it.timeStampSeconds
}
)
} else {
fileManager.createDirectoryAtURL(savePictureDir, true, null, null)
savePictureDir.mkdirs()
}
}
private fun makeFileUrl(fileName: String) =
savePictureDir.URLByAppendingPathComponent(fileName)!!
private fun readStringFromFile(fileName: String): String =
NSString.stringWithContentsOfURL(
url = makeFileUrl(fileName),
encoding = NSUTF8StringEncoding,
error = null,
) as String
private fun String.writeToFile(fileName: String) =
writeToURL(makeFileUrl(fileName))
private fun readPngFromFile(fileName: String) =
NSData.dataWithContentsOfURL(makeFileUrl(fileName))
private fun NSData.writeToFile(fileName: String) =
writeToURL(makeFileUrl(fileName), true)
override fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage) {
override fun saveImage(pictureData: PictureData.Camera, image: PlatformStorableImage) {
ioScope.launch {
UIImageJPEGRepresentation(image.rawValue.resizeToThumbnail(), 0.6)
?.writeToFile(picture.thumbnailJpgFile)
pictures.add(0, picture)
UIImageJPEGRepresentation(image.rawValue.resizeToBig(), 0.6)
?.writeToFile(picture.jpgFile)
val jsonStr = Json.Default.encodeToString(picture)
jsonStr.writeToFile(picture.jsonFile)
with(image.rawValue) {
pictureData.jpgFile.writeJpeg(fitInto(maxStorableImageSizePx))
pictureData.thumbnailJpgFile.writeJpeg(fitInto(storableThumbnailSizePx))
}
pictures.add(0, pictureData)
pictureData.jsonFile.writeText(pictureData.toJson())
}
}
override suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap =
ioScope.async {
val jpgRepresentation = readPngFromFile(picture.thumbnailJpgFile)!!
val byteArray: ByteArray = ByteArray(jpgRepresentation.length.toInt()).apply {
usePinned {
memcpy(it.addressOf(0), jpgRepresentation.bytes, jpgRepresentation.length)
}
}
Image.makeFromEncoded(byteArray).toComposeImageBitmap()
}.await()
override suspend fun getImage(picture: PictureData.Camera): ImageBitmap =
ioScope.async {
fun getFileContent() = readPngFromFile(picture.jpgFile)
var jpgRepresentation: NSData? = getFileContent()
while (jpgRepresentation == null) {
yield()
jpgRepresentation = getFileContent()
}
val byteArray: ByteArray = ByteArray(jpgRepresentation.length.toInt()).apply {
usePinned {
memcpy(it.addressOf(0), jpgRepresentation.bytes, jpgRepresentation.length)
}
}
Image.makeFromEncoded(byteArray).toComposeImageBitmap()
}.await()
}
override suspend fun getThumbnail(pictureData: PictureData.Camera): ImageBitmap =
withContext(ioScope.coroutineContext) {
pictureData.thumbnailJpgFile.readBytes().toImageBitmap()
}
private fun UIImage.resizeToThumbnail(): UIImage {
val targetScale = maxOf(
storableThumbnailSizePx.toFloat() / size.useContents { width },
storableThumbnailSizePx.toFloat() / size.useContents { height },
)
val newSize = size.useContents { CGSizeMake(width * targetScale, height * targetScale) }
return resize(newSize)
override suspend fun getImage(pictureData: PictureData.Camera): ImageBitmap =
withContext(ioScope.coroutineContext) {
pictureData.jpgFile.readBytes().toImageBitmap()
}
}
private fun UIImage.resizeToBig(): UIImage {
private fun UIImage.fitInto(px: Int): UIImage {
val targetScale = maxOf(
maxStorableImageSizePx.toFloat() / size.useContents { width },
maxStorableImageSizePx.toFloat() / size.useContents { height },
px.toFloat() / size.useContents { width },
px.toFloat() / size.useContents { height },
)
val newSize = size.useContents { CGSizeMake(width * targetScale, height * targetScale) }
return resize(newSize)
@ -169,12 +117,13 @@ private fun UIImage.resize(targetSize: CValue<CGSize>): UIImage {
return newImage!!
}
private val PictureData.Camera.jpgFile get(): String = id + ".jpg"
private val PictureData.Camera.thumbnailJpgFile get(): String = id + "-thumbnail.jpg"
private val PictureData.Camera.jsonFile get(): String = id + ".json"
private fun String.writeToURL(url: NSURL) = (this as NSString).writeToURL(
url = url,
atomically = true,
encoding = NSUTF8StringEncoding,
error = null
)
private fun PictureData.Camera.toJson(): String =
Json.Default.encodeToString(this)
private fun String.toCameraMetadata(): PictureData.Camera =
Json.Default.decodeFromString(this)
private fun NSURL.writeJpeg(image: UIImage, compressionQuality: Int = jpegCompressionQuality) {
UIImageJPEGRepresentation(image, compressionQuality / 100.0)
?.writeToURL(this, true)
}

Loading…
Cancel
Save