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