Implement VideoCapture function in CameraXService
Bug: 297450506
Test: CameraXServiceTest
Change-Id: Idb2a8da1c486cfbaa03aad616969dc43013deb6b
diff --git a/camera/integration-tests/coretestapp/lint-baseline.xml b/camera/integration-tests/coretestapp/lint-baseline.xml
index 78ea985..9e9f16f 100644
--- a/camera/integration-tests/coretestapp/lint-baseline.xml
+++ b/camera/integration-tests/coretestapp/lint-baseline.xml
@@ -238,8 +238,8 @@
<issue
id="RestrictedApiAndroidX"
message="FileUtil.createParentFolder can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
- errorLine1=" if (createParentFolder(pictureFolder)) {"
- errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ errorLine1=" if (!createParentFolder(pictureFolder)) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
</issue>
@@ -247,8 +247,143 @@
<issue
id="RestrictedApiAndroidX"
message="FileUtil.createParentFolder can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
- errorLine1=" if (createParentFolder(pictureFolder)) {"
- errorLine2=" ~~~~~~~~~~~~~">
+ errorLine1=" if (!createParentFolder(pictureFolder)) {"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.canDeviceWriteToMediaStore can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" if (!canDeviceWriteToMediaStore()) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.generateVideoFileOutputOptions can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" this, generateVideoFileOutputOptions(fileName, extension));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.generateVideoFileOutputOptions can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" this, generateVideoFileOutputOptions(fileName, extension));"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.generateVideoFileOutputOptions can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" this, generateVideoFileOutputOptions(fileName, extension));"
+ errorLine2=" ~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.generateVideoMediaStoreOptions can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" generateVideoMediaStoreOptions(getContentResolver(), fileName));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.generateVideoMediaStoreOptions can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" generateVideoMediaStoreOptions(getContentResolver(), fileName));"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.generateVideoMediaStoreOptions can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" generateVideoMediaStoreOptions(getContentResolver(), fileName));"
+ errorLine2=" ~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.getAbsolutePathFromUri can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" String videoFilePath = getAbsolutePathFromUri(getContentResolver(),"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.getAbsolutePathFromUri can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" String videoFilePath = getAbsolutePathFromUri(getContentResolver(),"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.getAbsolutePathFromUri can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" MediaStore.Video.Media.EXTERNAL_CONTENT_URI);"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.createParentFolder can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" if (videoFilePath == null || !createParentFolder(videoFilePath)) {"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.createParentFolder can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" if (videoFilePath == null || !createParentFolder(videoFilePath)) {"
+ errorLine2=" ~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.getAbsolutePathFromUri can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" videoFilePath = getAbsolutePathFromUri("
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.getAbsolutePathFromUri can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" getApplicationContext().getContentResolver(),"
+ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+ <location
+ file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
+ </issue>
+
+ <issue
+ id="RestrictedApiAndroidX"
+ message="FileUtil.getAbsolutePathFromUri can only be called from within the same library group (referenced groupId=`androidx.camera` from groupId=`androidx.camera.integration-tests`)"
+ errorLine1=" uri"
+ errorLine2=" ~~~">
<location
file="src/main/java/androidx/camera/integration/core/CameraXService.java"/>
</issue>
diff --git a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/CameraXServiceTest.kt b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/CameraXServiceTest.kt
index df79650..5ce4624 100644
--- a/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/CameraXServiceTest.kt
+++ b/camera/integration-tests/coretestapp/src/androidTest/java/androidx/camera/integration/core/CameraXServiceTest.kt
@@ -33,6 +33,8 @@
import androidx.camera.core.ImageCapture
import androidx.camera.core.UseCase
import androidx.camera.integration.core.CameraXService.ACTION_BIND_USE_CASES
+import androidx.camera.integration.core.CameraXService.ACTION_START_RECORDING
+import androidx.camera.integration.core.CameraXService.ACTION_STOP_RECORDING
import androidx.camera.integration.core.CameraXService.ACTION_TAKE_PICTURE
import androidx.camera.integration.core.CameraXService.EXTRA_IMAGE_ANALYSIS_ENABLED
import androidx.camera.integration.core.CameraXService.EXTRA_IMAGE_CAPTURE_ENABLED
@@ -52,6 +54,7 @@
import com.google.common.truth.Truth.assertThat
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
@@ -78,6 +81,7 @@
val permissionRule: GrantPermissionRule =
GrantPermissionRule.grant(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
+ Manifest.permission.RECORD_AUDIO,
)
@get:Rule
@@ -162,7 +166,7 @@
}
@Test
- fun canReceiveAnalysisFrame() = runBlocking {
+ fun canReceiveAnalysisFrame() {
// Arrange.
context.startService(createServiceIntent(ACTION_BIND_USE_CASES).apply {
putExtra(EXTRA_IMAGE_ANALYSIS_ENABLED, true)
@@ -176,7 +180,7 @@
}
@Test
- fun canTakePicture() = runBlocking {
+ fun canTakePicture() {
// Arrange.
context.startService(createServiceIntent(ACTION_BIND_USE_CASES).apply {
putExtra(EXTRA_IMAGE_CAPTURE_ENABLED, true)
@@ -190,6 +194,25 @@
assertThat(latch.await(15, TimeUnit.SECONDS)).isTrue()
}
+ @Test
+ fun canRecordVideo() = runBlocking {
+ // Arrange.
+ context.startService(createServiceIntent(ACTION_BIND_USE_CASES).apply {
+ putExtra(EXTRA_VIDEO_CAPTURE_ENABLED, true)
+ })
+
+ // Act.
+ val latch = service.acquireRecordVideoCountDownLatch()
+ context.startService(createServiceIntent(ACTION_START_RECORDING))
+
+ delay(3000L)
+
+ context.startService(createServiceIntent(ACTION_STOP_RECORDING))
+
+ // Assert.
+ assertThat(latch.await(15, TimeUnit.SECONDS)).isTrue()
+ }
+
private fun createServiceIntent(action: String? = null) =
Intent(context, CameraXService::class.java).apply {
action?.let { setAction(it) }
diff --git a/camera/integration-tests/coretestapp/src/main/AndroidManifest.xml b/camera/integration-tests/coretestapp/src/main/AndroidManifest.xml
index 3fd027e..5ee37e2 100644
--- a/camera/integration-tests/coretestapp/src/main/AndroidManifest.xml
+++ b/camera/integration-tests/coretestapp/src/main/AndroidManifest.xml
@@ -79,11 +79,13 @@
<service
android:name=".CameraXService"
android:exported="true"
- android:foregroundServiceType="camera"
+ android:foregroundServiceType="camera|microphone"
android:label="CameraX Service">
<intent-filter>
<action android:name="androidx.camera.integration.core.intent.action.BIND_USE_CASES" />
<action android:name="androidx.camera.integration.core.intent.action.TAKE_PICTURE" />
+ <action android:name="androidx.camera.integration.core.intent.action.START_RECORDING" />
+ <action android:name="androidx.camera.integration.core.intent.action.STOP_RECORDING" />
</intent-filter>
</service>
</application>
@@ -97,4 +99,5 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
</manifest>
diff --git a/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXService.java b/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXService.java
index 4e5333c..ac8e9cf 100644
--- a/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXService.java
+++ b/camera/integration-tests/coretestapp/src/main/java/androidx/camera/integration/core/CameraXService.java
@@ -17,7 +17,16 @@
package androidx.camera.integration.core;
import static androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA;
+import static androidx.camera.testing.impl.FileUtil.canDeviceWriteToMediaStore;
import static androidx.camera.testing.impl.FileUtil.createParentFolder;
+import static androidx.camera.testing.impl.FileUtil.generateVideoFileOutputOptions;
+import static androidx.camera.testing.impl.FileUtil.generateVideoMediaStoreOptions;
+import static androidx.camera.testing.impl.FileUtil.getAbsolutePathFromUri;
+import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED;
+import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED;
+import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_INSUFFICIENT_STORAGE;
+import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_NONE;
+import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE;
import static com.google.common.base.Preconditions.checkNotNull;
@@ -25,6 +34,7 @@
import android.app.NotificationManager;
import android.content.ContentValues;
import android.content.Intent;
+import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Binder;
import android.os.Build;
@@ -45,8 +55,14 @@
import androidx.camera.core.UseCase;
import androidx.camera.core.UseCaseGroup;
import androidx.camera.lifecycle.ProcessCameraProvider;
+import androidx.camera.video.FileOutputOptions;
+import androidx.camera.video.MediaStoreOutputOptions;
+import androidx.camera.video.OutputOptions;
+import androidx.camera.video.PendingRecording;
import androidx.camera.video.Recorder;
+import androidx.camera.video.Recording;
import androidx.camera.video.VideoCapture;
+import androidx.camera.video.VideoRecordEvent;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
import androidx.core.util.Consumer;
@@ -82,6 +98,10 @@
"androidx.camera.integration.core.intent.action.BIND_USE_CASES";
public static final String ACTION_TAKE_PICTURE =
"androidx.camera.integration.core.intent.action.TAKE_PICTURE";
+ public static final String ACTION_START_RECORDING =
+ "androidx.camera.integration.core.intent.action.START_RECORDING";
+ public static final String ACTION_STOP_RECORDING =
+ "androidx.camera.integration.core.intent.action.STOP_RECORDING";
// Extras
public static final String EXTRA_VIDEO_CAPTURE_ENABLED = "EXTRA_VIDEO_CAPTURE_ENABLED";
@@ -94,12 +114,14 @@
// Members only accessed on main thread //
////////////////////////////////////////////////////////////////////////////////////////////////
private final Map<Class<?>, UseCase> mBoundUseCases = new HashMap<>();
+ @Nullable
+ private Recording mActiveRecording;
//--------------------------------------------------------------------------------------------//
////////////////////////////////////////////////////////////////////////////////////////////////
// Members for testing //
////////////////////////////////////////////////////////////////////////////////////////////////
- private final Set<Uri> mSavedImageUri = new HashSet<>();
+ private final Set<Uri> mSavedMediaUri = new HashSet<>();
@Nullable
private Consumer<Collection<UseCase>> mOnUseCaseBoundCallback;
@@ -107,6 +129,8 @@
private CountDownLatch mAnalysisFrameLatch;
@Nullable
private CountDownLatch mTakePictureLatch;
+ @Nullable
+ private CountDownLatch mRecordVideoLatch;
//--------------------------------------------------------------------------------------------//
@Override
@@ -131,6 +155,10 @@
bindToLifecycle(intent);
} else if (ACTION_TAKE_PICTURE.equals(action)) {
takePicture();
+ } else if (ACTION_START_RECORDING.equals(action)) {
+ startRecording();
+ } else if (ACTION_STOP_RECORDING.equals(action)) {
+ stopRecording();
}
}
return super.onStartCommand(intent, flags, startId);
@@ -222,6 +250,12 @@
return (ImageCapture) mBoundUseCases.get(ImageCapture.class);
}
+ @SuppressWarnings("unchecked")
+ @Nullable
+ private VideoCapture<Recorder> getVideoCapture() {
+ return (VideoCapture<Recorder>) mBoundUseCases.get(VideoCapture.class);
+ }
+
private void takePicture() {
ImageCapture imageCapture = getImageCapture();
if (imageCapture == null) {
@@ -250,7 +284,7 @@
long durationMs = SystemClock.elapsedRealtime() - startTimeMs;
Log.d(TAG, "Saved image " + outputFileResults.getSavedUri()
+ " (" + durationMs + " ms)");
- mSavedImageUri.add(outputFileResults.getSavedUri());
+ mSavedMediaUri.add(outputFileResults.getSavedUri());
if (mTakePictureLatch != null) {
mTakePictureLatch.countDown();
}
@@ -272,6 +306,52 @@
}
}
+ private void startRecording() {
+ VideoCapture<Recorder> videoCapture = getVideoCapture();
+ if (videoCapture == null) {
+ Log.w(TAG, "VideoCapture is not bound.");
+ return;
+ }
+
+ createDefaultVideoFolderIfNotExist();
+ if (mActiveRecording == null) {
+ PendingRecording pendingRecording;
+ String fileName = "video_" + System.currentTimeMillis();
+ String extension = "mp4";
+ if (canDeviceWriteToMediaStore()) {
+ // Use MediaStoreOutputOptions for public share media storage.
+ pendingRecording = getVideoCapture().getOutput().prepareRecording(
+ this,
+ generateVideoMediaStoreOptions(getContentResolver(), fileName));
+ } else {
+ // Use FileOutputOption for devices in MediaStoreVideoCannotWrite Quirk.
+ pendingRecording = getVideoCapture().getOutput().prepareRecording(
+ this, generateVideoFileOutputOptions(fileName, extension));
+ }
+ //noinspection MissingPermission
+ mActiveRecording = pendingRecording
+ .withAudioEnabled()
+ .start(ContextCompat.getMainExecutor(this), mRecordingListener);
+ } else {
+ Log.e(TAG, "It should stop the active recording before start a new one.");
+ }
+ }
+
+ private void stopRecording() {
+ if (mActiveRecording != null) {
+ mActiveRecording.stop();
+ mActiveRecording = null;
+ }
+ }
+
+ private void createDefaultVideoFolderIfNotExist() {
+ String videoFilePath = getAbsolutePathFromUri(getContentResolver(),
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI);
+ if (videoFilePath == null || !createParentFolder(videoFilePath)) {
+ Log.e(TAG, "Failed to create parent directory for: " + videoFilePath);
+ }
+ }
+
private final ImageAnalysis.Analyzer mAnalyzer = image -> {
if (mAnalysisFrameLatch != null) {
mAnalysisFrameLatch.countDown();
@@ -279,6 +359,58 @@
image.close();
};
+ private final Consumer<VideoRecordEvent> mRecordingListener = event -> {
+ if (event instanceof VideoRecordEvent.Finalize) {
+ VideoRecordEvent.Finalize finalize = (VideoRecordEvent.Finalize) event;
+
+ switch (finalize.getError()) {
+ case ERROR_NONE:
+ case ERROR_FILE_SIZE_LIMIT_REACHED:
+ case ERROR_DURATION_LIMIT_REACHED:
+ case ERROR_INSUFFICIENT_STORAGE:
+ case ERROR_SOURCE_INACTIVE:
+ Uri uri = finalize.getOutputResults().getOutputUri();
+ OutputOptions outputOptions = finalize.getOutputOptions();
+ String msg;
+ String videoFilePath;
+ if (outputOptions instanceof MediaStoreOutputOptions) {
+ msg = "Saved video " + uri;
+ videoFilePath = getAbsolutePathFromUri(
+ getApplicationContext().getContentResolver(),
+ uri
+ );
+ } else if (outputOptions instanceof FileOutputOptions) {
+ videoFilePath = ((FileOutputOptions) outputOptions).getFile().getPath();
+ MediaScannerConnection.scanFile(this,
+ new String[]{videoFilePath}, null,
+ (path, uri1) -> Log.i(TAG, "Scanned " + path + " -> uri= " + uri1));
+ msg = "Saved video " + videoFilePath;
+ } else {
+ throw new AssertionError("Unknown or unsupported OutputOptions type: "
+ + outputOptions.getClass().getSimpleName());
+ }
+ // The video file path is used in tracing e2e test log. Don't remove it.
+ Log.d(TAG, "Saved video file: " + videoFilePath);
+
+ if (finalize.getError() != ERROR_NONE) {
+ msg += " with code (" + finalize.getError() + ")";
+ }
+ Log.d(TAG, msg, finalize.getCause());
+
+ mSavedMediaUri.add(uri);
+ if (mRecordVideoLatch != null) {
+ mRecordVideoLatch.countDown();
+ }
+ break;
+ default:
+ String errMsg = "Video capture failed by (" + finalize.getError() + "): "
+ + finalize.getCause();
+ Log.e(TAG, errMsg, finalize.getCause());
+ }
+ mActiveRecording = null;
+ }
+ };
+
@RequiresApi(26)
static class Api26Impl {
@@ -320,8 +452,15 @@
}
@VisibleForTesting
+ @NonNull
+ CountDownLatch acquireRecordVideoCountDownLatch() {
+ mRecordVideoLatch = new CountDownLatch(1);
+ return mRecordVideoLatch;
+ }
+
+ @VisibleForTesting
void deleteSavedMediaFiles() {
- deleteUriSet(mSavedImageUri);
+ deleteUriSet(mSavedMediaUri);
}
private void deleteUriSet(@NonNull Set<Uri> uriSet) {