diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef555f0225..19415e87ca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,7 @@ compileSdk = "34" minSdk = "21" spotless = "7.0.4" gummyBears = "0.12.0" +camerax = "1.3.0" [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } @@ -200,6 +201,13 @@ androidx-test-runner = { module = "androidx.test:runner", version = "1.6.2" } awaitility-kotlin = { module = "org.awaitility:awaitility-kotlin", version = "4.1.1" } awaitility-kotlin-spring7 = { module = "org.awaitility:awaitility-kotlin", version = "4.3.0" } awaitility3-kotlin = { module = "org.awaitility:awaitility-kotlin", version = "3.1.6" } + +# CameraX dependencies +camerax-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } +camerax-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } +camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } +camerax-view = { module = "androidx.camera:camera-view", version.ref = "camerax" } + hsqldb = { module = "org.hsqldb:hsqldb", version = "2.6.1" } javafaker = { module = "com.github.javafaker:javafaker", version = "1.0.2" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index acec6ac809..48ac6dda5c 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -137,6 +137,7 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) + implementation(libs.androidx.constraintlayout) implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.foundation.layout) @@ -149,6 +150,10 @@ dependencies { implementation(libs.retrofit.gson) implementation(libs.sentry.native.ndk) implementation(libs.timber) + implementation(libs.camerax.core) + implementation(libs.camerax.camera2) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) debugImplementation(projects.sentryAndroidDistribution) debugImplementation(libs.leakcanary) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 65445d8570..c6360ca911 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -75,6 +75,10 @@ + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/CameraXActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/CameraXActivity.java new file mode 100644 index 0000000000..b8c0491459 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/CameraXActivity.java @@ -0,0 +1,166 @@ +package io.sentry.samples.android; + +import android.Manifest; +import android.content.ContentValues; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.provider.MediaStore; +import android.util.Log; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.camera.core.Camera; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.ImageCapture; +import androidx.camera.core.ImageCaptureException; +import androidx.camera.core.Preview; +import androidx.camera.lifecycle.ProcessCameraProvider; +import androidx.camera.view.PreviewView; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import com.google.common.util.concurrent.ListenableFuture; +import io.sentry.Sentry; +import io.sentry.samples.android.databinding.ActivityCameraxBinding; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.concurrent.ExecutionException; + +public class CameraXActivity extends AppCompatActivity { + private static final String TAG = "CameraXActivity"; + private static final int CAMERA_PERMISSION_REQUEST_CODE = 1001; + + private ActivityCameraxBinding binding; + private PreviewView previewView; + private ListenableFuture cameraProviderFuture; + private ImageCapture imageCapture; + private Camera camera; + private CameraSelector cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityCameraxBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + previewView = binding.previewView; + + if (allPermissionsGranted()) { + startCamera(); + } else { + ActivityCompat.requestPermissions( + this, new String[] {Manifest.permission.CAMERA}, CAMERA_PERMISSION_REQUEST_CODE); + } + + binding.captureButton.setOnClickListener(view -> takePhoto()); + binding.switchCameraButton.setOnClickListener(view -> switchCamera()); + binding.backButton.setOnClickListener(view -> finish()); + } + + private void startCamera() { + cameraProviderFuture = ProcessCameraProvider.getInstance(this); + cameraProviderFuture.addListener( + () -> { + try { + ProcessCameraProvider cameraProvider = cameraProviderFuture.get(); + bindPreview(cameraProvider); + } catch (ExecutionException | InterruptedException e) { + Log.e(TAG, "Error starting camera", e); + Sentry.captureException(e); + } + }, + ContextCompat.getMainExecutor(this)); + } + + private void bindPreview(ProcessCameraProvider cameraProvider) { + Preview preview = new Preview.Builder().build(); + imageCapture = + new ImageCapture.Builder() + .setTargetRotation(previewView.getDisplay().getRotation()) + .build(); + + preview.setSurfaceProvider(previewView.getSurfaceProvider()); + + cameraProvider.unbindAll(); + camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture); + } + + private void takePhoto() { + if (imageCapture == null) return; + + String timeStamp = + new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(new Date()); + String fileName = "CameraX_" + timeStamp + ".jpg"; + + ContentValues contentValues = new ContentValues(); + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName); + contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg"); + contentValues.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraX-Images"); + + ImageCapture.OutputFileOptions outputFileOptions = + new ImageCapture.OutputFileOptions.Builder( + getContentResolver(), MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + .build(); + + imageCapture.takePicture( + outputFileOptions, + ContextCompat.getMainExecutor(this), + new ImageCapture.OnImageSavedCallback() { + @Override + public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) { + String msg = "Photo saved successfully: " + outputFileResults.getSavedUri(); + Toast.makeText(CameraXActivity.this, "Photo saved!", Toast.LENGTH_SHORT).show(); + Log.d(TAG, msg); + } + + @Override + public void onError(@NonNull ImageCaptureException exception) { + Log.e(TAG, "Photo capture failed", exception); + Toast.makeText(CameraXActivity.this, "Photo capture failed", Toast.LENGTH_SHORT).show(); + Sentry.captureException(exception); + } + }); + } + + private void switchCamera() { + cameraSelector = + (cameraSelector == CameraSelector.DEFAULT_BACK_CAMERA) + ? CameraSelector.DEFAULT_FRONT_CAMERA + : CameraSelector.DEFAULT_BACK_CAMERA; + + try { + if (cameraProviderFuture != null) { + ProcessCameraProvider cameraProvider = cameraProviderFuture.get(); + bindPreview(cameraProvider); + } + } catch (ExecutionException | InterruptedException e) { + Log.e(TAG, "Error switching camera", e); + Sentry.captureException(e); + } + } + + private boolean allPermissionsGranted() { + return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED; + } + + @Override + public void onRequestPermissionsResult( + int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) { + if (allPermissionsGranted()) { + startCamera(); + } else { + Toast.makeText(this, "Camera permission is required", Toast.LENGTH_SHORT).show(); + finish(); + } + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + binding = null; + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 25907655f7..62280ecd76 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -341,6 +341,11 @@ public void run() { }); }); + binding.openCameraActivity.setOnClickListener( + view -> { + startActivity(new Intent(this, CameraXActivity.class)); + }); + Sentry.logger().log(SentryLogLevel.INFO, "Creating content view"); setContentView(binding.getRoot()); diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_camerax.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_camerax.xml new file mode 100644 index 0000000000..88c85e7caf --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_camerax.xml @@ -0,0 +1,53 @@ + + + + + +