Skip to content

Commit 79af0de

Browse files
committed
feat(gallery): divide and conquer parsing images from gallery
Signed-off-by: Brandon McAnsh <[email protected]>
1 parent 94e99d5 commit 79af0de

File tree

4 files changed

+188
-74
lines changed

4 files changed

+188
-74
lines changed

app/src/main/java/com/getcode/util/Bitmap.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.getcode.util
22

33
import android.graphics.Bitmap
4+
import com.getcode.utils.ErrorUtils
5+
import java.io.File
6+
import java.io.FileOutputStream
47

58
fun Bitmap.toByteArray(): ByteArray = getLuminanceData()
69

@@ -28,4 +31,24 @@ private fun Bitmap.getLuminanceData(): ByteArray {
2831
}
2932

3033
return luminanceData
34+
}
35+
36+
internal fun Bitmap.save(destination: File, name: () -> String): Boolean {
37+
val filename = name()
38+
if (!destination.exists()) {
39+
destination.mkdirs()
40+
}
41+
val dest = File(destination, filename)
42+
43+
try {
44+
val out = FileOutputStream(dest)
45+
compress(Bitmap.CompressFormat.PNG, 90, out)
46+
out.flush()
47+
out.close()
48+
} catch (e: Exception) {
49+
e.printStackTrace()
50+
ErrorUtils.handleError(e)
51+
return false
52+
}
53+
return true
3154
}

app/src/main/java/com/getcode/util/Context.kt

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -42,51 +42,4 @@ fun Context.uriToBitmap(uri: Uri): Bitmap? {
4242
} else {
4343
MediaStore.Images.Media.getBitmap(contentResolver, uri)
4444
}
45-
}
46-
47-
private fun resizeBitmap(source: Bitmap, maxLength: Int): Bitmap {
48-
try {
49-
if (source.height >= source.width) {
50-
if (source.height <= maxLength) {
51-
return source
52-
}
53-
54-
val aspectRatio = source.width.toDouble() / source.height.toDouble()
55-
val targetWidth = (maxLength * aspectRatio).toInt()
56-
return Bitmap.createScaledBitmap(source, targetWidth, maxLength, false)
57-
} else {
58-
if (source.width <= maxLength) {
59-
return source
60-
}
61-
62-
val aspectRatio = source.height.toDouble() / source.width.toDouble()
63-
val targetHeight = (maxLength * aspectRatio).toInt()
64-
return Bitmap.createScaledBitmap(source, maxLength, targetHeight, false)
65-
}
66-
} catch (e: Exception) {
67-
return source
68-
}
69-
}
70-
71-
72-
@Throws(FileNotFoundException::class, IOException::class)
73-
fun Context.getThumbnail(uri: Uri): Bitmap? {
74-
val onlyBoundsOptions = BitmapFactory.Options()
75-
onlyBoundsOptions.inJustDecodeBounds = true
76-
onlyBoundsOptions.inDither = true //optional
77-
onlyBoundsOptions.inPreferredConfig = Bitmap.Config.ARGB_8888 //optional
78-
getContentResolver().openInputStream(uri)?.use {
79-
BitmapFactory.decodeStream(it, null, onlyBoundsOptions)
80-
}
81-
82-
if ((onlyBoundsOptions.outWidth == -1) || (onlyBoundsOptions.outHeight == -1)) {
83-
return null
84-
}
85-
86-
val bitmapOptions = BitmapFactory.Options()
87-
bitmapOptions.inDither = true //optional
88-
bitmapOptions.inPreferredConfig = Bitmap.Config.ARGB_8888 //
89-
return getContentResolver().openInputStream(uri).use {
90-
BitmapFactory.decodeStream(it, null, bitmapOptions)
91-
}
9245
}

