@@ -7,7 +7,6 @@ import android.content.pm.ShortcutManager
77import android.graphics.ImageDecoder
88import android.os.Build
99import android.os.Bundle
10- import android.provider.MediaStore
1110import android.util.Size
1211import android.view.Menu
1312import 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}
0 commit comments