Skip to content

Commit 0f90604

Browse files
committed
fix: address PR review comments
- opt-in ExperimentalCamera2Interop to suppress lint UnsafeOptInUsageError - MainActivity: fix String identity comparison (== -> .equals()) - CameraDemoActivity: add null guard for createVideoOutputPath() result; guard getCameraProvider() NPE in onPause - FileUtil: invalidate storagePath cache in init() to allow re-resolution - PermissionUtil: only pass missing permissions to requestPermissions(), not the full array - CameraGLSurfaceView: make mCameraProvider volatile; synchronize getCameraProvider(), setCameraProvider(), and setFlashMode(); forward existing preview size to newly set provider - CameraGLSurfaceView: fix deprecated focusAtPoint to pass actual Camera instance instead of null to legacy AutoFocusCallback - CameraGLSurfaceViewWithTexture: onSwitchCamera now checks needsManualRotation() before applying PI/2 rotation so CameraX preserves its own orientation handling - Camera1Provider: replace hardcoded 90-degree JPEG rotation with CameraInfo-derived rotation (handles front camera correctly) - CameraXProvider: replace deprecated setTargetResolution() with ResolutionSelector/ResolutionStrategy for Preview and ImageCapture - CameraXProvider: move takePicture image conversion to background executor to avoid blocking the main thread - CameraXProvider: fix imageProxyToYuvImage to correctly copy Y plane row-by-row using rowStride and build NV21 chroma using per-pixel and per-row strides, fixing corrupted output when rowStride != width - Pass EXTRA_CAMERA_API to all activities in MainActivity, not just CameraDemoActivity, so FaceTrackingDemoActivity honours the user's camera API selection - Restore thread interrupt status in CameraXProvider.openCamera() when catching InterruptedException to avoid silently discarding the signal - Add TODO comment in values-v35/styles.xml noting that windowOptOutEdgeToEdgeEnforcement is deprecated in API 36 and will require proper insets handling when targeting Android 16
1 parent e89ce06 commit 0f90604

9 files changed

Lines changed: 151 additions & 57 deletions

File tree

