From 81914f544e2005d3b16c9c2e987c8540fc4aa3f3 Mon Sep 17 00:00:00 2001 From: Zakhar Pashkin <112114097+zack-dev-cm@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:15:30 +0400 Subject: [PATCH] Own one Android barcode scanner per analyzer and settle image consumers --- android/build.gradle | 8 + .../rncamerakit/BarcodeNativeLifecycleTest.kt | 150 ++++++++++ .../src/main/java/com/rncamerakit/CKCamera.kt | 21 +- .../java/com/rncamerakit/QRCodeAnalyzer.kt | 71 ++++- .../com/rncamerakit/SharedImageAnalyzer.kt | 20 ++ .../com/rncamerakit/BarcodeLifecycleTest.kt | 266 ++++++++++++++++++ .../BarcodeOwnershipRegressionTest.kt | 132 +++++++++ 7 files changed, 644 insertions(+), 24 deletions(-) create mode 100644 android/src/androidTest/java/com/rncamerakit/BarcodeNativeLifecycleTest.kt create mode 100644 android/src/main/java/com/rncamerakit/SharedImageAnalyzer.kt create mode 100644 android/src/test/java/com/rncamerakit/BarcodeLifecycleTest.kt create mode 100644 android/src/test/java/com/rncamerakit/BarcodeOwnershipRegressionTest.kt diff --git a/android/build.gradle b/android/build.gradle index 145733b842..94dc35a384 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -14,12 +14,14 @@ if (isNewArchitectureEnabled()) { } android { + testOptions { unitTests.includeAndroidResources = true } def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION if (agpVersion.tokenize('.')[0].toInteger() >= 7) { namespace "com.rncamerakit" } compileSdkVersion = 34 defaultConfig { + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" minSdkVersion = 24 // noinspection ExpiredTargetSdkVersion targetSdkVersion = 34 @@ -46,6 +48,12 @@ android { } dependencies { + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.14.1' + testImplementation 'org.mockito:mockito-core:5.18.0' + androidTestImplementation 'androidx.test:runner:1.6.2' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'com.google.zxing:core:3.5.3' implementation 'com.facebook.react:react-native:+' implementation group: 'com.drewnoakes', name: 'metadata-extractor', version: '2.18.0' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" diff --git a/android/src/androidTest/java/com/rncamerakit/BarcodeNativeLifecycleTest.kt b/android/src/androidTest/java/com/rncamerakit/BarcodeNativeLifecycleTest.kt new file mode 100644 index 0000000000..2313074598 --- /dev/null +++ b/android/src/androidTest/java/com/rncamerakit/BarcodeNativeLifecycleTest.kt @@ -0,0 +1,150 @@ +package com.rncamerakit + +import android.annotation.SuppressLint +import android.graphics.ImageFormat +import android.graphics.Matrix +import android.graphics.Rect +import android.media.Image +import android.media.ImageReader +import android.media.ImageWriter +import android.os.Debug +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.camera.core.ImageInfo +import androidx.camera.core.ImageProxy +import androidx.camera.core.impl.TagBundle +import androidx.camera.core.impl.utils.ExifData +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.gms.tasks.Task +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import com.google.zxing.BarcodeFormat +import com.google.zxing.qrcode.QRCodeWriter +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import java.nio.ByteBuffer +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** Real bundled ML Kit clients; authored Android YUV image; no camera/HAL claim. */ +@RunWith(AndroidJUnit4::class) +@SuppressLint("RestrictedApi", "UnsafeOptInUsageError") +class BarcodeNativeLifecycleTest { + @Test + fun repeatedGenerationsCloseClientsAndImagesExactlyOnce() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val ready = CountDownLatch(1) + val reader = ImageReader.newInstance(320, 240, ImageFormat.YUV_420_888, 2) + val writer = ImageWriter.newInstance(reader.surface, 2) + reader.setOnImageAvailableListener({ ready.countDown() }, Handler(Looper.getMainLooper())) + val writable = writer.dequeueInputImage() + val qr = QRCodeWriter().encode("LIFECYCLE", BarcodeFormat.QR_CODE, 160, 160) + for ((index, plane) in writable.planes.withIndex()) { + val width = if (index == 0) 320 else 160 + val height = if (index == 0) 240 else 120 + for (y in 0 until height) for (x in 0 until width) { + val value = if (index != 0) 128 else if (x in 80 until 240 && y in 40 until 200 && qr[x - 80, y - 40]) 0 else 255 + plane.buffer.put(y * plane.rowStride + x * plane.pixelStride, value.toByte()) + } + } + writer.queueInputImage(writable) + assertTrue(ready.await(10, TimeUnit.SECONDS)) + val media = requireNotNull(reader.acquireNextImage()) + val created = AtomicInteger() + val closed = AtomicInteger() + val processed = AtomicInteger() + val imageCloses = AtomicInteger() + val events = AtomicInteger() + fun report(generation: Int) { + val memory = Debug.MemoryInfo().also { Debug.getMemoryInfo(it) } + Log.i("CameraKitLifecycle", "generation=$generation created=${created.get()} closed=${closed.get()} processed=${processed.get()} imageCloses=${imageCloses.get()} events=${events.get()} nativeHeapBytes=${Debug.getNativeHeapAllocatedSize()} totalPssKb=${memory.totalPss} fixture=authored-yuv") + } + report(0) + try { + repeat(20) { generation -> + var latest: Task>? = null + val analyzer = QRCodeAnalyzer({ codes, _ -> + assertEquals(listOf("LIFECYCLE"), codes.map { it.rawValue }) + events.incrementAndGet() + }, 0) { + created.incrementAndGet() + val client = BarcodeScanning.getClient() + object : BarcodeScanner by client { + override fun process(image: InputImage): Task> { + processed.incrementAndGet() + return client.process(image).also { latest = it } + } + override fun close() { + closed.incrementAndGet() + client.close() + } + } + } + try { + repeat(3) { frame -> + val proxy = SceneImage(media, imageCloses) + instrumentation.runOnMainSync { + analyzer.analyze(proxy) + // Close before queued success callbacks can run for the final frame. + if (frame == 2) analyzer.close() + } + val done = CountDownLatch(1) + val task = requireNotNull(latest) + task.addOnCompleteListener { done.countDown() } + assertTrue(done.await(30, TimeUnit.SECONDS)) + assertTrue("Real barcode inference failed: ${task.exception}", task.isSuccessful) + assertEquals(listOf("LIFECYCLE"), task.result.map { it.rawValue }) + // The shared-task barrier schedules its own completion callback. + assertTrue("Shared image was not released", proxy.closed.await(5, TimeUnit.SECONDS)) + assertEquals(1, proxy.closeCount) + } + } finally { + analyzer.close() + } + assertEquals(generation + 1, created.get()) + assertEquals(generation + 1, closed.get()) + assertEquals((generation + 1) * 3, imageCloses.get()) + assertEquals((generation + 1) * 2, events.get()) + if ((generation + 1) % 5 == 0) report(generation + 1) + } + assertEquals(60, processed.get()) + } finally { + media.close() + writer.close() + reader.close() + } + } + + private class SceneImage(private val media: Image, private val totalCloses: AtomicInteger) : ImageProxy { + val closed = CountDownLatch(1) + var closeCount = 0 + override fun close() { closeCount++; totalCloses.incrementAndGet(); closed.countDown() } + // Media storage belongs to the fixture and is reused only after each task completes. + override fun getImage(): Image = media + override fun getWidth() = media.width + override fun getHeight() = media.height + override fun getFormat() = media.format + override fun getCropRect() = Rect(0, 0, width, height) + override fun setCropRect(rect: Rect?) = Unit + override fun getPlanes(): Array = media.planes.map { plane -> + object : ImageProxy.PlaneProxy { + override fun getBuffer(): ByteBuffer = plane.buffer + override fun getRowStride() = plane.rowStride + override fun getPixelStride() = plane.pixelStride + } + }.toTypedArray() + override fun getImageInfo(): ImageInfo = object : ImageInfo { + override fun getRotationDegrees() = 0 + override fun getTimestamp() = media.timestamp + override fun getTagBundle(): TagBundle = TagBundle.emptyBundle() + override fun getSensorToBufferTransformMatrix() = Matrix() + override fun populateExifData(builder: ExifData.Builder) = Unit + } + } +} diff --git a/android/src/main/java/com/rncamerakit/CKCamera.kt b/android/src/main/java/com/rncamerakit/CKCamera.kt index bf8d9e7f44..06316ed228 100644 --- a/android/src/main/java/com/rncamerakit/CKCamera.kt +++ b/android/src/main/java/com/rncamerakit/CKCamera.kt @@ -45,8 +45,6 @@ import android.graphics.Rect import android.graphics.RectF import android.util.Size import com.facebook.react.uimanager.UIManagerHelper -import com.google.android.gms.tasks.Task -import com.google.android.gms.tasks.Tasks import com.google.mlkit.vision.barcode.common.Barcode import com.rncamerakit.events.* @@ -86,6 +84,7 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs private var imageCapture: ImageCapture? = null private var imageAnalyzer: ImageAnalysis? = null private var faceAnalyzer: FaceAnalyzer? = null + private var barcodeAnalyzer: QRCodeAnalyzer? = null private var orientationListener: OrientationEventListener? = null private var viewFinder: PreviewView = PreviewView(context) private var rectOverlay: RectOverlay = RectOverlay(context) @@ -149,6 +148,8 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs override fun onDetachedFromWindow() { super.onDetachedFromWindow() + barcodeAnalyzer?.close() + barcodeAnalyzer = null faceAnalyzer?.close() faceAnalyzer = null cameraExecutor.shutdown() @@ -358,10 +359,12 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs val useCases = mutableListOf(preview, imageCapture) + barcodeAnalyzer?.close() + barcodeAnalyzer = null faceAnalyzer?.close() faceAnalyzer = null - val barcodeAnalyzer: QRCodeAnalyzer? = if (scanBarcode) { + barcodeAnalyzer = if (scanBarcode) { QRCodeAnalyzer({ barcodes, imageSize -> if (barcodes.isEmpty()) return@QRCodeAnalyzer @@ -422,14 +425,14 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs ) } else null + val activeBarcodeAnalyzer = barcodeAnalyzer val activeFaceAnalyzer = faceAnalyzer - if (barcodeAnalyzer != null || activeFaceAnalyzer != null) { + if (activeBarcodeAnalyzer != null || activeFaceAnalyzer != null) { imageAnalyzer!!.setAnalyzer(cameraExecutor) { image -> - val tasks = mutableListOf>() - barcodeAnalyzer?.analyzeWithoutClosing(image)?.let { tasks.add(it) } - activeFaceAnalyzer?.analyzeWithoutClosing(image)?.let { tasks.add(it) } - if (tasks.isEmpty()) image.close() - else Tasks.whenAllComplete(tasks).addOnCompleteListener { image.close() } + analyzeSharedImage(image, + { activeBarcodeAnalyzer?.analyzeWithoutClosing(it) }, + { activeFaceAnalyzer?.analyzeWithoutClosing(it) }, + ) } useCases.add(imageAnalyzer) } diff --git a/android/src/main/java/com/rncamerakit/QRCodeAnalyzer.kt b/android/src/main/java/com/rncamerakit/QRCodeAnalyzer.kt index 9faf67bd7c..05f8a725b4 100644 --- a/android/src/main/java/com/rncamerakit/QRCodeAnalyzer.kt +++ b/android/src/main/java/com/rncamerakit/QRCodeAnalyzer.kt @@ -6,27 +6,50 @@ import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy import com.google.android.gms.tasks.Task +import com.google.mlkit.vision.barcode.BarcodeScanner import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage -class QRCodeAnalyzer ( +class QRCodeAnalyzer internal constructor( private val onQRCodesDetected: (qrCodes: List, imageSize: Size) -> Unit, - private val scanThrottleDelay: Long = 0L -) : ImageAnalysis.Analyzer { + private val scanThrottleDelay: Long, + private val scannerFactory: () -> BarcodeScanner, +) : ImageAnalysis.Analyzer, AutoCloseable { + constructor( + onQRCodesDetected: (qrCodes: List, imageSize: Size) -> Unit, + scanThrottleDelay: Long = 0L, + ) : this(onQRCodesDetected, scanThrottleDelay, { BarcodeScanning.getClient() }) + + private enum class State { OPEN, CLOSING, CLOSED } + private val lock = Any() + private var state = State.OPEN + private var scanner: BarcodeScanner? = null + private var inFlight = 0 + // Time in milliseconds of the last time we dispatched detected barcodes private var lastBarcodeDetectedTime: Long = 0L @SuppressLint("UnsafeExperimentalUsageError") @ExperimentalGetImage - fun analyzeWithoutClosing(image: ImageProxy): Task<*>? { + fun analyzeWithoutClosing(image: ImageProxy): Task<*>? = synchronized(lock) { + if (state != State.OPEN) return null val mediaImage = image.image ?: return null - + val imageSize = Size(image.width, image.height) val inputImage = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees) - - val scanner = BarcodeScanning.getClient() - return scanner.process(inputImage) - .addOnSuccessListener { barcodes -> + val activeScanner = scanner ?: scannerFactory().also { scanner = it } + // Register the consumer before process() can race with close(). + inFlight++ + val task = try { + activeScanner.process(inputImage) + } catch (e: Exception) { + inFlight-- + closeIfIdle() + throw e + } + task.addOnSuccessListener { barcodes -> + synchronized(lock) { + if (state != State.OPEN) return@addOnSuccessListener // Throttle callback invocations based on scanThrottleDelay (ms) val now = System.currentTimeMillis() if (scanThrottleDelay > 0 && (now - lastBarcodeDetectedTime) < scanThrottleDelay) { @@ -35,19 +58,37 @@ class QRCodeAnalyzer ( if (barcodes.isNotEmpty()) { lastBarcodeDetectedTime = now - onQRCodesDetected(barcodes, Size(image.width, image.height)) + onQRCodesDetected(barcodes, imageSize) } } + } + task.addOnCompleteListener { + synchronized(lock) { + inFlight-- + closeIfIdle() + } + } + task } @SuppressLint("UnsafeExperimentalUsageError") @ExperimentalGetImage override fun analyze(image: ImageProxy) { - val task = analyzeWithoutClosing(image) - if (task == null) { - image.close() - return + analyzeSharedImage(image, ::analyzeWithoutClosing) + } + + override fun close() = synchronized(lock) { + if (state == State.OPEN) state = State.CLOSING + closeIfIdle() + } + + // All state transitions and outward callbacks are serialized by lock. + private fun closeIfIdle() { + if (state == State.CLOSING && inFlight == 0) { + state = State.CLOSED + val closingScanner = scanner + scanner = null + closingScanner?.close() } - task.addOnCompleteListener { image.close() } } } diff --git a/android/src/main/java/com/rncamerakit/SharedImageAnalyzer.kt b/android/src/main/java/com/rncamerakit/SharedImageAnalyzer.kt new file mode 100644 index 0000000000..7b61030e50 --- /dev/null +++ b/android/src/main/java/com/rncamerakit/SharedImageAnalyzer.kt @@ -0,0 +1,20 @@ +package com.rncamerakit + +import android.util.Log +import androidx.camera.core.ImageProxy +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks + +/** Every successfully launched consumer owns the image until its task settles. */ +internal fun analyzeSharedImage(image: ImageProxy, vararg consumers: (ImageProxy) -> Task<*>?) { + val tasks = mutableListOf>() + for (consumer in consumers) { + try { + consumer(image)?.let { tasks.add(it) } + } catch (e: Exception) { + Log.w("CameraKit", "Image analyzer failed to start", e) + } + } + if (tasks.isEmpty()) image.close() + else Tasks.whenAllComplete(tasks).addOnCompleteListener { image.close() } +} diff --git a/android/src/test/java/com/rncamerakit/BarcodeLifecycleTest.kt b/android/src/test/java/com/rncamerakit/BarcodeLifecycleTest.kt new file mode 100644 index 0000000000..6ad9b12877 --- /dev/null +++ b/android/src/test/java/com/rncamerakit/BarcodeLifecycleTest.kt @@ -0,0 +1,266 @@ +package com.rncamerakit + +import android.content.Context +import android.media.Image +import android.os.Looper +import android.view.WindowManager +import androidx.camera.core.ImageInfo +import androidx.camera.core.ImageProxy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.google.android.gms.tasks.CancellationTokenSource +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class BarcodeLifecycleTest { + private class Fixture(val delay: Long = 0) : AutoCloseable { + val media = mock(Image::class.java) + val image = mock(ImageProxy::class.java) + val info = mock(ImageInfo::class.java) + val input = mock(InputImage::class.java) + val scanner = mock(BarcodeScanner::class.java) + val inputs = mockStatic(InputImage::class.java) + val tasks = mutableListOf>>() + var factories = 0 + var events = 0 + val analyzer = QRCodeAnalyzer({ _, size -> + assertEquals(android.util.Size(128, 96), size) + events++ + }, delay, { factories++; scanner }) + + init { + `when`(image.image).thenReturn(media) + `when`(image.imageInfo).thenReturn(info) + `when`(image.width).thenReturn(128) + `when`(image.height).thenReturn(96) + inputs.`when` { InputImage.fromMediaImage(media, 0) }.thenReturn(input) + `when`(scanner.process(input)).thenAnswer { + TaskCompletionSource>().also { tasks.add(it) }.task + } + } + + fun success(index: Int = 0, codes: Boolean = true) { + tasks[index].setResult(if (codes) listOf(mock(Barcode::class.java)) else emptyList()) + idle() + } + + fun idle() = shadowOf(Looper.getMainLooper()).idle() + override fun close() { + analyzer.close() + inputs.close() + } + } + + @Test + fun closeBeforeFirstFrameAllocatesNothingAndRejectsFurtherWork() = Fixture().use { f -> + f.analyzer.close() + f.analyzer.close() + f.analyzer.analyze(f.image) + assertEquals(0, f.factories) + verify(f.image).close() + verifyNoInteractions(f.scanner) + } + + @Test + fun closeWaitsForEveryPendingTaskAndSuppressesLateEvents() = Fixture().use { f -> + repeat(2) { f.analyzer.analyzeWithoutClosing(f.image) } + f.analyzer.close() + f.analyzer.close() + assertNull(f.analyzer.analyzeWithoutClosing(f.image)) + f.success(1) + verify(f.scanner, never()).close() + f.success(0) + f.analyzer.close() + assertEquals(0, f.events) + assertEquals(1, f.factories) + verify(f.scanner, times(1)).close() + verify(f.image, never()).close() // analyzeWithoutClosing leaves image ownership with its caller. + } + + @Test + fun completedSuccessfulWorkClosesExactlyOnce() = Fixture().use { f -> + f.analyzer.analyze(f.image) + f.success() + assertEquals(1, f.events) + verify(f.image, times(1)).close() + verify(f.scanner, never()).close() + repeat(3) { f.analyzer.close() } + verify(f.scanner, times(1)).close() + } + + @Test + fun failedWorkSettlesBothScannerAndImageOwnership() = Fixture().use { f -> + f.analyzer.analyze(f.image) + f.analyzer.close() + f.tasks[0].setException(IllegalStateException("authored asynchronous failure")) + f.idle() + verify(f.scanner).close() + verify(f.image).close() + assertEquals(0, f.events) + } + + @Test + fun canceledWorkSettlesBothScannerAndImageOwnership() = Fixture().use { f -> + val cancellation = CancellationTokenSource() + val task = TaskCompletionSource>(cancellation.token) + `when`(f.scanner.process(f.input)).thenReturn(task.task) + f.analyzer.analyze(f.image) + f.analyzer.close() + cancellation.cancel() + f.idle() + verify(f.scanner).close() + verify(f.image).close() + assertEquals(0, f.events) + } + + @Test + fun synchronousProcessFailureDoesNotLeaveAnInFlightConsumer() = Fixture().use { f -> + `when`(f.scanner.process(f.input)).thenThrow(IllegalStateException("authored launch failure")) + f.analyzer.analyze(f.image) + f.analyzer.close() + verify(f.image).close() + verify(f.scanner).close() + } + + @Test + fun inputSetupFailureClosesTheImageWithoutCreatingAScanner() = Fixture().use { f -> + f.inputs.`when` { InputImage.fromMediaImage(f.media, 0) } + .thenThrow(IllegalArgumentException("authored unsupported image")) + f.analyzer.analyze(f.image) + f.analyzer.close() + assertEquals(0, f.factories) + verify(f.image).close() + verifyNoInteractions(f.scanner) + } + + @Test + fun scannerFactoryFailureClosesTheImage() = Fixture().use { f -> + val analyzer = QRCodeAnalyzer({ _, _ -> }, 0) { throw IllegalStateException("authored factory failure") } + analyzer.analyze(f.image) + analyzer.close() + verify(f.image).close() + } + + @Test + fun eventThrottleDoesNotBecomeAnInferenceThrottle() = Fixture(60_000).use { f -> + f.analyzer.analyzeWithoutClosing(f.image) + f.success(0) + f.analyzer.analyzeWithoutClosing(f.image) + f.success(1) + assertEquals(1, f.events) + verify(f.scanner, times(2)).process(f.input) + assertEquals(1, f.factories) + } + + @Test + fun closeRacingWithProcessWaitsUntilTheReturnedTaskSettles() = Fixture().use { f -> + val executor = Executors.newSingleThreadExecutor() + val attemptingClose = CountDownLatch(1) + lateinit var closing: Future<*> + val pending = TaskCompletionSource>() + `when`(f.scanner.process(f.input)).thenAnswer { + closing = executor.submit { + attemptingClose.countDown() + f.analyzer.close() + } + assertTrue(attemptingClose.await(5, TimeUnit.SECONDS)) + verify(f.scanner, never()).close() + pending.task + } + try { + f.analyzer.analyzeWithoutClosing(f.image) + closing.get(5, TimeUnit.SECONDS) + verify(f.scanner, never()).close() + pending.setResult(listOf(mock(Barcode::class.java))) + f.idle() + verify(f.scanner).close() + assertEquals(0, f.events) + } finally { + executor.shutdownNow() + } + } + + @Test + fun closingOneGenerationDoesNotCloseItsReplacement() = Fixture().use { f -> + f.analyzer.analyzeWithoutClosing(f.image) + f.analyzer.close() + val replacementScanner = mock(BarcodeScanner::class.java) + val pending = TaskCompletionSource>() + `when`(replacementScanner.process(f.input)).thenReturn(pending.task) + var events = 0 + val replacement = QRCodeAnalyzer({ _, _ -> events++ }, 0) { replacementScanner } + replacement.analyzeWithoutClosing(f.image) + f.success() + verify(f.scanner).close() + verify(replacementScanner, never()).close() + pending.setResult(listOf(mock(Barcode::class.java))) + f.idle() + assertEquals(0, f.events) + assertEquals(1, events) + replacement.close() + verify(replacementScanner).close() + } + + private fun camera(): CKCamera { + val context = ThemedReactContext(mock(ReactApplicationContext::class.java), RuntimeEnvironment.getApplication(), "lifecycle", 1) + return CKCamera(context) + } + + private fun set(camera: CKCamera, field: String, value: Any?) { + CKCamera::class.java.getDeclaredField(field).apply { isAccessible = true }.set(camera, value) + } + + @Test + fun cameraDetachClosesAndReleasesItsOwnedAnalyzer() { + val camera = camera() + val analyzer = mock(QRCodeAnalyzer::class.java) + set(camera, "barcodeAnalyzer", analyzer) + repeat(2) { + CKCamera::class.java.getDeclaredMethod("onDetachedFromWindow").apply { isAccessible = true }.invoke(camera) + } + verify(analyzer, times(1)).close() + assertNull(CKCamera::class.java.getDeclaredField("barcodeAnalyzer").apply { isAccessible = true }.get(camera)) + } + + @Test fun cameraRebindReplacesTheOwnedGeneration() = rebind(true) + @Test fun disablingBarcodeScanningClosesTheOwnedGeneration() = rebind(false) + + private fun rebind(enabled: Boolean) { + val camera = camera() + val old = mock(QRCodeAnalyzer::class.java) + val view = spy(PreviewView(camera.context)).apply { layout(0, 0, 1280, 720) } + val display = (camera.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay + `when`(view.display).thenReturn(display) + set(camera, "viewFinder", view) + set(camera, "cameraProvider", mock(ProcessCameraProvider::class.java)) + set(camera, "barcodeAnalyzer", old) + set(camera, "scanBarcode", enabled) + // No camera is bound in this unit fixture; its error event is a bridge boundary. + mockStatic(UIManagerHelper::class.java).use { + CKCamera::class.java.getDeclaredMethod("bindCameraUseCases").apply { isAccessible = true }.invoke(camera) + } + verify(old).close() + val replacement = CKCamera::class.java.getDeclaredField("barcodeAnalyzer").apply { isAccessible = true }.get(camera) + if (enabled) assertTrue(replacement is QRCodeAnalyzer && replacement !== old) else assertNull(replacement) + (replacement as? QRCodeAnalyzer)?.close() + } +} diff --git a/android/src/test/java/com/rncamerakit/BarcodeOwnershipRegressionTest.kt b/android/src/test/java/com/rncamerakit/BarcodeOwnershipRegressionTest.kt new file mode 100644 index 0000000000..6bcb285de7 --- /dev/null +++ b/android/src/test/java/com/rncamerakit/BarcodeOwnershipRegressionTest.kt @@ -0,0 +1,132 @@ +package com.rncamerakit + +import android.media.Image +import android.os.Looper +import androidx.camera.core.ImageInfo +import androidx.camera.core.ImageProxy +import com.google.android.gms.tasks.TaskCompletionSource +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.* +import org.mockito.stubbing.Answer +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.lang.reflect.InvocationTargetException + +/** Shared regression harness also compiled against the unchanged baseline. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class BarcodeOwnershipRegressionTest { + @Test + fun repeatedFramesReuseOneOwnedScanner() { + val media = mock(Image::class.java) + val image = mock(ImageProxy::class.java) + val info = mock(ImageInfo::class.java) + `when`(image.image).thenReturn(media) + `when`(image.imageInfo).thenReturn(info) + `when`(image.width).thenReturn(128) + `when`(image.height).thenReturn(96) + val input = mock(InputImage::class.java) + val scanner = mock(BarcodeScanner::class.java) + val task = TaskCompletionSource>() + `when`(scanner.process(input)).thenReturn(task.task) + var factories = 0 + mockStatic(BarcodeScanning::class.java, Answer { + factories++ + scanner + }).use { + mockStatic(InputImage::class.java).use { inputs -> + inputs.`when` { InputImage.fromMediaImage(media, 0) }.thenReturn(input) + val analyzer = QRCodeAnalyzer({ _, _ -> }, 0) + repeat(3) { analyzer.analyzeWithoutClosing(image) } + assertEquals("One scanner belongs to the active analyzer generation", 1, factories) + verify(scanner, times(3)).process(input) + } + } + } + + @Test + fun nullMediaDoesNotAllocateAScanner() { + val image = mock(ImageProxy::class.java) + mockStatic(BarcodeScanning::class.java).use { scanners -> + assertNull(QRCodeAnalyzer({ _, _ -> }).analyzeWithoutClosing(image)) + scanners.verifyNoInteractions() + } + } + + private fun pipeline(barcode: QRCodeAnalyzer?, face: FaceAnalyzer?, image: ImageProxy) { + // Invoke the actual callback installed in CKCamera's ImageAnalysis. + val method = CKCamera::class.java.declaredMethods.single { + it.parameterTypes.contentEquals(arrayOf(QRCodeAnalyzer::class.java, FaceAnalyzer::class.java, ImageProxy::class.java)) + }.apply { isAccessible = true } + try { + method.invoke(null, barcode, face, image) + } catch (e: InvocationTargetException) { + throw e.targetException + } + } + + @Test fun barcodeFinishingFirstKeepsTheSharedImageOpen() = completionOrder(true) + @Test fun faceFinishingFirstKeepsTheSharedImageOpen() = completionOrder(false) + + private fun completionOrder(barcodeFirst: Boolean) { + val image = mock(ImageProxy::class.java) + val barcode = mock(QRCodeAnalyzer::class.java) + val face = mock(FaceAnalyzer::class.java) + val a = TaskCompletionSource() + val b = TaskCompletionSource() + `when`(barcode.analyzeWithoutClosing(image)).thenReturn(a.task) + `when`(face.analyzeWithoutClosing(image)).thenReturn(b.task) + pipeline(barcode, face, image) + (if (barcodeFirst) a else b).setResult(null) + shadowOf(Looper.getMainLooper()).idle() + verify(image, never()).close() + (if (barcodeFirst) b else a).setResult(null) + shadowOf(Looper.getMainLooper()).idle() + verify(image, times(1)).close() + } + + @Test + fun secondConsumerThrowStillClosesAfterTheFirstCompletes() { + val image = mock(ImageProxy::class.java) + val barcode = mock(QRCodeAnalyzer::class.java) + val face = mock(FaceAnalyzer::class.java) + val a = TaskCompletionSource() + `when`(barcode.analyzeWithoutClosing(image)).thenReturn(a.task) + `when`(face.analyzeWithoutClosing(image)).thenThrow(IllegalStateException("authored launch failure")) + runCatching { pipeline(barcode, face, image) } + verify(image, never()).close() + a.setResult(null) + shadowOf(Looper.getMainLooper()).idle() + verify(image, times(1)).close() + } + + @Test + fun firstConsumerThrowStillRunsTheSecondAndClosesAfterIt() { + val image = mock(ImageProxy::class.java) + val barcode = mock(QRCodeAnalyzer::class.java) + val face = mock(FaceAnalyzer::class.java) + val b = TaskCompletionSource() + `when`(barcode.analyzeWithoutClosing(image)).thenThrow(IllegalStateException("authored launch failure")) + `when`(face.analyzeWithoutClosing(image)).thenReturn(b.task) + runCatching { pipeline(barcode, face, image) } + verify(face).analyzeWithoutClosing(image) + verify(image, never()).close() + b.setResult(null) + shadowOf(Looper.getMainLooper()).idle() + verify(image, times(1)).close() + } + + @Test + fun noConsumersCloseImmediately() { + val image = mock(ImageProxy::class.java) + pipeline(null, null, image) + verify(image, times(1)).close() + } +}