Navaron Bracke
Committed by GitHub

Merge pull request #1139 from navaronbracke/fix_barcode_scanner_leak

fix: fix barcode scanner leak on Android
  1 +## NEXT
  2 +* Fixed a leak of the barcode scanner on Android.
  3 +
1 ## 5.1.1 4 ## 5.1.1
2 * This release fixes an issue with automatic starts in the examples. 5 * This release fixes an issue with automatic starts in the examples.
3 6
@@ -13,6 +13,7 @@ import android.os.Looper @@ -13,6 +13,7 @@ import android.os.Looper
13 import android.util.Size 13 import android.util.Size
14 import android.view.Surface 14 import android.view.Surface
15 import android.view.WindowManager 15 import android.view.WindowManager
  16 +import androidx.annotation.VisibleForTesting
16 import androidx.camera.core.Camera 17 import androidx.camera.core.Camera
17 import androidx.camera.core.CameraSelector 18 import androidx.camera.core.CameraSelector
18 import androidx.camera.core.ExperimentalGetImage 19 import androidx.camera.core.ExperimentalGetImage
@@ -20,12 +21,12 @@ import androidx.camera.core.ImageAnalysis @@ -20,12 +21,12 @@ import androidx.camera.core.ImageAnalysis
20 import androidx.camera.core.ImageProxy 21 import androidx.camera.core.ImageProxy
21 import androidx.camera.core.Preview 22 import androidx.camera.core.Preview
22 import androidx.camera.core.TorchState 23 import androidx.camera.core.TorchState
23 -import androidx.camera.core.resolutionselector.AspectRatioStrategy  
24 import androidx.camera.core.resolutionselector.ResolutionSelector 24 import androidx.camera.core.resolutionselector.ResolutionSelector
25 import androidx.camera.core.resolutionselector.ResolutionStrategy 25 import androidx.camera.core.resolutionselector.ResolutionStrategy
26 import androidx.camera.lifecycle.ProcessCameraProvider 26 import androidx.camera.lifecycle.ProcessCameraProvider
27 import androidx.core.content.ContextCompat 27 import androidx.core.content.ContextCompat
28 import androidx.lifecycle.LifecycleOwner 28 import androidx.lifecycle.LifecycleOwner
  29 +import com.google.mlkit.vision.barcode.BarcodeScanner
