Skip to content

Commit 1802556

Browse files
committed
review: await import before finishing, downsample imported images, stateful torch a11y
Addresses CodeRabbit findings on the scanner: - Image import now decodes bounded to MAX_IMPORT_DIMEN (2048px longer edge) via ImageDecoder target size (P+) / BitmapFactory inSampleSize (pre-P), so large gallery images can't OOM before ML Kit runs; bitmaps are recycled after scanning. - Split parsing into a suspend importText() that returns the created-profile count; the multi-image flow awaits it and only finish()es after imports complete (and only when a profile was actually created, not merely when QR text was found). Live scan latches 'finished' then imports in the background. - Torch FAB content-description is now stateful (turn on / turn off) for screen readers.
1 parent 392d90f commit 1802556

2 files changed

Lines changed: 106 additions & 52 deletions

File tree

app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt

Lines changed: 104 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import android.content.pm.ShortcutManager
77
import android.graphics.ImageDecoder
88
import android.os.Build
99
import android.os.Bundle
10-
import android.provider.MediaStore
1110
import android.util.Size
1211
import android.view.Menu
1312
import android.view.MenuItem
@@ -132,6 +131,7 @@ class ScannerActivity : ThemedActivity() {
132131
binding.fabTorch.visibility =
133132
if (camera?.cameraInfo?.hasFlashUnit() == true) android.view.View.VISIBLE
134133
else android.view.View.GONE
134+
binding.fabTorch.contentDescription = getString(R.string.scan_torch_turn_on)
135135
} catch (e: Exception) {
136136
Logs.w(e)
137137
Toast.makeText(app, e.readableMessage, Toast.LENGTH_LONG).show()
@@ -150,8 +150,12 @@ class ScannerActivity : ThemedActivity() {
150150
val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
151151
barcodeScanner.process(input)
152152
.addOnSuccessListener { barcodes ->
153-
barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue?.let {
154-
onText(it, multi = false)
153+
val text = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue
154+
if (text != null && !finished.getAndSet(true)) {
155+
// First successful scan wins: finish the activity and import in the
156+
// background (the activity-scoped toast in onDestroy reports the count).
157+
finish()
158+
runOnDefaultDispatcher { importText(text) }
155159
}
156160
}
157161
.addOnFailureListener { Logs.w(it) }
@@ -166,6 +170,10 @@ class ScannerActivity : ThemedActivity() {
166170
binding.fabTorch.setImageResource(
167171
if (torchOn) R.drawable.ic_baseline_flash_on_24 else R.drawable.ic_baseline_flash_off_24
168172
)
173+
// Keep the accessibility label describing the action the button will perform.
174+
binding.fabTorch.contentDescription = getString(
175+
if (torchOn) R.string.scan_torch_turn_off else R.string.scan_torch_turn_on
176+
)
169177
}
170178

171179
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -187,30 +195,25 @@ class ScannerActivity : ThemedActivity() {
187195
) { uris ->
188196
if (uris.isEmpty()) return@registerForActivityResult
189197
runOnDefaultDispatcher {
190-
var found = false
198+
var foundQr = false
199+
var imported = 0
191200
try {
192201
uris.forEachTry { uri ->
193-
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
194-
ImageDecoder.decodeBitmap(
195-
ImageDecoder.createSource(contentResolver, uri)
196-
) { decoder, _, _ ->
197-
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
198-
decoder.isMutableRequired = true
199-
}
200-
} else {
201-
@Suppress("DEPRECATION")
202-
MediaStore.Images.Media.getBitmap(contentResolver, uri)
202+
val bitmap = decodeBoundedBitmap(uri)
203+
val barcodes = try {
204+
com.google.android.gms.tasks.Tasks.await(
205+
barcodeScanner.process(InputImage.fromBitmap(bitmap, 0))
206+
)
207+
} finally {
208+
bitmap.recycle()
203209
}
204-
val barcodes = com.google.android.gms.tasks.Tasks.await(
205-
barcodeScanner.process(InputImage.fromBitmap(bitmap, 0))
206-
)
207210
val text = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue
208211
if (text != null) {
209-
found = true
210-
onText(text, multi = true)
212+
foundQr = true
213+
imported += importText(text)
211214
}
212215
}
213-
if (!found) {
216+
if (!foundQr) {
214217
onMainDispatcher {
215218
Toast.makeText(app, R.string.scan_no_qr_found, Toast.LENGTH_LONG).show()
216219
}
@@ -221,48 +224,91 @@ class ScannerActivity : ThemedActivity() {
221224
Toast.makeText(app, e.readableMessage, Toast.LENGTH_LONG).show()
222225
}
223226
} finally {
224-
if (found) onMainDispatcher { finish() }
227+
// Only finish once the import actually completed (importText is awaited
228+
// above) and at least one profile was created.
229+
if (imported > 0) onMainDispatcher { finish() }
225230
}
226231
}
227232
}
228233

229234
/**
230-
* Handle a decoded QR payload. For live scanning (multi = false) only the first
231-
* result is accepted and the activity finishes; for image import (multi = true)
232-
* every selected image can contribute profiles.
235+
* Decode a user-selected image bounded to [MAX_IMPORT_DIMEN] on its longer edge.
236+
* Arbitrary gallery images can be huge; decoding at full resolution risks an OOM
237+
* before any exception handler runs. ML Kit detects QR codes fine at this size.
233238
*/
234-
private fun onText(text: String, multi: Boolean) {
235-
if (!multi && finished.getAndSet(true)) return
236-
if (!multi) finish()
237-
runOnDefaultDispatcher {
238-
try {
239-
val results = RawUpdater.parseRaw(text)
240-
if (!results.isNullOrEmpty()) {
241-
val currentGroupId = DataStore.selectedGroupForImport()
242-
if (DataStore.selectedGroup != currentGroupId) {
243-
DataStore.selectedGroup = currentGroupId
244-
}
245-
for (profile in results) {
246-
ProfileManager.createProfile(currentGroupId, profile)
247-
importedN.addAndGet(1)
248-
}
249-
} else {
250-
onMainDispatcher {
251-
Toast.makeText(app, R.string.action_import_err, Toast.LENGTH_SHORT).show()
252-
}
239+
private fun decodeBoundedBitmap(uri: android.net.Uri): android.graphics.Bitmap {
240+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
241+
ImageDecoder.decodeBitmap(
242+
ImageDecoder.createSource(contentResolver, uri)
243+
) { decoder, info, _ ->
244+
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
245+
decoder.isMutableRequired = true
246+
val longer = maxOf(info.size.width, info.size.height)
247+
if (longer > MAX_IMPORT_DIMEN) {
248+
val scale = MAX_IMPORT_DIMEN.toFloat() / longer
249+
decoder.setTargetSize(
250+
(info.size.width * scale).toInt().coerceAtLeast(1),
251+
(info.size.height * scale).toInt().coerceAtLeast(1)
252+
)
253253
}
254-
} catch (e: SubscriptionFoundException) {
255-
startActivity(Intent(this@ScannerActivity, MainActivity::class.java).apply {
256-
action = Intent.ACTION_VIEW
257-
data = e.link.toUri()
258-
})
259-
} catch (e: Throwable) {
260-
Logs.w(e)
254+
}
255+
} else {
256+
// Pre-P: read bounds first, then decode with an inSampleSize so the full-res
257+
// bitmap is never materialized.
258+
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
259+
contentResolver.openInputStream(uri)?.use {
260+
android.graphics.BitmapFactory.decodeStream(it, null, bounds)
261+
}
262+
var sample = 1
263+
val longer = maxOf(bounds.outWidth, bounds.outHeight)
264+
while (longer / sample > MAX_IMPORT_DIMEN) sample *= 2
265+
val opts = android.graphics.BitmapFactory.Options().apply { inSampleSize = sample }
266+
(contentResolver.openInputStream(uri)?.use {
267+
android.graphics.BitmapFactory.decodeStream(it, null, opts)
268+
}) ?: error("Cannot decode image")
269+
}
270+
}
271+
272+
/**
273+
* Parse a decoded QR payload and create the resulting profile(s). Suspends until the
274+
* import completes and returns the number of profiles created (0 if none / on error),
275+
* so callers can wait before finishing. A SubscriptionFoundException opens the
276+
* subscription import flow instead.
277+
*/
278+
private suspend fun importText(text: String): Int {
279+
return try {
280+
val results = RawUpdater.parseRaw(text)
281+
if (!results.isNullOrEmpty()) {
282+
val currentGroupId = DataStore.selectedGroupForImport()
283+
if (DataStore.selectedGroup != currentGroupId) {
284+
DataStore.selectedGroup = currentGroupId
285+
}
286+
var n = 0
287+
for (profile in results) {
288+
ProfileManager.createProfile(currentGroupId, profile)
289+
n++
290+
}
291+
importedN.addAndGet(n)
292+
n
293+
} else {
261294
onMainDispatcher {
262-
val msg = getString(R.string.action_import_err) + "\n" + e.readableMessage
263-
Toast.makeText(app, msg, Toast.LENGTH_SHORT).show()
295+
Toast.makeText(app, R.string.action_import_err, Toast.LENGTH_SHORT).show()
264296
}
297+
0
298+
}
299+
} catch (e: SubscriptionFoundException) {
300+
startActivity(Intent(this@ScannerActivity, MainActivity::class.java).apply {
301+
action = Intent.ACTION_VIEW
302+
data = e.link.toUri()
303+
})
304+
0
305+
} catch (e: Throwable) {
306+
Logs.w(e)
307+
onMainDispatcher {
308+
val msg = getString(R.string.action_import_err) + "\n" + e.readableMessage
309+
Toast.makeText(app, msg, Toast.LENGTH_SHORT).show()
265310
}
311+
0
266312
}
267313
}
268314

@@ -276,4 +322,10 @@ class ScannerActivity : ThemedActivity() {
276322
Toast.makeText(app, text, Toast.LENGTH_LONG).show()
277323
}
278324
}
325+
326+
companion object {
327+
// Bound for decoding imported images; large enough for ML Kit to read dense QR
328+
// codes, small enough to avoid OOM on arbitrary full-resolution gallery photos.
329+
private const val MAX_IMPORT_DIMEN = 2048
330+
}
279331
}

app/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@
267267
<string name="action_import_file">Import from file</string>
268268
<string name="scan_qr_hint">Point the camera at a QR code</string>
269269
<string name="scan_toggle_torch">Toggle flashlight</string>
270+
<string name="scan_torch_turn_on">Turn flashlight on</string>
271+
<string name="scan_torch_turn_off">Turn flashlight off</string>
270272
<string name="scan_no_qr_found">No QR code found in the selected image</string>
271273
<string name="action_export_msg">Successfully export!</string>
272274
<string name="action_export_err">Failed to export.</string>

0 commit comments

Comments
 (0)