cgeDemo/src/main/java/org/wysaid/cgeDemo/CameraDemoActivity.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ public void onClick(View v) {
115115
btn.setText("Recording");
116116
Log.i(LOG_TAG, "Start recording...");
117117
recordFilename = createVideoOutputPath();
118+
if (recordFilename == null) {
119+
Log.e(LOG_TAG, "Failed to create video output path");
120+
showText("Failed to create output path");
121+
isValid = true;
122+
return;
123+
}
118124
mCameraView.startRecording(recordFilename, new CameraRecordGLSurfaceView.StartRecordingCallback() {
119125
@Override
120126
public void startRecordingOver(boolean success) {
@@ -531,7 +537,10 @@ public void onDestroy() {
531537
@Override
532538
public void onPause() {
533539
super.onPause();
534-
mCameraView.getCameraProvider().closeCamera();
540+
ICameraProvider provider = mCameraView.getCameraProvider();
541+
if (provider != null) {
542+
provider.closeCamera();
543+
}
535544
Log.i(LOG_TAG, "activity onPause...");
536545
mCameraView.release(null);
537546
mCameraView.onPause();

cgeDemo/src/main/java/org/wysaid/cgeDemo/MainActivity.java

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ public void setDemo(DemoClassDescription demo) {
226226
@Override
227227
public void onClick(final View v) {
228228

229-
if (mDemo.activityName == "FaceTrackingDemoActivity") {
229+
if ("FaceTrackingDemoActivity".equals(mDemo.activityName)) {
230230
MsgUtil.toastMsg(v.getContext(), "Error: Please checkout the branch 'face_features' for this demo!");
231231
return;
232232
}
@@ -244,12 +244,11 @@ public void onClick(final View v) {
244244
if (cls != null) {
245245
Intent intent = new Intent(MainActivity.this, cls);
246246

247-
// Pass camera API selection only to explicitly supported activities
248-
if ("CameraDemoActivity".equals(mDemo.activityName)) {
249-
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
250-
String api = prefs.getString(PREFS_KEY_CAMERA_API, CAMERA_API_CAMERA1);
251-
intent.putExtra(EXTRA_CAMERA_API, api);
252-
}
247+
// Pass camera API selection to all activities; those that don't use it
248+
// will simply ignore the extra.
249+
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
250+
String api = prefs.getString(PREFS_KEY_CAMERA_API, CAMERA_API_CAMERA1);
251+
intent.putExtra(EXTRA_CAMERA_API, api);
253252

254253
startActivity(intent);
255254
}

cgeDemo/src/main/res/values-v35/styles.xml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
<!-- Android 15 (API 35) enforces edge-to-edge by default when compileSdkVersion >= 35,
55
which causes system bars to overlay app content. Since this demo app uses a traditional
66
ActionBar layout and does not implement windowing insets handling, we opt out of the
7-
forced edge-to-edge behaviour to restore the standard window fitting. -->
7+
forced edge-to-edge behaviour to restore the standard window fitting.
8+
TODO: On Android 16 (API 36), windowOptOutEdgeToEdgeEnforcement is deprecated and
9+
disabled — apps targeting API 36+ cannot opt out of edge-to-edge. When minSdk or
10+
compileSdk is raised to 36, migrate the layout to handle window insets properly. -->
811
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
912
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
1013
</style>

library/src/main/java/org/wysaid/camera/Camera1Provider.java

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,40 @@ public void setFocusMode(String focusMode) {
141141

142142
// ========== Capture ==========
143143

144+
/**
145+
* Computes the JPEG rotation needed so the output appears upright for the given camera facing.
146+
*
147+
* <p>Uses {@link Camera.CameraInfo#orientation} (the sensor-to-natural-orientation angle) as
148+
* the base. For back-facing cameras this value is already correct for portrait mode. For
149+
* front-facing cameras the mirror effect requires a compensating inversion.
150+
*
151+
* <p>Note: A full implementation would also factor in the display rotation
152+
* ({@code getWindowManager().getDefaultDisplay().getRotation()}). Because
153+
* {@link Camera1Provider} has no {@link android.content.Context} reference, the display
154+
* rotation is assumed to be 0° (portrait). This covers the dominant use-case; if your app
155+
* needs landscape support, inject a Context and apply the standard Camera1 rotation formula.
156+
*
157+
* @param facing the Camera1 facing constant ({@link Camera.CameraInfo#CAMERA_FACING_BACK} etc.)
158+
* @return clockwise JPEG rotation in degrees
159+
*/
160+
private int computeJpegRotation(int facing) {
161+
int numberOfCameras = Camera.getNumberOfCameras();
162+
Camera.CameraInfo cameraInfo = new Camera.CameraInfo();
163+
for (int i = 0; i < numberOfCameras; i++) {
164+
Camera.getCameraInfo(i, cameraInfo);
165+
if (cameraInfo.facing == facing) {
166+
if (facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
167+
// Front camera: compensate mirror
168+
return (360 - cameraInfo.orientation) % 360;
169+
} else {
170+
return cameraInfo.orientation;
171+
}
172+
}
173+
}
174+
// Fallback if no matching camera found
175+
return 90;
176+
}
177+
144178
@Override
145179
public void takePicture(ShutterCallback shutterCallback, PictureDataCallback pictureCallback) {
146180
Camera cameraDevice = CameraInstance.getInstance().getCameraDevice();
@@ -152,11 +186,14 @@ public void takePicture(ShutterCallback shutterCallback, PictureDataCallback pic
152186
return;
153187
}
154188

189+
int camera1Facing = CameraInstance.getInstance().getFacing();
190+
int jpegRotation = computeJpegRotation(camera1Facing);
191+
155192
// Set rotation
156193
Camera.Parameters params = CameraInstance.getInstance().getParams();
157194
if (params != null) {
158195
try {
159-
params.setRotation(90);
196+
params.setRotation(jpegRotation);
160197
CameraInstance.getInstance().setParams(params);
161198
} catch (Exception e) {
162199
Log.e(LOG_TAG, "Error setting rotation: " + e.toString());
@@ -168,9 +205,10 @@ public void takePicture(ShutterCallback shutterCallback, PictureDataCallback pic
168205
: null;
169206

170207
CameraFacing currentFacing = getFacing();
208+
final int captureRotation = jpegRotation;
171209
cameraDevice.takePicture(legacyShutter, null, (data, camera) -> {
172210
if (pictureCallback != null) {
173-
pictureCallback.onPictureTaken(data, currentFacing, 90);
211+
pictureCallback.onPictureTaken(data, currentFacing, captureRotation);
174212
}
175213
});
176214
}

library/src/main/java/org/wysaid/camera/CameraXProvider.java

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import android.view.Surface;
1212

1313
import androidx.camera.camera2.interop.Camera2Interop;
14+
import androidx.camera.camera2.interop.ExperimentalCamera2Interop;
1415

1516
import androidx.annotation.NonNull;
1617
import androidx.camera.core.Camera;
@@ -24,6 +25,8 @@
2425
import androidx.camera.core.ImageProxy;
2526
import androidx.camera.core.Preview;
2627
import androidx.camera.core.SurfaceRequest;
28+
import androidx.camera.core.resolutionselector.ResolutionSelector;
29+
import androidx.camera.core.resolutionselector.ResolutionStrategy;
2730
import androidx.camera.lifecycle.ProcessCameraProvider;
2831
import androidx.core.content.ContextCompat;
2932
import androidx.lifecycle.LifecycleOwner;
@@ -34,6 +37,8 @@
3437

3538
import java.nio.ByteBuffer;
3639
import java.util.concurrent.ExecutionException;
40+
import java.util.concurrent.ExecutorService;
41+
import java.util.concurrent.Executors;
3742

3843
/**
3944
* CameraX implementation of {@link ICameraProvider}.
@@ -77,6 +82,9 @@ public class CameraXProvider implements ICameraProvider {
7782
private boolean mPictureSizeBigger = true;
7883
private FlashMode mFlashMode = FlashMode.OFF;
7984

85+
/** Single-thread executor used to process captured images off the main thread. */
86+
private final ExecutorService mCaptureExecutor = Executors.newSingleThreadExecutor();
87+
8088
public CameraXProvider(@NonNull Context context) {
8189
mContext = context.getApplicationContext();
8290
}
@@ -108,8 +116,11 @@ public boolean openCamera(CameraFacing facing, CameraOpenCallback callback) {
108116
if (callback != null) {
109117
callback.cameraReady();
110118
}
111-
} catch (ExecutionException | InterruptedException e) {
119+
} catch (ExecutionException e) {
120+
Log.e(LOG_TAG, "CameraX: Failed to get ProcessCameraProvider: " + e.toString());
121+
} catch (InterruptedException e) {
112122
Log.e(LOG_TAG, "CameraX: Failed to get ProcessCameraProvider: " + e.toString());
123+
Thread.currentThread().interrupt();
113124
}
114125
}, ContextCompat.getMainExecutor(mContext));
115126

@@ -346,11 +357,11 @@ public void takePicture(ShutterCallback shutterCallback, PictureDataCallback pic
346357
shutterCallback.onShutter();
347358
}
348359

349-
mImageCapture.takePicture(ContextCompat.getMainExecutor(mContext),
360+
mImageCapture.takePicture(mCaptureExecutor,
350361
new ImageCapture.OnImageCapturedCallback() {
351362
@Override
352363
public void onCaptureSuccess(@NonNull ImageProxy image) {
353-
// Convert ImageProxy to JPEG bytes
364+
// Convert ImageProxy to JPEG bytes on the background thread.
354365
byte[] jpegData = imageProxyToJpeg(image);
355366
int rotation = image.getImageInfo().getRotationDegrees();
356367
image.close();
@@ -377,6 +388,7 @@ public void resumePreviewAfterCapture() {
377388

378389
// ========== Internal ==========
379390

391+
@androidx.annotation.OptIn(markerClass = ExperimentalCamera2Interop.class)
380392
private void bindCamera() {
381393
if (mCameraProvider == null || mLifecycleOwner == null || mSurfaceTexture == null) {
382394
return;
@@ -391,13 +403,16 @@ private void bindCamera() {
391403

392404
// Build Preview use case with target resolution
393405
Preview.Builder previewBuilder = new Preview.Builder();
394-
previewBuilder.setTargetResolution(
395-
new Size(mPreferredPreviewWidth, mPreferredPreviewHeight));
406+
ResolutionSelector previewResolutionSelector = new ResolutionSelector.Builder()
407+
.setResolutionStrategy(new ResolutionStrategy(
408+
new Size(mPreferredPreviewWidth, mPreferredPreviewHeight),
409+
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER))
410+
.build();
411+
previewBuilder.setResolutionSelector(previewResolutionSelector);
396412

397413
// Request high frame rate (up to 60fps) to match Camera1 behavior.
398414
// Camera2Interop allows setting Camera2 capture-request options on CameraX use cases.
399415
// Using Range(30, 60) so the camera picks the best rate it supports.
400-
@SuppressWarnings("unchecked")
401416
Camera2Interop.Extender<Preview> extender = new Camera2Interop.Extender<>(previewBuilder);
402417
extender.setCaptureRequestOption(
403418
CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, new Range<>(30, 60));
@@ -432,7 +447,12 @@ private void bindCamera() {
432447

433448
// Build ImageCapture use case
434449
ImageCapture.Builder captureBuilder = new ImageCapture.Builder();
435-
captureBuilder.setTargetResolution(new Size(mPictureWidth, mPictureHeight));
450+
ResolutionSelector captureResolutionSelector = new ResolutionSelector.Builder()
451+
.setResolutionStrategy(new ResolutionStrategy(
452+
new Size(mPictureWidth, mPictureHeight),
453+
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER))
454+
.build();
455+
captureBuilder.setResolutionSelector(captureResolutionSelector);
436456

437457
// Apply current flash mode
438458
switch (mFlashMode) {
@@ -492,6 +512,10 @@ private byte[] imageProxyToJpeg(ImageProxy image) {
492512

493513
/**
494514
* Convert ImageProxy (YUV_420_888) to YuvImage (NV21).
515+
*
516+
* <p>Correctly handles the case where a plane's {@code rowStride} is wider than the image
517+
* width (padding rows), by copying exactly {@code width} bytes per row for the Y plane and
518+
* using {@code rowStride}/{@code pixelStride} offsets for the chroma planes.
495519
*/
496520
private android.graphics.YuvImage imageProxyToYuvImage(ImageProxy image) {
497521
if (image.getFormat() != android.graphics.ImageFormat.YUV_420_888) {
@@ -503,47 +527,42 @@ private android.graphics.YuvImage imageProxyToYuvImage(ImageProxy image) {
503527
int width = image.getWidth();
504528
int height = image.getHeight();
505529

506-
// Y plane
530+
int yRowStride = planes[0].getRowStride();
531+
int chromaRowStride = planes[1].getRowStride();
532+
int chromaPixelStride = planes[1].getPixelStride();
533+
507534
ByteBuffer yBuffer = planes[0].getBuffer();
508-
// U plane
509535
ByteBuffer uBuffer = planes[1].getBuffer();
510-
// V plane
511536
ByteBuffer vBuffer = planes[2].getBuffer();
512537

513-
int ySize = yBuffer.remaining();
514-
int uSize = uBuffer.remaining();
515-
int vSize = vBuffer.remaining();
516-
517-
byte[] nv21 = new byte[ySize + width * height / 2];
538+
byte[] nv21 = new byte[width * height * 3 / 2];
518539

519-
// Copy Y
520-
yBuffer.get(nv21, 0, ySize);
540+
// Copy Y plane row-by-row to strip any row-stride padding.
541+
for (int row = 0; row < height; row++) {
542+
yBuffer.position(row * yRowStride);
543+
yBuffer.get(nv21, row * width, width);
544+
}
521545

522-
// Interleave V and U (NV21 = YYYYVUVU)
546+
// Read the full V and U plane buffers (may contain padding).
547+
int vSize = vBuffer.remaining();
548+
int uSize = uBuffer.remaining();
523549
byte[] vData = new byte[vSize];
524550
byte[] uData = new byte[uSize];
551+
vBuffer.rewind();
525552
vBuffer.get(vData);
553+
uBuffer.rewind();
526554
uBuffer.get(uData);
527555

528-
int uvIndex = ySize;
529-
int pixelStride = planes[1].getPixelStride();
530-
531-
if (pixelStride == 2) {
532-
// Already interleaved (common case)
533-
// V plane in NV21 comes first, but the interleaved buffer from CameraX
534-
// may already be in the right order if the V plane starts first
535-
for (int i = 0; i < vSize && uvIndex < nv21.length; i += pixelStride) {
536-
nv21[uvIndex++] = vData[i];
537-
if (i < uSize && uvIndex < nv21.length) {
538-
nv21[uvIndex++] = uData[i];
556+
// Interleave V then U into NV21, using per-pixel and per-row strides to skip padding.
557+
int uvIndex = width * height;
558+
for (int row = 0; row < height / 2; row++) {
559+
for (int col = 0; col < width / 2; col++) {
560+
int srcOffset = row * chromaRowStride + col * chromaPixelStride;
561+
if (srcOffset < vSize && uvIndex < nv21.length) {
562+
nv21[uvIndex++] = vData[srcOffset];
539563
}
540-
}
541-
} else {
542-
// Pixel stride == 1, need to manually interleave
543-
for (int i = 0; i < vSize && uvIndex < nv21.length; i++) {
544-
nv21[uvIndex++] = vData[i];
545-
if (i < uSize && uvIndex < nv21.length) {
546-
nv21[uvIndex++] = uData[i];
564+
if (srcOffset < uSize && uvIndex < nv21.length) {
565+
nv21[uvIndex++] = uData[srcOffset];
547566
}
548567
}
549568
}

library/src/main/java/org/wysaid/myUtils/FileUtil.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ public class FileUtil {
3232
public static void init(Context context) {
3333
if (context != null) {
3434
sAppContext = context.getApplicationContext();
35+
// Invalidate the cached path so the next getPath() call re-resolves
36+
// using the updated context (e.g. after switching from internal to external storage).
37+
storagePath = null;
3538
}
3639
}
3740

library/src/main/java/org/wysaid/myUtils/PermissionUtil.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,22 @@ private static String[] getRequiredPermissions() {
5858
public static void verifyStoragePermissions(Activity activity) {
5959
try {
6060
String[] permissions = getRequiredPermissions();
61+
java.util.List<String> missingPermissions = new java.util.ArrayList<>();
6162
StringBuilder toastText = null;
6263
for (int i = 0; i != permissions.length; ++i) {
6364
int reqCode = ActivityCompat.checkSelfPermission(activity, permissions[i]);
6465
if (reqCode != PackageManager.PERMISSION_GRANTED) {
66+
missingPermissions.add(permissions[i]);
6567
if (toastText == null) {
6668
toastText = new StringBuilder("Request permission");
6769
}
6870
toastText.append(" ").append(permissions[i]);
6971
}
7072
}
71-
if (toastText != null) {
73+
if (toastText != null && !missingPermissions.isEmpty()) {
7274
Toast.makeText(activity, toastText.toString(), Toast.LENGTH_LONG).show();
73-
ActivityCompat.requestPermissions(activity, permissions, REQUEST_PERMISSION);
75+
ActivityCompat.requestPermissions(activity,
76+
missingPermissions.toArray(new String[0]), REQUEST_PERMISSION);
7477
}
7578
} catch (Exception e) {
7679
Log.e(Common.LOG_TAG, "Error: " + e.getMessage());

0 commit comments

Comments
 (0)