Skip to content

Commit 5433c15

Browse files
authored
Fixed crashes due to large images. (#14)
* chore: android working properly. * chore: ios and jvm image selector working too. * chore: updated docs for `selectImage` function. * chore: updated docs for `selectImage` function. (edited doc) * refactor: made the parameters optional for `selectImage()` function. * refactor: replaced screen size with window size. that means the image will be selected and down-sampled for the app window size instead of screen size of the device. * refactor: removed `reqResolution` optional parameter to make it simple simple to use and eliminate the possibility that an invalid resolution is passed. * bump: bumped version to 1.4.0.
1 parent 244c293 commit 5433c15

10 files changed

Lines changed: 277 additions & 72 deletions

File tree

imageselector/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ plugins {
1111
}
1212

1313
group = "com.wannaverse"
14-
version = "1.3.0"
14+
version = "1.4.0"
1515

1616
kotlin {
1717
androidTarget {

imageselector/src/androidMain/kotlin/com/wannaverse/imageselector/ByteArrayExtension.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ actual suspend fun ByteArray.downSamplingToImageBitmap(
3636
return imageBitmap
3737
}
3838

39-
private fun calculateInSampleSize(options: BitmapFactory.Options, reqHeight: Int, reqWidth: Int): Int {
39+
fun calculateInSampleSize(options: BitmapFactory.Options, reqHeight: Int, reqWidth: Int): Int {
4040
val (height: Int, width: Int) = options.run { outHeight to outWidth }
4141
var inSampleSize = 1
4242
if(height > reqHeight || width > reqWidth) {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.wannaverse.imageselector
2+
3+
import android.content.Context
4+
import android.os.Build
5+
import android.view.WindowManager
6+
7+
actual fun getCurrentWindowSize(): WindowSize {
8+
val windowManager = getAppContext().getSystemService(Context.WINDOW_SERVICE) as WindowManager
9+
10+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
11+
val metrics = windowManager.currentWindowMetrics
12+
val bounds = metrics.bounds
13+
WindowSize(width = bounds.width(), height = bounds.height())
14+
} else {
15+
val display = windowManager.defaultDisplay
16+
val point = android.graphics.Point()
17+
display.getSize(point)
18+
WindowSize(width = point.x, height = point.y)
19+
}
20+
}
Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,48 @@
11
package com.wannaverse.imageselector
22

3+
import android.content.Context
4+
import android.graphics.BitmapFactory
35
import android.net.Uri
46
import androidx.activity.ComponentActivity
57
import androidx.activity.result.ActivityResultLauncher
68
import androidx.activity.result.contract.ActivityResultContracts
9+
import androidx.compose.ui.graphics.asImageBitmap
10+
import kotlinx.coroutines.DelicateCoroutinesApi
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.GlobalScope
13+
import kotlinx.coroutines.launch
714
import kotlinx.coroutines.suspendCancellableCoroutine
8-
import java.io.IOException
915
import kotlin.coroutines.resume
1016

1117
private var imageSelectorLauncher: ActivityResultLauncher<String>? = null
1218
private var pendingContinuation: ((ImageData?) -> Unit)? = null
1319
private var currentActivity: ComponentActivity? = null
20+
private lateinit var imageLoadingState: ((Boolean) -> Unit)
21+
private var reqImageResolution: WindowSize? = null
1422

1523
fun setImageSelectorActivity(activity: ComponentActivity) {
1624
currentActivity = activity
1725
}
1826

27+
fun getAppContext(): Context {
28+
if(currentActivity == null) throw RuntimeException("add `setImageSelectorActivity(this)` in `MainActivity.kt` of your android module!")
29+
return currentActivity!!.applicationContext
30+
}
1931
fun ComponentActivity.registerImageSelectorLauncher() {
2032
imageSelectorLauncher =
2133
registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
22-
val activity = currentActivity ?: return@registerForActivityResult
23-
val result = uri?.let {
24-
try {
25-
val bytes = activity.contentResolver.openInputStream(it)?.use { s -> s.readBytes() }
26-
ImageData(
27-
bytes = bytes
28-
)
29-
} catch (e: IOException) {
30-
e.printStackTrace()
31-
null
32-
}
34+
if (uri != null && reqImageResolution != null) {
35+
decodeSampledBitmapFromUri(this, uri, reqImageResolution!!)
36+
} else {
37+
pendingContinuation?.invoke(null)
38+
pendingContinuation = null
3339
}
34-
pendingContinuation?.invoke(result)
35-
pendingContinuation = null
3640
}
3741
}
3842

39-
actual suspend fun selectImage(): ImageData? = suspendCancellableCoroutine { continuation ->
43+
actual suspend fun selectImage(loadingState: (Boolean) -> Unit): ImageData? = suspendCancellableCoroutine { continuation ->
44+
45+
reqImageResolution = getCurrentWindowSize()
4046
val launcher = imageSelectorLauncher
4147
if (launcher == null) {
4248
continuation.resume(null)
@@ -47,5 +53,38 @@ actual suspend fun selectImage(): ImageData? = suspendCancellableCoroutine { con
4753
continuation.resume(imageData)
4854
}
4955

56+
imageLoadingState = { state ->
57+
loadingState(state)
58+
}
5059
launcher.launch("image/*")
60+
}
61+
62+
@OptIn(DelicateCoroutinesApi::class)
63+
fun decodeSampledBitmapFromUri(context: Context, uri: Uri, reqResolution: WindowSize) = GlobalScope.launch(
64+
Dispatchers.Default) {
65+
imageLoadingState.invoke(true)
66+
var resultData: ImageData? = null
67+
68+
try {
69+
context.contentResolver.openFileDescriptor(uri, "r")?.use { parcelFileDescriptor ->
70+
val fileDescriptor = parcelFileDescriptor.fileDescriptor
71+
72+
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
73+
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, options)
74+
75+
options.inSampleSize = calculateInSampleSize(options, reqResolution.width, reqResolution.height)
76+
options.inJustDecodeBounds = false
77+
78+
val bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor, null, options)
79+
if (bitmap != null) {
80+
resultData = ImageData(bitmap.asImageBitmap().toByteArray())
81+
}
82+
}
83+
} catch (e: Exception) {
84+
e.printStackTrace()
85+
} finally {
86+
pendingContinuation?.invoke(resultData)
87+
pendingContinuation = null
88+
imageLoadingState.invoke(false)
89+
}
5190
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.wannaverse.imageselector
2+
3+
data class WindowSize(val width: Int, val height: Int)
4+
5+
expect fun getCurrentWindowSize(): WindowSize

imageselector/src/commonMain/kotlin/com/wannaverse/imageselector/ImageSelector.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,9 @@ package com.wannaverse.imageselector
33
/**
44
* Opens the platform's image picker and returns the selected [ImageData], or `null`
55
* if the user cancels the selection.
6+
* The image isn't loaded in the memory in its actual size, it's downsampled according to the
7+
* current window size of the app and then loaded into memory as [ImageData] which is lower than the
8+
* actual size eliminating the possibility of `OutOfMemoryError`
9+
* @param loadingState a callback to pass true if image is processing and false if it has finished processing the image.
610
*/
7-
expect suspend fun selectImage(): ImageData?
11+
expect suspend fun selectImage(loadingState: (Boolean) -> Unit = {}): ImageData?
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.wannaverse.imageselector
2+
3+
import kotlinx.cinterop.ExperimentalForeignApi
4+
import kotlinx.cinterop.useContents
5+
import platform.UIKit.UIApplication
6+
import platform.UIKit.UIScreen
7+
import platform.UIKit.UIWindow
8+
import platform.UIKit.UIWindowScene
9+
10+
@OptIn(ExperimentalForeignApi::class)
11+
actual fun getCurrentWindowSize(): WindowSize {
12+
val activeScene = UIApplication.sharedApplication.connectedScenes
13+
.filterIsInstance<UIWindowScene>()
14+
.firstOrNull()
15+
16+
val windowBounds = activeScene?.windows?.filterIsInstance<UIWindow>()
17+
?.firstOrNull { it.isKeyWindow() }?.bounds
18+
?: UIScreen.mainScreen.bounds
19+
20+
val scale = UIScreen.mainScreen.scale
21+
22+
val widthPx = (windowBounds.useContents { size.width } * scale).toInt()
23+
val heightPx = (windowBounds.useContents { size.height } * scale).toInt()
24+
25+
return WindowSize(width = widthPx, height = heightPx)
26+
}
Lines changed: 75 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,103 @@
11
package com.wannaverse.imageselector
22

3+
import kotlinx.cinterop.BetaInteropApi
34
import kotlinx.cinterop.ExperimentalForeignApi
4-
import kotlinx.cinterop.memScoped
5-
import kotlinx.cinterop.refTo
5+
import kotlinx.cinterop.addressOf
6+
import kotlinx.cinterop.autoreleasepool
7+
import kotlinx.cinterop.useContents
8+
import kotlinx.cinterop.usePinned
9+
import kotlinx.coroutines.CoroutineScope
10+
import kotlinx.coroutines.Dispatchers
11+
import kotlinx.coroutines.launch
612
import kotlinx.coroutines.suspendCancellableCoroutine
7-
import platform.Foundation.NSData
13+
import kotlinx.coroutines.withContext
14+
import platform.UIKit.UIApplication
15+
import platform.UIKit.UIImage
816
import platform.UIKit.UIImagePickerController
9-
import platform.UIKit.UIImagePickerControllerSourceType
1017
import platform.UIKit.UIImagePickerControllerDelegateProtocol
11-
import platform.UIKit.UINavigationControllerDelegateProtocol
1218
import platform.UIKit.UIImagePickerControllerOriginalImage
13-
import platform.UIKit.UIImageJPEGRepresentation
14-
import platform.UIKit.UIApplication
15-
import platform.UIKit.UIImage
19+
import platform.UIKit.UIImagePickerControllerSourceType
20+
import platform.UIKit.UINavigationControllerDelegateProtocol
1621
import platform.darwin.NSObject
17-
import platform.posix.memcpy
1822
import kotlin.coroutines.resume
1923

2024
private var activePickerDelegate: NSObject? = null
2125
private var activePicker: UIImagePickerController? = null
2226

23-
actual suspend fun selectImage(): ImageData? = suspendCancellableCoroutine { continuation ->
27+
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
28+
actual suspend fun selectImage(
29+
loadingState: (Boolean) -> Unit
30+
): ImageData? = suspendCancellableCoroutine { continuation ->
2431
val picker = UIImagePickerController().apply {
25-
sourceType =
26-
UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypePhotoLibrary
32+
sourceType = UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypePhotoLibrary
2733
allowsEditing = false
2834
}
29-
30-
val delegate = object : NSObject(), UIImagePickerControllerDelegateProtocol, UINavigationControllerDelegateProtocol {
35+
val reqResolution = getCurrentWindowSize()
36+
val delegate = object : NSObject(), UIImagePickerControllerDelegateProtocol,
37+
UINavigationControllerDelegateProtocol {
3138
override fun imagePickerController(
3239
picker: UIImagePickerController,
3340
didFinishPickingMediaWithInfo: Map<Any?, *>
3441
) {
35-
val image = didFinishPickingMediaWithInfo[UIImagePickerControllerOriginalImage] as? UIImage
36-
val data = image?.toJpegData(quality = 1.0)
37-
val bytes = data?.toByteArray()
42+
loadingState(true)
3843

39-
picker.dismissViewControllerAnimated(true) {
40-
clearActivePickerReferences()
41-
continuation.resume(bytes?.let { ImageData(bytes = it) })
44+
val originalImage = didFinishPickingMediaWithInfo[UIImagePickerControllerOriginalImage] as? UIImage
45+
46+
if (originalImage == null) {
47+
picker.dismissViewControllerAnimated(true) {
48+
clearActivePickerReferences()
49+
loadingState(false)
50+
continuation.resume(null)
51+
}
52+
return
4253
}
43-
}
4454

55+
CoroutineScope(Dispatchers.Default).launch {
56+
var resultBytes: ByteArray? = null
57+
58+
autoreleasepool {
59+
val (srcWidth, srcHeight) = originalImage.size.useContents { width to height }
60+
61+
val scaleFactor = minOf(reqResolution.width.toDouble() / srcWidth, reqResolution.height.toDouble() / srcHeight)
62+
val finalScale = if (scaleFactor < 1.0) scaleFactor else 1.0
63+
val targetWidth = srcWidth * finalScale
64+
val targetHeight = srcHeight * finalScale
65+
66+
val targetSize = platform.CoreGraphics.CGSizeMake(targetWidth, targetHeight)
67+
68+
platform.UIKit.UIGraphicsBeginImageContextWithOptions(targetSize, false, 1.0)
69+
originalImage.drawInRect(platform.CoreGraphics.CGRectMake(0.0, 0.0, targetWidth, targetHeight))
70+
val downsampledImage = platform.UIKit.UIGraphicsGetImageFromCurrentImageContext()
71+
platform.UIKit.UIGraphicsEndImageContext()
72+
73+
if (downsampledImage != null) {
74+
val nsData = platform.UIKit.UIImageJPEGRepresentation(downsampledImage, 0.85)
75+
if (nsData != null) {
76+
val byteArray = ByteArray(nsData.length.toInt())
77+
if (byteArray.isNotEmpty()) {
78+
byteArray.usePinned { pinned ->
79+
platform.posix.memcpy(pinned.addressOf(0), nsData.bytes, nsData.length)
80+
}
81+
}
82+
resultBytes = byteArray
83+
}
84+
}
85+
}
86+
87+
withContext(Dispatchers.Main) {
88+
picker.dismissViewControllerAnimated(true) {
89+
clearActivePickerReferences()
90+
loadingState(false)
91+
92+
continuation.resume(resultBytes?.let { ImageData(bytes = it) })
93+
}
94+
}
95+
}
96+
}
4597
override fun imagePickerControllerDidCancel(picker: UIImagePickerController) {
4698
picker.dismissViewControllerAnimated(true) {
4799
clearActivePickerReferences()
100+
loadingState(false)
48101
continuation.resume(null)
49102
}
50103
}
@@ -73,18 +126,4 @@ private fun clearActivePickerReferences() {
73126
activePicker?.delegate = null
74127
activePicker = null
75128
activePickerDelegate = null
76-
}
77-
78-
private fun UIImage.toJpegData(quality: Double = 1.0): NSData? {
79-
return UIImageJPEGRepresentation(this, quality)
80-
}
81-
82-
@OptIn(ExperimentalForeignApi::class)
83-
private fun NSData.toByteArray(): ByteArray {
84-
val bytes = ByteArray(this.length.toInt())
85-
memScoped {
86-
val rawPtr = bytes.refTo(0).getPointer(this)
87-
memcpy(rawPtr, this@toByteArray.bytes, this@toByteArray.length)
88-
}
89-
return bytes
90-
}
129+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.wannaverse.imageselector
2+
3+
import java.awt.Window
4+
5+
actual fun getCurrentWindowSize(): WindowSize {
6+
val activeWindow = Window.getWindows().firstOrNull { it.isFocused }
7+
?: Window.getWindows().firstOrNull()
8+
9+
return if (activeWindow != null) {
10+
WindowSize(
11+
width = activeWindow.width,
12+
height = activeWindow.height
13+
)
14+
} else {
15+
val screenSize = java.awt.Toolkit.getDefaultToolkit().screenSize
16+
WindowSize(width = screenSize.width, height = screenSize.height)
17+
}
18+
}

0 commit comments

Comments
 (0)