Skip to content

Commit 4b99655

Browse files
authored
Merge pull request #548 from code-payments/feat/make-image-detection-more-robust
feat: increase lookup successes from gallery
2 parents e944a26 + a105cf1 commit 4b99655

7 files changed

Lines changed: 220 additions & 27 deletions

File tree

app/src/main/java/com/getcode/Session.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ data class SessionState(
170170
UiElement.BALANCE
171171
),
172172
val tipCardConnected: Boolean = false,
173+
val fullScreenLoading: Boolean = false,
173174
)
174175

175176
sealed interface SessionEvent {
@@ -1463,16 +1464,34 @@ class Session @Inject constructor(
14631464
fun onImageSelected(
14641465
uri: Uri
14651466
) {
1466-
codeAnalyzer.onCodeScanned = { onCodeScan(it) }
1467+
var scanning = false
1468+
codeAnalyzer.onCodeScanned = {
1469+
scanning = false
1470+
1471+
uiFlow.update { state -> state.copy(fullScreenLoading = false) }
1472+
onCodeScan(it)
1473+
}
14671474
codeAnalyzer.onNoCodeFound = {
1475+
scanning = false
1476+
uiFlow.update { state -> state.copy(fullScreenLoading = false) }
1477+
14681478
TopBarManager.showMessage(
14691479
TopBarManager.TopBarMessage(
14701480
title = resources.getString(R.string.title_noCodeFound),
14711481
message = resources.getString(R.string.subtitle_noCodeFound)
14721482
)
14731483
)
14741484
}
1485+
14751486
codeAnalyzer.analyze(uri)
1487+
scanning = true
1488+
1489+
viewModelScope.launch {
1490+
delay(300)
1491+
if (scanning) {
1492+
uiFlow.update { it.copy(fullScreenLoading = true) }
1493+
}
1494+
}
14761495
}
14771496

14781497
fun onCodeScan(
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.getcode.ui.components
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.fillMaxSize
6+
import androidx.compose.foundation.layout.size
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.ui.Alignment
9+
import androidx.compose.ui.Modifier
10+
import androidx.compose.ui.unit.dp
11+
import com.getcode.theme.CodeTheme
12+
import com.getcode.ui.utils.swallowClicks
13+
14+
@Composable
15+
fun FullScreenProgressSpinner(isLoading: Boolean, modifier: Modifier = Modifier) {
16+
if (isLoading) {
17+
Box(
18+
modifier = modifier
19+
.fillMaxSize()
20+
.background(CodeTheme.colors.surface.copy(alpha = 0.32f))
21+
.swallowClicks()
22+
) {
23+
CodeCircularProgressIndicator(
24+
modifier = Modifier
25+
.size(100.dp)
26+
.align(Alignment.Center)
27+
)
28+
}
29+
}
30+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.getcode.ui.utils
2+
3+
import android.view.WindowManager
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.runtime.DisposableEffect
6+
import androidx.compose.ui.platform.LocalContext
7+
import androidx.compose.ui.platform.LocalLifecycleOwner
8+
import androidx.lifecycle.Lifecycle
9+
import androidx.lifecycle.LifecycleEventObserver
10+
11+
@Composable
12+
fun KeepScreenOn(isEnabled: Boolean) {
13+
val context = LocalContext.current
14+
val lifecycleOwner = LocalLifecycleOwner.current
15+
val window = (context as? androidx.activity.ComponentActivity)?.window
16+
17+
DisposableEffect(lifecycleOwner, isEnabled) {
18+
val observer = LifecycleEventObserver { _, event ->
19+
if (event == Lifecycle.Event.ON_RESUME) {
20+
if (isEnabled) {
21+
// Keep screen on
22+
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
23+
} else {
24+
// Allow screen to turn off
25+
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
26+
}
27+
}
28+
}
29+
30+
lifecycleOwner.lifecycle.addObserver(observer)
31+
32+
onDispose {
33+
lifecycleOwner.lifecycle.removeObserver(observer)
34+
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
35+
}
36+
}
37+
}

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,10 @@ private fun Bitmap.getLuminanceData(): ByteArray {
1919
for (x in 0 until width) {
2020
val pixel = pixelData[y * width + x]
2121

22-
// Extract RGB values from the pixel
2322
val r = (pixel shr 16) and 0xFF
2423
val g = (pixel shr 8) and 0xFF
2524
val b = pixel and 0xFF
2625

27-
// Calculate luminance (grayscale) using a common formula
2826
val luminance = (0.299 * r + 0.587 * g + 0.114 * b).toInt()
2927
luminanceData[y * width + x] = luminance.toByte()
3028
}

app/src/main/java/com/getcode/view/MainActivity.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import com.getcode.LocalPhoneFormatter
2121
import com.getcode.LocalSession
2222
import com.getcode.R
2323
import com.getcode.Session
24-
import com.getcode.SessionState
2524
import com.getcode.analytics.AnalyticsService
2625
import com.getcode.network.TipController
2726
import com.getcode.network.client.Client

app/src/main/java/com/getcode/view/main/scanner/ScannerScreen.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,12 @@ import com.getcode.navigation.screens.EnterTipModal
6363
import com.getcode.navigation.screens.GetKinModal
6464
import com.getcode.navigation.screens.GiveKinModal
6565
import com.getcode.navigation.screens.ShareDownloadLinkModal
66+
import com.getcode.ui.components.FullScreenProgressSpinner
6667
import com.getcode.ui.components.OnLifecycleEvent
6768
import com.getcode.ui.components.PermissionCheck
6869
import com.getcode.ui.components.getPermissionLauncher
6970
import com.getcode.ui.utils.AnimationUtils
71+
import com.getcode.ui.utils.KeepScreenOn
7072
import com.getcode.ui.utils.ModalAnimationSpeed
7173
import com.getcode.ui.utils.measured
7274
import com.getcode.view.login.notificationPermissionCheck
@@ -261,6 +263,9 @@ private fun ScannerContent(
261263
onAction = { handleAction(it) },
262264
)
263265

266+
FullScreenProgressSpinner(dataState.fullScreenLoading)
267+
KeepScreenOn(dataState.fullScreenLoading)
268+
264269
OnLifecycleEvent { _, event ->
265270
when (event) {
266271
Lifecycle.Event.ON_START -> {

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

Lines changed: 128 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.kik.kikx.kikcodes.implementation
33
import android.content.Context
44
import android.graphics.Bitmap
55
import android.graphics.Rect
6+
import android.icu.text.DateFormat
7+
import android.icu.text.SimpleDateFormat
68
import android.net.Uri
79
import android.os.Environment
810
import androidx.camera.core.ImageAnalysis
@@ -22,8 +24,6 @@ import kotlinx.coroutines.Dispatchers
2224
import kotlinx.coroutines.launch
2325
import kotlinx.coroutines.withContext
2426
import java.io.File
25-
import java.text.DateFormat
26-
import java.text.SimpleDateFormat
2727
import java.util.Date
2828
import java.util.Locale
2929
import javax.inject.Inject
@@ -99,7 +99,6 @@ class KikCodeAnalyzer @Inject constructor(
9999

100100
private suspend fun detectCodeInImage(
101101
bitmap: Bitmap,
102-
minSectionSize: Int = 100,
103102
scan: suspend (Bitmap) -> Result<ScannableKikCode>
104103
): Result<ScannableKikCode> = withContext(Dispatchers.Default) {
105104
val destinationRoot =
@@ -111,75 +110,177 @@ class KikCodeAnalyzer @Inject constructor(
111110
}
112111

113112
// Start the recursive division and scanning process
114-
return@withContext divideAndScan(bitmap, destination, minSectionSize, scan)
113+
return@withContext search(bitmap, destination, 100, scan)
115114
}
116115

117-
private suspend fun divideAndScan(
116+
private suspend fun search(
118117
bitmap: Bitmap,
119118
destination: File,
120119
minSectionSize: Int,
121120
scan: suspend (Bitmap) -> Result<ScannableKikCode>,
122121
): Result<ScannableKikCode> {
122+
// try scanning raw
123+
val raw = scan(bitmap)
124+
if (raw.isSuccess) {
125+
debugPrint("Code found raw")
126+
bitmap.recycle()
127+
return raw
128+
} else {
129+
debugPrint("No Code found via raw")
130+
}
131+
132+
// attempt quick lookup by recursively splitting image into quadrants, with increasing zoom levels
123133
val zoomLevels = listOf(1.0, 2.0, 5.0, 10.0)
134+
val recursiveSearch = processBitmapRecursively(
135+
bitmap,
136+
destination,
137+
minSectionSize,
138+
scan,
139+
zoomLevels,
140+
""
141+
)
142+
143+
if (recursiveSearch.isSuccess) {
144+
debugPrint("Code found via recursive lookup")
145+
bitmap.recycle()
146+
return recursiveSearch
147+
} else {
148+
debugPrint("No Code found via recursive lookup")
149+
}
150+
151+
val result = slidingWindowSearch(
152+
bitmap = bitmap,
153+
windowSize = 300,
154+
stepSize = 150,
155+
scan = scan,
156+
zoomLevels = zoomLevels
157+
)
158+
159+
if (result.isSuccess) {
160+
debugPrint("Code found via sliding window")
161+
}
162+
163+
bitmap.recycle()
164+
return result
165+
}
166+
167+
private suspend fun slidingWindowSearch(
168+
bitmap: Bitmap,
169+
windowSize: Int,
170+
stepSize: Int,
171+
scan: suspend (Bitmap) -> Result<ScannableKikCode>,
172+
zoomLevels: List<Double>
173+
): Result<ScannableKikCode> {
174+
val w = bitmap.width
175+
val h = bitmap.height
176+
177+
debugPrint("search: original ${w}x${h}")
178+
179+
for (zoomLevel in zoomLevels) {
180+
val windowWidth = (windowSize * zoomLevel).toInt()
181+
val windowHeight = (windowSize * zoomLevel).toInt()
182+
183+
for (i in 0 until w step stepSize) {
184+
for (j in 0 until h step stepSize) {
185+
val x = i.coerceAtMost(w - windowWidth)
186+
val y = j.coerceAtMost(h - windowHeight)
187+
val width = windowWidth.coerceAtMost(w - x)
188+
val height = windowHeight.coerceAtMost(h - y)
189+
val windowBitmap = Bitmap.createBitmap(
190+
bitmap,
191+
x, y,
192+
width, height
193+
)
194+
195+
debugPrint("search: checking {x: $x, y: $y, w: $width, h: $height} @ $zoomLevel")
196+
val result = scan(windowBitmap)
197+
windowBitmap.recycle()
124198

125-
return processBitmapRecursively(bitmap, destination, minSectionSize, scan, zoomLevels)
199+
if (result.isSuccess) {
200+
debugPrint("search: SUCCESS in {x: $x, y: $y, w: $width, h: $height} @ $zoomLevel")
201+
return result
202+
}
203+
}
204+
}
205+
}
206+
207+
return Result.failure(KikCodeScanner.NoKikCodeFoundException())
126208
}
127209

128210
private suspend fun processBitmapRecursively(
129211
bitmap: Bitmap,
130212
destination: File,
131213
minSectionSize: Int,
132214
scan: suspend (Bitmap) -> Result<ScannableKikCode>,
133-
zoomLevels: List<Double>
215+
zoomLevels: List<Double>,
216+
regionName: String
134217
): Result<ScannableKikCode> {
135218
val width = bitmap.width
136219
val height = bitmap.height
137220

138221
// Base case: If the bitmap is smaller than the minimum section size, process it directly
139222
if (width <= minSectionSize || height <= minSectionSize) {
140-
return scanWithZoomLevels(bitmap, destination, scan, zoomLevels)
223+
return scanWithZoomLevels(bitmap, destination, scan, zoomLevels, regionName)
141224
}
142225

143-
// Scan the center section first
144226
val centerRect = calculateCenterRect(width, height)
145227
val centerBitmap = Bitmap.createBitmap(bitmap, centerRect.left, centerRect.top, centerRect.width(), centerRect.height())
146228

147-
val centerResult = scanWithZoomLevels(centerBitmap, destination, scan, zoomLevels)
229+
val centerResult = scanWithZoomLevels(centerBitmap, destination, scan, zoomLevels, "center")
148230
centerBitmap.recycle()
149231

150232
if (centerResult.isSuccess) {
151233
return centerResult
152234
}
153235

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)
236+
val quadrants = splitIntoQuadrants(bitmap)
157237

158-
val leftResult = processBitmapRecursively(leftHalf, destination, minSectionSize, scan, zoomLevels)
159-
leftHalf.recycle()
238+
// Process each quadrant recursively
239+
for ((quadrantBitmap, name) in quadrants) {
240+
val quadrantResult = processBitmapRecursively(quadrantBitmap, destination, minSectionSize, scan, zoomLevels, name)
241+
quadrantBitmap.recycle()
160242

161-
if (leftResult.isSuccess) {
162-
rightHalf.recycle()
163-
return leftResult
243+
if (quadrantResult.isSuccess) {
244+
return quadrantResult
245+
}
164246
}
165247

166-
val rightResult = processBitmapRecursively(rightHalf, destination, minSectionSize, scan, zoomLevels)
167-
rightHalf.recycle()
248+
return Result.failure(KikCodeScanner.NoKikCodeFoundException())
249+
}
168250

169-
return rightResult
251+
private fun splitIntoQuadrants(bitmap: Bitmap): List<Pair<Bitmap, String>> {
252+
val width = bitmap.width
253+
val height = bitmap.height
254+
val halfWidth = width / 2
255+
val halfHeight = height / 2
256+
257+
val topLeft = Bitmap.createBitmap(bitmap, 0, 0, halfWidth, halfHeight)
258+
val topRight = Bitmap.createBitmap(bitmap, halfWidth, 0, halfWidth, halfHeight)
259+
val bottomLeft = Bitmap.createBitmap(bitmap, 0, halfHeight, halfWidth, halfHeight)
260+
val bottomRight = Bitmap.createBitmap(bitmap, halfWidth, halfHeight, halfWidth, halfHeight)
261+
262+
return listOf(
263+
topLeft to "topLeft",
264+
topRight to "topRight",
265+
bottomLeft to "bottomLeft",
266+
bottomRight to "bottomRight"
267+
)
170268
}
171269

172270
private suspend fun scanWithZoomLevels(
173271
bitmap: Bitmap,
174272
destination: File,
175273
scan: suspend (Bitmap) -> Result<ScannableKikCode>,
176-
zoomLevels: List<Double>
274+
zoomLevels: List<Double>,
275+
regionName: String // Use the region name to give unique filenames
177276
): Result<ScannableKikCode> {
178277
for (zoomLevel in zoomLevels) {
179278
val zoomedBitmap = zoomBitmap(bitmap, zoomLevel)
180279
saveSegment(zoomedBitmap, destination) {
181-
"section_${zoomedBitmap.width}x${zoomedBitmap.height}_zoom${zoomLevel}.png"
280+
val prefix = regionName.ifEmpty { null }?.let { "${it}_"}
281+
"$prefix${zoomedBitmap.width}x${zoomedBitmap.height}_zoom${zoomLevel}.png"
182282
}
283+
183284
val result = scan(zoomedBitmap)
184285

185286
zoomedBitmap.recycle()
@@ -226,4 +327,8 @@ class KikCodeAnalyzer @Inject constructor(
226327
}
227328
}
228329

330+
private fun debugPrint(message: String) {
331+
if (DEBUG) println(message)
332+
}
333+
229334
private const val DEBUG = false

0 commit comments

Comments
 (0)