app/src/main/java/com/getcode/view/login/BaseAccessKeyViewModel.kt

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import com.getcode.theme.White
2424
import com.getcode.ui.utils.toAGColor
2525
import com.getcode.util.generateQrCode
2626
import com.getcode.util.resources.ResourceHelper
27+
import com.getcode.util.save
2728
import com.getcode.utils.ErrorUtils
2829
import com.getcode.vendor.Base58
2930
import com.getcode.view.BaseViewModel
@@ -118,24 +119,20 @@ abstract class BaseAccessKeyViewModel(
118119

119120
internal fun saveBitmapToFile(): Boolean {
120121
val bitmap = uiFlow.value.accessKeyBitmap ?: return false
121-
val date: DateFormat = SimpleDateFormat("yyy-MM-dd-h-mm", Locale.CANADA)
122-
123-
val filename = "Code-Recovery-${date.format(Date())}.png"
124-
val sd: File =
125-
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
126-
val dest = File(sd, filename)
127-
128-
try {
129-
val out = FileOutputStream(dest)
130-
bitmap.compress(Bitmap.CompressFormat.PNG, 90, out)
131-
out.flush()
132-
out.close()
133-
} catch (e: Exception) {
134-
ErrorUtils.handleError(e)
135-
return false
122+
val destination = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
123+
val result = bitmap.save(
124+
destination = destination,
125+
name = {
126+
val date: DateFormat = SimpleDateFormat("yyy-MM-dd-h-mm", Locale.CANADA)
127+
"Code-Recovery-${date.format(Date())}.png"
128+
},
129+
)
130+
131+
if (result) {
132+
mediaScanner.scan(destination)
136133
}
137-
mediaScanner.scan(sd)
138-
return true
134+
135+
return result
139136
}
140137

141138
private fun createBitmapForExport(

app/src/main/java/com/kik/kikx/kikcodes/implementation/KikCodeAnalyzer.kt

Lines changed: 151 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package com.kik.kikx.kikcodes.implementation
22

3-
import android.R.attr.bitmap
43
import android.content.Context
54
import android.graphics.Bitmap
5+
import android.graphics.Rect
66
import android.net.Uri
7+
import android.os.Environment
78
import androidx.camera.core.ImageAnalysis
89
import androidx.camera.core.ImageProxy
910
import androidx.compose.runtime.Composable
1011
import androidx.compose.runtime.remember
12+
import com.getcode.media.MediaScanner
13+
import com.getcode.util.save
1114
import com.getcode.util.toByteArray
1215
import com.getcode.util.uriToBitmap
1316
import com.getcode.utils.ErrorUtils
@@ -17,7 +20,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext
1720
import kotlinx.coroutines.CoroutineScope
1821
import kotlinx.coroutines.Dispatchers
1922
import kotlinx.coroutines.launch
20-
import java.nio.ByteBuffer
23+
import kotlinx.coroutines.withContext
24+
import java.io.File
25+
import java.text.DateFormat
26+
import java.text.SimpleDateFormat
27+
import java.util.Date
28+
import java.util.Locale
2129
import javax.inject.Inject
2230

2331

@@ -43,6 +51,9 @@ class KikCodeAnalyzer @Inject constructor(
4351
var onCodeScanned: (ScannableKikCode) -> Unit = { }
4452
var onNoCodeFound: () -> Unit = { }
4553

54+
@Inject
55+
internal lateinit var mediaScanner: MediaScanner
56+
4657
override fun analyze(imageProxy: ImageProxy) {
4758
launch {
4859
scanner.scanKikCode(
@@ -67,22 +78,152 @@ class KikCodeAnalyzer @Inject constructor(
6778
launch {
6879
val bitmap = context.uriToBitmap(uri)
6980
if (bitmap != null) {
70-
scanner.scanKikCode(
71-
bitmap.toByteArray(),
72-
bitmap.width,
73-
bitmap.height,
74-
).onSuccess { result ->
81+
detectCodeInImage(bitmap) {
82+
scanner.scanKikCode(
83+
it.toByteArray(),
84+
it.width,
85+
it.height,
86+
)
87+
}.onSuccess { result ->
7588
onCodeScanned(result)
76-
bitmap.recycle()
7789

7890
}.onFailure { error ->
7991
when (error) {
8092
is KikCodeScanner.NoKikCodeFoundException -> onNoCodeFound()
8193
else -> ErrorUtils.handleError(error)
8294
}
83-
bitmap.recycle()
8495
}
8596
}
8697
}
8798
}
88-
}
99+
100+
private suspend fun detectCodeInImage(
101+
bitmap: Bitmap,
102+
minSectionSize: Int = 100,
103+
scan: suspend (Bitmap) -> Result<ScannableKikCode>
104+
): Result<ScannableKikCode> = withContext(Dispatchers.Default) {
105+
val destinationRoot =
106+
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
107+
val date: DateFormat = SimpleDateFormat("yyyy-MM-dd-H-mm", Locale.CANADA)
108+
val destination = File(destinationRoot, date.format(Date()))
109+
if (!destination.exists()) {
110+
destination.mkdirs()
111+
}
112+
113+
// Start the recursive division and scanning process
114+
return@withContext divideAndScan(bitmap, destination, minSectionSize, scan)
115+
}
116+
117+
private suspend fun divideAndScan(
118+
bitmap: Bitmap,
119+
destination: File,
120+
minSectionSize: Int,
121+
scan: suspend (Bitmap) -> Result<ScannableKikCode>,
122+
): Result<ScannableKikCode> {
123+
val zoomLevels = listOf(1.0, 2.0, 5.0, 10.0)
124+
125+
return processBitmapRecursively(bitmap, destination, minSectionSize, scan, zoomLevels)
126+
}
127+
128+
private suspend fun processBitmapRecursively(
129+
bitmap: Bitmap,
130+
destination: File,
131+
minSectionSize: Int,
132+
scan: suspend (Bitmap) -> Result<ScannableKikCode>,
133+
zoomLevels: List<Double>
134+
): Result<ScannableKikCode> {
135+
val width = bitmap.width
136+
val height = bitmap.height
137+
138+
// Base case: If the bitmap is smaller than the minimum section size, process it directly
139+
if (width <= minSectionSize || height <= minSectionSize) {
140+
return scanWithZoomLevels(bitmap, destination, scan, zoomLevels)
141+
}
142+
143+
// Scan the center section first
144+
val centerRect = calculateCenterRect(width, height)
145+
val centerBitmap = Bitmap.createBitmap(bitmap, centerRect.left, centerRect.top, centerRect.width(), centerRect.height())
146+
147+
val centerResult = scanWithZoomLevels(centerBitmap, destination, scan, zoomLevels)
148+
centerBitmap.recycle()
149+
150+
if (centerResult.isSuccess) {
151+
return centerResult
152+
}
153+
154+
// Divide the bitmap into left and right halves and process recursively
155+
val leftHalf = Bitmap.createBitmap(bitmap, 0, 0, width / 2, height)
156+
val rightHalf = Bitmap.createBitmap(bitmap, width / 2, 0, width / 2, height)
157+
158+
val leftResult = processBitmapRecursively(leftHalf, destination, minSectionSize, scan, zoomLevels)
159+
leftHalf.recycle()
160+
161+
if (leftResult.isSuccess) {
162+
rightHalf.recycle()
163+
return leftResult
164+
}
165+
166+
val rightResult = processBitmapRecursively(rightHalf, destination, minSectionSize, scan, zoomLevels)
167+
rightHalf.recycle()
168+
169+
return rightResult
170+
}
171+
172+
private suspend fun scanWithZoomLevels(
173+
bitmap: Bitmap,
174+
destination: File,
175+
scan: suspend (Bitmap) -> Result<ScannableKikCode>,
176+
zoomLevels: List<Double>
177+
): Result<ScannableKikCode> {
178+
for (zoomLevel in zoomLevels) {
179+
val zoomedBitmap = zoomBitmap(bitmap, zoomLevel)
180+
saveSegment(zoomedBitmap, destination) {
181+
"section_${zoomedBitmap.width}x${zoomedBitmap.height}_zoom${zoomLevel}.png"
182+
}
183+
val result = scan(zoomedBitmap)
184+
185+
zoomedBitmap.recycle()
186+
187+
if (result.isSuccess) {
188+
return result
189+
}
190+
}
191+
192+
return Result.failure(Exception("No successful scan"))
193+
}
194+
195+
private fun saveSegment(bitmap: Bitmap, destination: File, name: () -> String) {
196+
if (DEBUG) {
197+
bitmap.save(destination, name)
198+
}
199+
}
200+
201+
private fun zoomBitmap(bitmap: Bitmap, zoomLevel: Double): Bitmap {
202+
// If zoomLevel is 1.0, just return a copy of the original bitmap (to prevent recycling issues)
203+
if (zoomLevel == 1.0) return Bitmap.createBitmap(bitmap)
204+
205+
val cropWidth = (bitmap.width / zoomLevel).toInt()
206+
val cropHeight = (bitmap.height / zoomLevel).toInt()
207+
val xOffset = (bitmap.width - cropWidth) / 2
208+
val yOffset = (bitmap.height - cropHeight) / 2
209+
210+
val croppedBitmap = Bitmap.createBitmap(bitmap, xOffset, yOffset, cropWidth, cropHeight)
211+
val scaledBitmap = Bitmap.createScaledBitmap(croppedBitmap, bitmap.width, bitmap.height, true)
212+
213+
croppedBitmap.recycle()
214+
return scaledBitmap
215+
}
216+
217+
private fun calculateCenterRect(width: Int, height: Int): Rect {
218+
val centerWidth = width / 2
219+
val centerHeight = height / 2
220+
return Rect(
221+
centerWidth / 2,
222+
centerHeight / 2,
223+
centerWidth + centerWidth / 2,
224+
centerHeight + centerHeight / 2
225+
)
226+
}
227+
}
228+
229+
private const val DEBUG = false

0 commit comments

Comments
 (0)