-
-
Notifications
You must be signed in to change notification settings - Fork 605
fix: fix barcode scanner leak on Android #1139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
926bee4
8951c43
8bc2cfe
e59c026
64f57c2
ee336cd
6b06c5d
5bf6bb3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,19 +13,20 @@ import android.os.Looper | |
import android.util.Size | ||
import android.view.Surface | ||
import android.view.WindowManager | ||
import androidx.annotation.VisibleForTesting | ||
import androidx.camera.core.Camera | ||
import androidx.camera.core.CameraSelector | ||
import androidx.camera.core.ExperimentalGetImage | ||
import androidx.camera.core.ImageAnalysis | ||
import androidx.camera.core.ImageProxy | ||
import androidx.camera.core.Preview | ||
import androidx.camera.core.TorchState | ||
import androidx.camera.core.resolutionselector.AspectRatioStrategy | ||
import androidx.camera.core.resolutionselector.ResolutionSelector | ||
import androidx.camera.core.resolutionselector.ResolutionStrategy | ||
import androidx.camera.lifecycle.ProcessCameraProvider | ||
import androidx.core.content.ContextCompat | ||
import androidx.lifecycle.LifecycleOwner | ||
import com.google.mlkit.vision.barcode.BarcodeScanner | ||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions | ||
import com.google.mlkit.vision.barcode.BarcodeScanning | ||
import com.google.mlkit.vision.barcode.common.Barcode | ||
|
@@ -37,20 +38,20 @@ import io.flutter.view.TextureRegistry | |
import java.io.ByteArrayOutputStream | ||
import kotlin.math.roundToInt | ||
|
||
|
||
class MobileScanner( | ||
private val activity: Activity, | ||
private val textureRegistry: TextureRegistry, | ||
private val mobileScannerCallback: MobileScannerCallback, | ||
private val mobileScannerErrorCallback: MobileScannerErrorCallback | ||
private val mobileScannerErrorCallback: MobileScannerErrorCallback, | ||
private val barcodeScannerFactory: (options: BarcodeScannerOptions?) -> BarcodeScanner = ::defaultBarcodeScannerFactory, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This new callback is mainly for tests. I'm adding a new test soon that needs this, in a follow up PR for the rounding bug #1134 |
||
) { | ||
|
||
/// Internal variables | ||
private var cameraProvider: ProcessCameraProvider? = null | ||
private var camera: Camera? = null | ||
private var preview: Preview? = null | ||
private var textureEntry: TextureRegistry.SurfaceTextureEntry? = null | ||
private var scanner = BarcodeScanning.getClient() | ||
private var scanner: BarcodeScanner? = null | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This client was never disposed |
||
private var lastScanned: List<String?>? = null | ||
private var scannerTimeout = false | ||
private var displayListener: DisplayManager.DisplayListener? = null | ||
|
@@ -61,6 +62,15 @@ class MobileScanner( | |
private var detectionTimeout: Long = 250 | ||
private var returnImage = false | ||
|
||
companion object { | ||
/** | ||
* Create a barcode scanner from the given options. | ||
*/ | ||
fun defaultBarcodeScannerFactory(options: BarcodeScannerOptions?) : BarcodeScanner { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Default for the barcode scanner creator function |
||
return if (options == null) BarcodeScanning.getClient() else BarcodeScanning.getClient(options) | ||
} | ||
} | ||
|
||
/** | ||
* callback for the camera. Every frame is passed through this function. | ||
*/ | ||
|
@@ -76,76 +86,75 @@ class MobileScanner( | |
scannerTimeout = true | ||
} | ||
|
||
scanner.process(inputImage) | ||
.addOnSuccessListener { barcodes -> | ||
scanner?.let { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scanner is nullable now, so used a let block |
||
it.process(inputImage).addOnSuccessListener { barcodes -> | ||
if (detectionSpeed == DetectionSpeed.NO_DUPLICATES) { | ||
val newScannedBarcodes = barcodes.mapNotNull { barcode -> barcode.rawValue }.sorted() | ||
val newScannedBarcodes = barcodes.mapNotNull { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Formatting of mapNotNull to use braces |
||
barcode -> barcode.rawValue | ||
}.sorted() | ||
|
||
if (newScannedBarcodes == lastScanned) { | ||
// New scanned is duplicate, returning | ||
return@addOnSuccessListener | ||
} | ||
if (newScannedBarcodes.isNotEmpty()) lastScanned = newScannedBarcodes | ||
if (newScannedBarcodes.isNotEmpty()) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same formatting change here |
||
lastScanned = newScannedBarcodes | ||
} | ||
} | ||
|
||
val barcodeMap: MutableList<Map<String, Any?>> = mutableListOf() | ||
|
||
for (barcode in barcodes) { | ||
if (scanWindow != null) { | ||
val match = isBarcodeInScanWindow(scanWindow!!, barcode, imageProxy) | ||
if (!match) { | ||
continue | ||
} else { | ||
barcodeMap.add(barcode.data) | ||
} | ||
} else { | ||
if (scanWindow == null) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inverting the check here made more sense |
||
barcodeMap.add(barcode.data) | ||
continue | ||
} | ||
} | ||
|
||
|
||
if (barcodeMap.isNotEmpty()) { | ||
if (returnImage) { | ||
|
||
val bitmap = Bitmap.createBitmap(mediaImage.width, mediaImage.height, Bitmap.Config.ARGB_8888) | ||
|
||
val imageFormat = YuvToRgbConverter(activity.applicationContext) | ||
if (isBarcodeInScanWindow(scanWindow!!, barcode, imageProxy)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also invert the check here, which drops a redundant continue statement |
||
barcodeMap.add(barcode.data) | ||
} | ||
} | ||
|
||
imageFormat.yuvToRgb(mediaImage, bitmap) | ||
if (barcodeMap.isEmpty()) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Early return 2x |
||
return@addOnSuccessListener | ||
} | ||
|
||
val bmResult = rotateBitmap(bitmap, camera?.cameraInfo?.sensorRotationDegrees?.toFloat() ?: 90f) | ||
if (!returnImage) { | ||
mobileScannerCallback( | ||
barcodeMap, | ||
null, | ||
null, | ||
null | ||
) | ||
return@addOnSuccessListener | ||
} | ||
|
||
val stream = ByteArrayOutputStream() | ||
bmResult.compress(Bitmap.CompressFormat.PNG, 100, stream) | ||
val byteArray = stream.toByteArray() | ||
val bmWidth = bmResult.width | ||
val bmHeight = bmResult.height | ||
bmResult.recycle() | ||
val bitmap = Bitmap.createBitmap(mediaImage.width, mediaImage.height, Bitmap.Config.ARGB_8888) | ||
val imageFormat = YuvToRgbConverter(activity.applicationContext) | ||
|
||
imageFormat.yuvToRgb(mediaImage, bitmap) | ||
|
||
mobileScannerCallback( | ||
barcodeMap, | ||
byteArray, | ||
bmWidth, | ||
bmHeight | ||
) | ||
val bmResult = rotateBitmap(bitmap, camera?.cameraInfo?.sensorRotationDegrees?.toFloat() ?: 90f) | ||
|
||
} else { | ||
val stream = ByteArrayOutputStream() | ||
bmResult.compress(Bitmap.CompressFormat.PNG, 100, stream) | ||
val byteArray = stream.toByteArray() | ||
val bmWidth = bmResult.width | ||
val bmHeight = bmResult.height | ||
bmResult.recycle() | ||
|
||
mobileScannerCallback( | ||
barcodeMap, | ||
null, | ||
null, | ||
null | ||
) | ||
} | ||
} | ||
} | ||
.addOnFailureListener { e -> | ||
mobileScannerCallback( | ||
barcodeMap, | ||
byteArray, | ||
bmWidth, | ||
bmHeight | ||
) | ||
}.addOnFailureListener { e -> | ||
mobileScannerErrorCallback( | ||
e.localizedMessage ?: e.toString() | ||
) | ||
} | ||
.addOnCompleteListener { imageProxy.close() } | ||
}.addOnCompleteListener { imageProxy.close() } | ||
} | ||
|
||
if (detectionSpeed == DetectionSpeed.NORMAL) { | ||
// Set timer and continue | ||
|
@@ -161,7 +170,6 @@ class MobileScanner( | |
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) | ||
} | ||
|
||
|
||
// scales the scanWindow to the provided inputImage and checks if that scaled | ||
// scanWindow contains the barcode | ||
private fun isBarcodeInScanWindow( | ||
|
@@ -240,11 +248,7 @@ class MobileScanner( | |
} | ||
|
||
lastScanned = null | ||
scanner = if (barcodeScannerOptions != null) { | ||
BarcodeScanning.getClient(barcodeScannerOptions) | ||
} else { | ||
BarcodeScanning.getClient() | ||
} | ||
scanner = barcodeScannerFactory(barcodeScannerOptions) | ||
|
||
val cameraProviderFuture = ProcessCameraProvider.getInstance(activity) | ||
val executor = ContextCompat.getMainExecutor(activity) | ||
|
@@ -408,14 +412,27 @@ class MobileScanner( | |
} | ||
|
||
val owner = activity as LifecycleOwner | ||
camera?.cameraInfo?.torchState?.removeObservers(owner) | ||
// Release the camera observers first. | ||
camera?.cameraInfo?.let { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was a bug, we didn't remove all observers. But we are listening to torch & zoom |
||
it.torchState.removeObservers(owner) | ||
it.zoomState.removeObservers(owner) | ||
it.cameraState.removeObservers(owner) | ||
} | ||
// Unbind the camera use cases, the preview is a use case. | ||
// The camera will be closed when the last use case is unbound. | ||
cameraProvider?.unbindAll() | ||
textureEntry?.release() | ||
|
||
cameraProvider = null | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The following is just reflowing code so that the relevant things are together. |
||
camera = null | ||
preview = null | ||
|
||
// Release the texture for the preview. | ||
textureEntry?.release() | ||
textureEntry = null | ||
cameraProvider = null | ||
|
||
// Release the scanner. | ||
scanner?.close() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is new, we release the scanner when the users stops the camera. No need to keep it around when the preview / camera is gone. |
||
scanner = null | ||
lastScanned = null | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Setting |
||
} | ||
|
||
private fun isStopped() = camera == null && preview == null | ||
|
@@ -439,22 +456,29 @@ class MobileScanner( | |
/** | ||
* Analyze a single image. | ||
*/ | ||
fun analyzeImage(image: Uri, onSuccess: AnalyzerSuccessCallback, onError: AnalyzerErrorCallback) { | ||
fun analyzeImage( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Analyze image now takes configurable options and uses a short lived scanner. Using the short lived scanner is part of the fix for the retain cycle thing and allowing options is part of the fix for analyze image allowing all formats. |
||
image: Uri, | ||
scannerOptions: BarcodeScannerOptions?, | ||
onSuccess: AnalyzerSuccessCallback, | ||
onError: AnalyzerErrorCallback) { | ||
val inputImage = InputImage.fromFilePath(activity, image) | ||
|
||
scanner.process(inputImage) | ||
.addOnSuccessListener { barcodes -> | ||
val barcodeMap = barcodes.map { barcode -> barcode.data } | ||
// Use a short lived scanner instance, which is closed when the analysis is done. | ||
val barcodeScanner: BarcodeScanner = barcodeScannerFactory(scannerOptions) | ||
|
||
if (barcodeMap.isNotEmpty()) { | ||
onSuccess(barcodeMap) | ||
} else { | ||
onSuccess(null) | ||
} | ||
} | ||
.addOnFailureListener { e -> | ||
onError(e.localizedMessage ?: e.toString()) | ||
barcodeScanner.process(inputImage).addOnSuccessListener { barcodes -> | ||
val barcodeMap = barcodes.map { barcode -> barcode.data } | ||
|
||
if (barcodeMap.isEmpty()) { | ||
onSuccess(null) | ||
} else { | ||
onSuccess(barcodeMap) | ||
} | ||
}.addOnFailureListener { e -> | ||
onError(e.localizedMessage ?: e.toString()) | ||
}.addOnCompleteListener { | ||
barcodeScanner.close() | ||
} | ||
} | ||
|
||
/** | ||
|
@@ -474,4 +498,14 @@ class MobileScanner( | |
camera?.cameraControl?.setZoomRatio(1f) | ||
} | ||
|
||
/** | ||
* Dispose of this scanner instance. | ||
*/ | ||
fun dispose() { | ||
if (isStopped()) { | ||
return | ||
} | ||
|
||
stop() // Defer to the stop method, which disposes all resources anyway. | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -92,6 +92,7 @@ class MobileScannerHandler( | |
fun dispose(activityPluginBinding: ActivityPluginBinding) { | ||
methodChannel?.setMethodCallHandler(null) | ||
methodChannel = null | ||
mobileScanner?.dispose() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When the user calls We might get only |
||
mobileScanner = null | ||
|
||
val listener: RequestPermissionsResultListener? = permissions.getPermissionListener() | ||
|
@@ -242,7 +243,13 @@ class MobileScannerHandler( | |
analyzerResult = result | ||
val uri = Uri.fromFile(File(call.arguments.toString())) | ||
|
||
mobileScanner!!.analyzeImage(uri, analyzeImageSuccessCallback, analyzeImageErrorCallback) | ||
// TODO: parse options from the method call | ||
// See https://github.com/juliansteenbakker/mobile_scanner/issues/1069 | ||
mobileScanner!!.analyzeImage( | ||
uri, | ||
null, | ||
analyzeImageSuccessCallback, | ||
analyzeImageErrorCallback) | ||
} | ||
|
||
private fun toggleTorch(result: MethodChannel.Result) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused import