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.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import example.imageviewer.* import example.imageviewer.*
import example.imageviewer.storage.AndroidImageStorage
import example.imageviewer.style.ImageViewerTheme import example.imageviewer.style.ImageViewerTheme
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -26,5 +27,5 @@ private fun getDependencies(context: Context, ioScope: CoroutineScope) = object
Toast.makeText(context, text, Toast.LENGTH_SHORT).show() 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 { interface ImageStorage {
fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage) fun saveImage(pictureData: PictureData.Camera, image: PlatformStorableImage)
suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap suspend fun getThumbnail(pictureData: PictureData.Camera): ImageBitmap
suspend fun getImage(picture: PictureData.Camera): ImageBitmap suspend fun getImage(pictureData: PictureData.Camera): ImageBitmap
} }
internal val LocalLocalization = staticCompositionLocalOf<Localization> { 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.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toAwtImage import androidx.compose.ui.graphics.toAwtImage
import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap
import example.imageviewer.filter.scaleBitmapAspectRatio import example.imageviewer.filter.scaleBitmapAspectRatio
@ -16,50 +17,41 @@ class DesktopImageStorage(
private val pictures: SnapshotStateList<PictureData>, private val pictures: SnapshotStateList<PictureData>,
private val ioScope: CoroutineScope private val ioScope: CoroutineScope
) : ImageStorage { ) : ImageStorage {
val largeImages = mutableMapOf<PictureData.Camera, ImageBitmap>() private val largeImages = mutableMapOf<PictureData.Camera, ImageBitmap>()
val thumbnails = mutableMapOf<PictureData.Camera, ImageBitmap>() private val thumbnails = mutableMapOf<PictureData.Camera, ImageBitmap>()
override fun saveImage(picture: PictureData.Camera, image: PlatformStorableImage) {
override fun saveImage(pictureData: PictureData.Camera, image: PlatformStorableImage) {
if (image.imageBitmap.width == 0 || image.imageBitmap.height == 0) { if (image.imageBitmap.width == 0 || image.imageBitmap.height == 0) {
return return
} }
ioScope.launch { ioScope.launch {
val awtImage = image.imageBitmap.toAwtImage() largeImages[pictureData] = image.imageBitmap.fitInto(maxStorableImageSizePx)
thumbnails[pictureData] = image.imageBitmap.fitInto(storableThumbnailSizePx)
val targetScale = maxOf( pictures.add(0, pictureData)
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)
} }
} }
override suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap { override suspend fun getThumbnail(pictureData: PictureData.Camera): ImageBitmap {
return thumbnails[picture]!! return thumbnails[pictureData]!!
} }
override suspend fun getImage(picture: PictureData.Camera): ImageBitmap { override suspend fun getImage(pictureData: PictureData.Camera): ImageBitmap {
return largeImages[picture]!! 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.ImageStorage
import example.imageviewer.PlatformStorableImage import example.imageviewer.PlatformStorableImage
import example.imageviewer.model.PictureData import example.imageviewer.model.PictureData
import example.imageviewer.toImageBitmap
import kotlinx.cinterop.CValue import kotlinx.cinterop.CValue
import kotlinx.cinterop.addressOf import kotlinx.cinterop.addressOf
import kotlinx.cinterop.useContents import kotlinx.cinterop.useContents
import kotlinx.cinterop.usePinned import kotlinx.cinterop.usePinned
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -27,113 +25,63 @@ import platform.posix.memcpy
private const val maxStorableImageSizePx = 1200 private const val maxStorableImageSizePx = 1200
private const val storableThumbnailSizePx = 180 private const val storableThumbnailSizePx = 180
private const val jpegCompressionQuality = 60
class IosImageStorage( class IosImageStorage(
private val pictures: SnapshotStateList<PictureData>, private val pictures: SnapshotStateList<PictureData>,
private val ioScope: CoroutineScope private val ioScope: CoroutineScope
) : ImageStorage { ) : ImageStorage {
private val savePictureDir = File(NSFileManager.defaultManager.DocumentDirectory, "ImageViewer/takenPhotos/")
private val fileManager = NSFileManager.defaultManager private val PictureData.Camera.jpgFile get() = File(savePictureDir, "$id.jpg")
private val savePictureDir = fileManager.URLForDirectory( private val PictureData.Camera.thumbnailJpgFile get() = File(savePictureDir, "$id-thumbnail.jpg")
directory = NSDocumentDirectory, private val PictureData.Camera.jsonFile get() = File(savePictureDir, "$id.json")
inDomain = NSUserDomainMask,
create = true,
appropriateForURL = null,
error = null
)!!.URLByAppendingPathComponent("ImageViewer/takenPhotos/")!!
init { init {
val directoryContent = fileManager.contentsOfDirectoryAtPath(savePictureDir.path!!, null) if (savePictureDir.isDirectory) {
if (directoryContent != null) { val files = savePictureDir.listFiles { _, name: String ->
name.endsWith(".json")
} ?: emptyArray()
pictures.addAll( pictures.addAll(
index = 0, index = 0,
elements = directoryContent.map { it.toString() } elements = files
.filter { it.endsWith(".json") }
.map { .map {
val jsonStr = readStringFromFile(it) it.readText().toCameraMetadata()
Json.Default.decodeFromString<PictureData.Camera>(jsonStr)
}.sortedByDescending { }.sortedByDescending {
it.timeStampSeconds it.timeStampSeconds
} }
) )
} else { } else {
fileManager.createDirectoryAtURL(savePictureDir, true, null, null) savePictureDir.mkdirs()
} }
} }
private fun makeFileUrl(fileName: String) = override fun saveImage(pictureData: PictureData.Camera, image: PlatformStorableImage) {
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) {
ioScope.launch { ioScope.launch {
UIImageJPEGRepresentation(image.rawValue.resizeToThumbnail(), 0.6) with(image.rawValue) {
?.writeToFile(picture.thumbnailJpgFile) pictureData.jpgFile.writeJpeg(fitInto(maxStorableImageSizePx))
pictures.add(0, picture) pictureData.thumbnailJpgFile.writeJpeg(fitInto(storableThumbnailSizePx))
UIImageJPEGRepresentation(image.rawValue.resizeToBig(), 0.6) }
?.writeToFile(picture.jpgFile) pictures.add(0, pictureData)
val jsonStr = Json.Default.encodeToString(picture) pictureData.jsonFile.writeText(pictureData.toJson())
jsonStr.writeToFile(picture.jsonFile)
} }
} }
override suspend fun getThumbnail(picture: PictureData.Camera): ImageBitmap = override suspend fun getThumbnail(pictureData: PictureData.Camera): ImageBitmap =
ioScope.async { withContext(ioScope.coroutineContext) {
val jpgRepresentation = readPngFromFile(picture.thumbnailJpgFile)!! pictureData.thumbnailJpgFile.readBytes().toImageBitmap()
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()
}
private fun UIImage.resizeToThumbnail(): UIImage { override suspend fun getImage(pictureData: PictureData.Camera): ImageBitmap =
val targetScale = maxOf( withContext(ioScope.coroutineContext) {
storableThumbnailSizePx.toFloat() / size.useContents { width }, pictureData.jpgFile.readBytes().toImageBitmap()
storableThumbnailSizePx.toFloat() / size.useContents { height }, }
)
val newSize = size.useContents { CGSizeMake(width * targetScale, height * targetScale) }
return resize(newSize)
} }
private fun UIImage.resizeToBig(): UIImage { private fun UIImage.fitInto(px: Int): UIImage {
val targetScale = maxOf( val targetScale = maxOf(
maxStorableImageSizePx.toFloat() / size.useContents { width }, px.toFloat() / size.useContents { width },
maxStorableImageSizePx.toFloat() / size.useContents { height }, px.toFloat() / size.useContents { height },
) )
val newSize = size.useContents { CGSizeMake(width * targetScale, height * targetScale) } val newSize = size.useContents { CGSizeMake(width * targetScale, height * targetScale) }
return resize(newSize) return resize(newSize)
@ -169,12 +117,13 @@ private fun UIImage.resize(targetSize: CValue<CGSize>): UIImage {
return newImage!! return newImage!!
} }
private val PictureData.Camera.jpgFile get(): String = id + ".jpg" private fun PictureData.Camera.toJson(): String =
private val PictureData.Camera.thumbnailJpgFile get(): String = id + "-thumbnail.jpg" Json.Default.encodeToString(this)
private val PictureData.Camera.jsonFile get(): String = id + ".json"
private fun String.writeToURL(url: NSURL) = (this as NSString).writeToURL( private fun String.toCameraMetadata(): PictureData.Camera =
url = url, Json.Default.decodeFromString(this)
atomically = true,
encoding = NSUTF8StringEncoding, private fun NSURL.writeJpeg(image: UIImage, compressionQuality: Int = jpegCompressionQuality) {
error = null UIImageJPEGRepresentation(image, compressionQuality / 100.0)
) ?.writeToURL(this, true)
}

Loading…
Cancel
Save