Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
22 changes: 12 additions & 10 deletions android/src/main/java/com/rncamerakit/barcode/BarcodeFrame.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -89,6 +90,7 @@ class BarcodeFrame(context: Context) : View(context) {
fun setFrameSize(size: Size?) {
barcodeFrameSize = size ?: DEFAULT_SIZE
calculateFrameRect()
invalidate()
}

companion object {
Expand Down
75 changes: 75 additions & 0 deletions android/src/test/java/com/rncamerakit/barcode/BarcodeFrameTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
121 changes: 121 additions & 0 deletions docs/android-scan-frame-layout.md
Original file line number Diff line number Diff line change
@@ -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 (
<View>
<Button title="Toggle frame" onPress={() => setShowFrame(value => !value)} />
<Suspense fallback={null}>
<Camera scanBarcode showFrame={showFrame} style={{ width: 300, aspectRatio: 1 }} />
</Suspense>
</View>
);
}
```

`CKCamera.setShowFrame()` directly lays out a new `BarcodeFrame` before adding
it. Its measured dimensions can still be zero. Previously, `frameRect` was
calculated from measured dimensions and only refreshed during `onMeasure()`.
The minimum 100-pixel frame therefore remained centered at local `(0, 0)`.
React Native need not measure the newly added native child again.

The fix calculates the rectangle from actual layout bounds in `onSizeChanged()`.
Custom frame-size changes use the current layout bounds and invalidate the view.

## Emulator measurements

Actual installed release APKs used the example app with a temporary validation
entry containing ratio, remount and frame-toggle buttons. The native camera
sources came from the baseline and this patch respectively. The emulator was
Android 35 Google APIs ARM64, emulator 36.5.11, 1080×1920 at 420 dpi, with hardware
GPU rendering and a 1280×960 image-file camera input containing an 80-pixel square
grid. Landscape used the rotated 1920×1080 display. Screenshots were inspected
and their original pixels measured.

| Orientation | Layout | Preview pixels | Grid X/Y, baseline / patch | Patched frame center error after re-enabling |
| --- | --- | --- | --- | --- |
| Portrait | 1:1 | 996×996 | 1.000000 / 1.000000 | (0, 0) px |
| Portrait | 4:3 | 996×1328 | 1.000000 / 1.000000 | (0, 0) px |
| Portrait | 16:9 | 874×1552 | 1.003876 / 1.003876 | (0, 0) px |
| Landscape | 1:1 | 712×712 | 1.000000 / 1.000000 | (0, 0) px |
| Landscape | 4:3 | 950×712 | 1.000000 / 1.000000 | (0, 0) px |
| Landscape | 16:9 | 1266×712 | 1.000000 / 1.000000 | (0, 0) px |

Initial frames were centered on both versions. All six baseline re-enabled
frames showed a 53×53-pixel corner fragment. All six patched re-enabled frames
retained their initial bounds. For square portrait, the visible cyan border
changed from `(42,476)-(95,529)` to `(145,776)-(935,1172)`, centered at `(540,974)`.

| Baseline after re-enabling | Patch after re-enabling |
| --- | --- |
| ![Corner fragment](images/android-frame-before.png) | ![Centered frame](images/android-frame-after.png) |

Preview stretching was not reproduced on current master in these six layouts;
grid spacing differed between axes by at most 0.39%, identically before and
after the patch. A Galaxy A13 was not available; the original Android 13 /
v13.0.0 distortion and first-load report remain unverified on that hardware.

Photo capture through the patched app also saved a valid 60,652-byte, 1280×720
JPEG. The visual runs used React Native 0.81's legacy architecture.

## Automated checks

`BarcodeFrameTest` covers direct layout without measurement, normal measured
layout, resize with stale measured dimensions, and custom/default frame sizes.
The square and portrait/landscape 4:3 and 16:9 sizes are included. The Android
build workflow runs these tests with the example build.

Local validation passed the release example build and release lint checks,
four committed Debug tests with the new architecture, and thirteen Release
layout tests with the legacy architecture (four committed tests plus nine
independent measurement and `CKCamera.setShowFrame()` controls).

```sh
cd example/android
./gradlew :react-native-camera-kit:testDebugUnitTest -PnewArchEnabled=true
./gradlew :react-native-camera-kit:testReleaseUnitTest -PnewArchEnabled=false
```

Local builds used JDK 17 and an external Gradle init script selecting installed
SDK 36 because SDK 34 was absent. The project's SDK setting remains 34.
The baseline TypeScript build passed. Existing JavaScript ESLint and Jest
commands were blocked by missing `eslint-plugin-import` and `jest` respectively.

## Related work and scope

[#710](https://github.com/teslamotors/react-native-camera-kit/pull/710) is merged
and chooses the Android use-case aspect ratio from the viewfinder dimensions.
[#644](https://github.com/teslamotors/react-native-camera-kit/pull/644) changes
iOS resize behavior.

Open [#812](https://github.com/teslamotors/react-native-camera-kit/pull/812)
handles a separate Android barcode-selection coordinate defect: fixed 90°
rotation assumptions and independent axis scaling omit the preview's crop and
sensor transform. Its source leaves `BarcodeFrame` layout unchanged. This patch
addresses visible frame placement and its selection rectangle; the coordinate
transform work remains in #812. Issue #576 should stay open for that broader
investigation and device-specific verification.

A separate local comparison compiled #812 at `d218635` with 54 tests: its 17
existing tests and all 24 independent barcode-selection matrix cases passed,
while seven frame-layout checks failed (including three of the four tests added
here). Composing that source with this frame-layout patch passed all 54 tests.
The coordinate unit tests use supplied detections and authored sensor matrices;
they exercise the compiled native callback, with camera hardware mocked.
Binary file added docs/images/android-frame-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/android-frame-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.