diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c9f1a084aa..f20376d3ac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,4 +44,4 @@ jobs: - name: Install example run: yarn bootstrap-linux - name: Build - run: cd example/android && ./gradlew assembleDebug + run: cd example/android && ./gradlew assembleDebug :react-native-camera-kit:testDebugUnitTest diff --git a/android/build.gradle b/android/build.gradle index 145733b842..f9100936c6 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -14,6 +14,7 @@ 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" @@ -46,6 +47,9 @@ android { } dependencies { + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.14.1' + 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/main/java/com/rncamerakit/barcode/BarcodeFrame.kt b/android/src/main/java/com/rncamerakit/barcode/BarcodeFrame.kt index d80d7afb35..b0ff5af5cb 100644 --- a/android/src/main/java/com/rncamerakit/barcode/BarcodeFrame.kt +++ b/android/src/main/java/com/rncamerakit/barcode/BarcodeFrame.kt @@ -32,24 +32,25 @@ class BarcodeFrame(context: Context) : View(context) { borderMargin = context.resources.getDimensionPixelSize(R.dimen.border_length) } - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - calculateFrameRect() + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + // React Native can lay out a newly enabled frame without measuring it. + calculateFrameRect(w, h) } - private fun calculateFrameRect() { + private fun calculateFrameRect(viewWidth: Int = width, viewHeight: Int = height) { val marginHeight = 40 val marginWidth = 40 val frameMaxWidth = barcodeFrameSize.width * context.resources.displayMetrics.density val frameMaxHeight = barcodeFrameSize.height * context.resources.displayMetrics.density val frameMinWidth = 100 val frameMinHeight = 100 - frameWidth = max(frameMinWidth, min(frameMaxWidth.toInt(), measuredWidth - (marginWidth * 2))) - frameHeight = max(frameMinHeight, min(frameMaxHeight.toInt(), measuredHeight - (marginHeight * 2))) - frameRect.left = (measuredWidth / 2) - (frameWidth / 2) - frameRect.right = (measuredWidth / 2) + (frameWidth / 2) - frameRect.top = (measuredHeight / 2) - (frameHeight / 2) - frameRect.bottom = (measuredHeight / 2) + (frameHeight / 2) + frameWidth = max(frameMinWidth, min(frameMaxWidth.toInt(), viewWidth - (marginWidth * 2))) + frameHeight = max(frameMinHeight, min(frameMaxHeight.toInt(), viewHeight - (marginHeight * 2))) + frameRect.left = (viewWidth / 2) - (frameWidth / 2) + frameRect.right = (viewWidth / 2) + (frameWidth / 2) + frameRect.top = (viewHeight / 2) - (frameHeight / 2) + frameRect.bottom = (viewHeight / 2) + (frameHeight / 2) } override fun onDraw(canvas: Canvas) { @@ -89,6 +90,7 @@ class BarcodeFrame(context: Context) : View(context) { fun setFrameSize(size: Size?) { barcodeFrameSize = size ?: DEFAULT_SIZE calculateFrameRect() + invalidate() } companion object { diff --git a/android/src/test/java/com/rncamerakit/barcode/BarcodeFrameTest.kt b/android/src/test/java/com/rncamerakit/barcode/BarcodeFrameTest.kt new file mode 100644 index 0000000000..c1bc76d144 --- /dev/null +++ b/android/src/test/java/com/rncamerakit/barcode/BarcodeFrameTest.kt @@ -0,0 +1,75 @@ +package com.rncamerakit.barcode + +import android.graphics.Rect +import android.util.Size +import android.view.View +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "mdpi") +class BarcodeFrameTest { + // Square, portrait/landscape 4:3, and portrait/landscape 16:9. + private val previewSizes = listOf( + Size(800, 800), Size(600, 800), Size(800, 600), + Size(450, 800), Size(800, 450), + ) + + @Test + fun directLayoutCentersTheFrameWithoutMeasurement() { + for (size in previewSizes) { + val frame = newFrame() + // CKCamera.setShowFrame lays out a new overlay directly. + frame.layout(0, 0, size.width, size.height) + assertCentered(frame, size) + } + } + + @Test + fun measuredLayoutPreservesTheSameFrameBounds() { + for (size in previewSizes) { + val frame = newFrame() + frame.measure(exactly(size.width), exactly(size.height)) + frame.layout(0, 0, size.width, size.height) + assertCentered(frame, size) + } + } + + @Test + fun layoutResizeUpdatesTheFrameEvenWhenMeasuredSizeIsStale() { + val frame = newFrame() + frame.measure(exactly(600), exactly(800)) + frame.layout(0, 0, 600, 800) + frame.layout(0, 0, 800, 600) + assertEquals(600, frame.measuredWidth) + assertCentered(frame, Size(800, 600)) + } + + @Test + fun customFrameSizeUsesTheCurrentLayoutBounds() { + val frame = newFrame() + frame.layout(0, 0, 800, 600) + frame.setFrameSize(Size(200, 100)) + assertEquals(Rect(300, 250, 500, 350), frame.frameRect) + frame.setFrameSize(null) + assertCentered(frame, Size(800, 600)) + } + + private fun newFrame() = BarcodeFrame(RuntimeEnvironment.getApplication()).apply { + setFrameSize(Size(300, 150)) + } + + private fun assertCentered(frame: BarcodeFrame, size: Size) { + assertEquals( + "Frame bounds after layout to $size", + Rect(size.width / 2 - 150, size.height / 2 - 75, size.width / 2 + 150, size.height / 2 + 75), + frame.frameRect, + ) + } + + private fun exactly(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY) +} diff --git a/docs/android-scan-frame-layout.md b/docs/android-scan-frame-layout.md new file mode 100644 index 0000000000..e751a5a681 --- /dev/null +++ b/docs/android-scan-frame-layout.md @@ -0,0 +1,121 @@ +# Android scan-frame layout validation + +Related to [#576](https://github.com/teslamotors/react-native-camera-kit/issues/576). +Baseline: `a2a81cee8995c831aca472836e2be446994ba070` (current master when checked). + +## Reproduced failure + +After mounting an Android camera with `showFrame`, toggle the prop false, then +true without changing the camera's layout. The newly enabled frame appears as +a small corner fragment. This reproduces in square, 4:3 and 16:9 camera views, +in portrait and landscape display orientations. + +With camera permission already granted, a minimal reproduction is: + +```tsx +import React, { Suspense, useState } from 'react'; +import { Button, View } from 'react-native'; +import { Camera } from 'react-native-camera-kit'; + +export default function ScanFrameExample() { + const [showFrame, setShowFrame] = useState(true); + return ( + +