29 import com.google.mlkit.vision.barcode.BarcodeScannerOptions 30 import com.google.mlkit.vision.barcode.BarcodeScannerOptions
30 import com.google.mlkit.vision.barcode.BarcodeScanning 31 import com.google.mlkit.vision.barcode.BarcodeScanning
31 import com.google.mlkit.vision.barcode.common.Barcode 32 import com.google.mlkit.vision.barcode.common.Barcode
@@ -37,12 +38,12 @@ import io.flutter.view.TextureRegistry @@ -37,12 +38,12 @@ import io.flutter.view.TextureRegistry
37 import java.io.ByteArrayOutputStream 38 import java.io.ByteArrayOutputStream
38 import kotlin.math.roundToInt 39 import kotlin.math.roundToInt
39 40
40 -  
41 class MobileScanner( 41 class MobileScanner(
42 private val activity: Activity, 42 private val activity: Activity,
43 private val textureRegistry: TextureRegistry, 43 private val textureRegistry: TextureRegistry,
44 private val mobileScannerCallback: MobileScannerCallback, 44 private val mobileScannerCallback: MobileScannerCallback,
45 - private val mobileScannerErrorCallback: MobileScannerErrorCallback 45 + private val mobileScannerErrorCallback: MobileScannerErrorCallback,
  46 + private val barcodeScannerFactory: (options: BarcodeScannerOptions?) -> BarcodeScanner = ::defaultBarcodeScannerFactory,
46 ) { 47 ) {
47 48
48 /// Internal variables 49 /// Internal variables
@@ -50,7 +51,7 @@ class MobileScanner( @@ -50,7 +51,7 @@ class MobileScanner(
50 private var camera: Camera? = null 51 private var camera: Camera? = null
51 private var preview: Preview? = null 52 private var preview: Preview? = null
52 private var textureEntry: TextureRegistry.SurfaceTextureEntry? = null 53 private var textureEntry: TextureRegistry.SurfaceTextureEntry? = null
53 - private var scanner = BarcodeScanning.getClient() 54 + private var scanner: BarcodeScanner? = null
54 private var lastScanned: List<String?>? = null 55 private var lastScanned: List<String?>? = null
55 private var scannerTimeout = false 56 private var scannerTimeout = false
56 private var displayListener: DisplayManager.DisplayListener? = null 57 private var displayListener: DisplayManager.DisplayListener? = null
@@ -61,6 +62,15 @@ class MobileScanner( @@ -61,6 +62,15 @@ class MobileScanner(
61 private var detectionTimeout: Long = 250 62 private var detectionTimeout: Long = 250
62 private var returnImage = false 63 private var returnImage = false
63 64
  65 + companion object {
  66 + /**
  67 + * Create a barcode scanner from the given options.
  68 + */
  69 + fun defaultBarcodeScannerFactory(options: BarcodeScannerOptions?) : BarcodeScanner {
  70 + return if (options == null) BarcodeScanning.getClient() else BarcodeScanning.getClient(options)
  71 + }
  72 + }
  73 +
64 /** 74 /**
65 * callback for the camera. Every frame is passed through this function. 75 * callback for the camera. Every frame is passed through this function.
66 */ 76 */
@@ -76,76 +86,75 @@ class MobileScanner( @@ -76,76 +86,75 @@ class MobileScanner(
76 scannerTimeout = true 86 scannerTimeout = true
77 } 87 }
78 88
79 - scanner.process(inputImage)  
80 - .addOnSuccessListener { barcodes -> 89 + scanner?.let {
  90 + it.process(inputImage).addOnSuccessListener { barcodes ->
81 if (detectionSpeed == DetectionSpeed.NO_DUPLICATES) { 91 if (detectionSpeed == DetectionSpeed.NO_DUPLICATES) {
82 - val newScannedBarcodes = barcodes.mapNotNull { barcode -> barcode.rawValue }.sorted() 92 + val newScannedBarcodes = barcodes.mapNotNull {
  93 + barcode -> barcode.rawValue
  94 + }.sorted()
  95 +
83 if (newScannedBarcodes == lastScanned) { 96 if (newScannedBarcodes == lastScanned) {
84 // New scanned is duplicate, returning 97 // New scanned is duplicate, returning
85 return@addOnSuccessListener 98 return@addOnSuccessListener
86 } 99 }
87 - if (newScannedBarcodes.isNotEmpty()) lastScanned = newScannedBarcodes 100 + if (newScannedBarcodes.isNotEmpty()) {
  101 + lastScanned = newScannedBarcodes
  102 + }
88 } 103 }
89 104
90 val barcodeMap: MutableList<Map<String, Any?>> = mutableListOf() 105 val barcodeMap: MutableList<Map<String, Any?>> = mutableListOf()
91 106
92 for (barcode in barcodes) { 107 for (barcode in barcodes) {
93 - if (scanWindow != null) {  
94 - val match = isBarcodeInScanWindow(scanWindow!!, barcode, imageProxy)  
95 - if (!match) {  
96 - continue  
97 - } else {  
98 - barcodeMap.add(barcode.data)  
99 - }  
100 - } else { 108 + if (scanWindow == null) {
101 barcodeMap.add(barcode.data) 109 barcodeMap.add(barcode.data)
  110 + continue
102 } 111 }
103 - }  
104 -  
105 -  
106 - if (barcodeMap.isNotEmpty()) {  
107 - if (returnImage) {  
108 112
109 - val bitmap = Bitmap.createBitmap(mediaImage.width, mediaImage.height, Bitmap.Config.ARGB_8888)  
110 -  
111 - val imageFormat = YuvToRgbConverter(activity.applicationContext) 113 + if (isBarcodeInScanWindow(scanWindow!!, barcode, imageProxy)) {
  114 + barcodeMap.add(barcode.data)
  115 + }
  116 + }
112 117
113 - imageFormat.yuvToRgb(mediaImage, bitmap) 118 + if (barcodeMap.isEmpty()) {
  119 + return@addOnSuccessListener
  120 + }
114 121
115 - val bmResult = rotateBitmap(bitmap, camera?.cameraInfo?.sensorRotationDegrees?.toFloat() ?: 90f) 122 + if (!returnImage) {
  123 + mobileScannerCallback(
  124 + barcodeMap,
  125 + null,
  126 + null,
  127 + null
  128 + )
  129 + return@addOnSuccessListener
  130 + }
116 131
117 - val stream = ByteArrayOutputStream()  
118 - bmResult.compress(Bitmap.CompressFormat.PNG, 100, stream)  
119 - val byteArray = stream.toByteArray()  
120 - val bmWidth = bmResult.width  
121 - val bmHeight = bmResult.height  
122 - bmResult.recycle() 132 + val bitmap = Bitmap.createBitmap(mediaImage.width, mediaImage.height, Bitmap.Config.ARGB_8888)
  133 + val imageFormat = YuvToRgbConverter(activity.applicationContext)
123 134
  135 + imageFormat.yuvToRgb(mediaImage, bitmap)
124 136
125 - mobileScannerCallback(  
126 - barcodeMap,  
127 - byteArray,  
128 - bmWidth,  
129 - bmHeight  
130 - ) 137 + val bmResult = rotateBitmap(bitmap, camera?.cameraInfo?.sensorRotationDegrees?.toFloat() ?: 90f)
131 138
132 - } else { 139 + val stream = ByteArrayOutputStream()
  140 + bmResult.compress(Bitmap.CompressFormat.PNG, 100, stream)
  141 + val byteArray = stream.toByteArray()
  142 + val bmWidth = bmResult.width
  143 + val bmHeight = bmResult.height
  144 + bmResult.recycle()
133 145
134 - mobileScannerCallback(  
135 - barcodeMap,  
136 - null,  
137 - null,  
138 - null  
139 - )  
140 - }  
141 - }  
142 - }  
143 - .addOnFailureListener { e -> 146 + mobileScannerCallback(
  147 + barcodeMap,
  148 + byteArray,
  149 + bmWidth,
  150 + bmHeight
  151 + )
  152 + }.addOnFailureListener { e ->
144 mobileScannerErrorCallback( 153 mobileScannerErrorCallback(
145 e.localizedMessage ?: e.toString() 154 e.localizedMessage ?: e.toString()
146 ) 155 )
147 - }  
148 - .addOnCompleteListener { imageProxy.close() } 156 + }.addOnCompleteListener { imageProxy.close() }
  157 + }
149 158
150 if (detectionSpeed == DetectionSpeed.NORMAL) { 159 if (detectionSpeed == DetectionSpeed.NORMAL) {
151 // Set timer and continue 160 // Set timer and continue
@@ -161,7 +170,6 @@ class MobileScanner( @@ -161,7 +170,6 @@ class MobileScanner(
161 return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) 170 return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
162 } 171 }
163 172
164 -  
165 // scales the scanWindow to the provided inputImage and checks if that scaled 173 // scales the scanWindow to the provided inputImage and checks if that scaled
166 // scanWindow contains the barcode 174 // scanWindow contains the barcode
167 private fun isBarcodeInScanWindow( 175 private fun isBarcodeInScanWindow(
@@ -240,11 +248,7 @@ class MobileScanner( @@ -240,11 +248,7 @@ class MobileScanner(
240 } 248 }
241 249
242 lastScanned = null 250 lastScanned = null
243 - scanner = if (barcodeScannerOptions != null) {  
244 - BarcodeScanning.getClient(barcodeScannerOptions)  
245 - } else {  
246 - BarcodeScanning.getClient()  
247 - } 251 + scanner = barcodeScannerFactory(barcodeScannerOptions)
248 252
249 val cameraProviderFuture = ProcessCameraProvider.getInstance(activity) 253 val cameraProviderFuture = ProcessCameraProvider.getInstance(activity)
250 val executor = ContextCompat.getMainExecutor(activity) 254 val executor = ContextCompat.getMainExecutor(activity)
@@ -408,14 +412,27 @@ class MobileScanner( @@ -408,14 +412,27 @@ class MobileScanner(
408 } 412 }
409 413
410 val owner = activity as LifecycleOwner 414 val owner = activity as LifecycleOwner
411 - camera?.cameraInfo?.torchState?.removeObservers(owner) 415 + // Release the camera observers first.
  416 + camera?.cameraInfo?.let {
  417 + it.torchState.removeObservers(owner)
  418 + it.zoomState.removeObservers(owner)
  419 + it.cameraState.removeObservers(owner)
  420 + }
  421 + // Unbind the camera use cases, the preview is a use case.
  422 + // The camera will be closed when the last use case is unbound.
412 cameraProvider?.unbindAll() 423 cameraProvider?.unbindAll()
413 - textureEntry?.release()  
414 - 424 + cameraProvider = null
415 camera = null 425 camera = null
416 preview = null 426 preview = null
  427 +
  428 + // Release the texture for the preview.
  429 + textureEntry?.release()
417 textureEntry = null 430 textureEntry = null
418 - cameraProvider = null 431 +
  432 + // Release the scanner.
  433 + scanner?.close()
  434 + scanner = null
  435 + lastScanned = null
419 } 436 }
420 437
421 private fun isStopped() = camera == null && preview == null 438 private fun isStopped() = camera == null && preview == null
@@ -439,22 +456,29 @@ class MobileScanner( @@ -439,22 +456,29 @@ class MobileScanner(
439 /** 456 /**
440 * Analyze a single image. 457 * Analyze a single image.
441 */ 458 */
442 - fun analyzeImage(image: Uri, onSuccess: AnalyzerSuccessCallback, onError: AnalyzerErrorCallback) { 459 + fun analyzeImage(
  460 + image: Uri,
  461 + scannerOptions: BarcodeScannerOptions?,
  462 + onSuccess: AnalyzerSuccessCallback,
  463 + onError: AnalyzerErrorCallback) {
443 val inputImage = InputImage.fromFilePath(activity, image) 464 val inputImage = InputImage.fromFilePath(activity, image)
444 465
445 - scanner.process(inputImage)  
446 - .addOnSuccessListener { barcodes ->  
447 - val barcodeMap = barcodes.map { barcode -> barcode.data } 466 + // Use a short lived scanner instance, which is closed when the analysis is done.
  467 + val barcodeScanner: BarcodeScanner = barcodeScannerFactory(scannerOptions)
448 468
449 - if (barcodeMap.isNotEmpty()) {  
450 - onSuccess(barcodeMap)  
451 - } else {  
452 - onSuccess(null)  
453 - }  
454 - }  
455 - .addOnFailureListener { e ->  
456 - onError(e.localizedMessage ?: e.toString()) 469 + barcodeScanner.process(inputImage).addOnSuccessListener { barcodes ->
  470 + val barcodeMap = barcodes.map { barcode -> barcode.data }
  471 +
  472 + if (barcodeMap.isEmpty()) {
  473 + onSuccess(null)
  474 + } else {
  475 + onSuccess(barcodeMap)
457 } 476 }
  477 + }.addOnFailureListener { e ->
  478 + onError(e.localizedMessage ?: e.toString())
  479 + }.addOnCompleteListener {
  480 + barcodeScanner.close()
  481 + }
458 } 482 }
459 483
460 /** 484 /**
@@ -474,4 +498,14 @@ class MobileScanner( @@ -474,4 +498,14 @@ class MobileScanner(
474 camera?.cameraControl?.setZoomRatio(1f) 498 camera?.cameraControl?.setZoomRatio(1f)
475 } 499 }
476 500
  501 + /**
  502 + * Dispose of this scanner instance.
  503 + */
  504 + fun dispose() {
  505 + if (isStopped()) {
  506 + return
  507 + }
  508 +
  509 + stop() // Defer to the stop method, which disposes all resources anyway.
  510 + }
477 } 511 }
@@ -92,6 +92,7 @@ class MobileScannerHandler( @@ -92,6 +92,7 @@ class MobileScannerHandler(
92 fun dispose(activityPluginBinding: ActivityPluginBinding) { 92 fun dispose(activityPluginBinding: ActivityPluginBinding) {
93 methodChannel?.setMethodCallHandler(null) 93 methodChannel?.setMethodCallHandler(null)
94 methodChannel = null 94 methodChannel = null
  95 + mobileScanner?.dispose()
95 mobileScanner = null 96 mobileScanner = null
96 97
97 val listener: RequestPermissionsResultListener? = permissions.getPermissionListener() 98 val listener: RequestPermissionsResultListener? = permissions.getPermissionListener()
@@ -242,7 +243,13 @@ class MobileScannerHandler( @@ -242,7 +243,13 @@ class MobileScannerHandler(
242 analyzerResult = result 243 analyzerResult = result
243 val uri = Uri.fromFile(File(call.arguments.toString())) 244 val uri = Uri.fromFile(File(call.arguments.toString()))
244 245
245 - mobileScanner!!.analyzeImage(uri, analyzeImageSuccessCallback, analyzeImageErrorCallback) 246 + // TODO: parse options from the method call
  247 + // See https://github.com/juliansteenbakker/mobile_scanner/issues/1069
  248 + mobileScanner!!.analyzeImage(
  249 + uri,
  250 + null,
  251 + analyzeImageSuccessCallback,
  252 + analyzeImageErrorCallback)
246 } 253 }
247 254
248 private fun toggleTorch(result: MethodChannel.Result) { 255 private fun toggleTorch(result: MethodChannel.Result) {