diff --git a/BUILD.md b/BUILD.md index d068994..c9c4d8f 100644 --- a/BUILD.md +++ b/BUILD.md @@ -45,16 +45,16 @@ $ git clone --depth=1 https://git.ffmpeg.org/ffmpeg.git If you want Gocryptfs support, you need to download OpenSSL: ``` $ cd ../libgocryptfs -$ wget https://www.openssl.org/source/openssl-1.1.1t.tar.gz +$ wget https://www.openssl.org/source/openssl-1.1.1v.tar.gz ``` Verify OpenSSL signature: ``` -$ wget https://www.openssl.org/source/openssl-1.1.1t.tar.gz.asc -$ gpg --verify openssl-1.1.1t.tar.gz.asc openssl-1.1.1t.tar.gz +$ wget https://www.openssl.org/source/openssl-1.1.1v.tar.gz.asc +$ gpg --verify openssl-1.1.1v.tar.gz.asc openssl-1.1.1v.tar.gz ``` Continue **ONLY** if the signature is **VALID**. ``` -$ tar -xzf openssl-1.1.1t.tar.gz +$ tar -xzf openssl-1.1.1v.tar.gz ``` If you want CryFS support, initialize libcryfs: ``` @@ -76,7 +76,7 @@ $ ./build.sh ffmpeg This step is only required if you want Gocryptfs support. ``` $ cd app/libgocryptfs -$ OPENSSL_PATH="./openssl-1.1.1t" ./build.sh +$ OPENSSL_PATH="./openssl-1.1.1v" ./build.sh ``` ## Compile APKs Gradle build libgocryptfs and libcryfs by default. diff --git a/app/build.gradle b/app/build.gradle index e043a58..6bd02ed 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,13 +20,17 @@ if (hasProperty("nosplits")) { } android { - compileSdkVersion 33 - buildToolsVersion "33.0.0" + compileSdk 34 ndkVersion "25.1.8937393" namespace "sushi.hardcore.droidfs" compileOptions { - targetCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" } defaultConfig { @@ -59,7 +63,7 @@ android { } } - applicationVariants.all { variant -> + applicationVariants.configureEach { variant -> variant.resValue "string", "versionName", variant.versionName buildConfigField "boolean", "CRYFS_DISABLED", "${project.ext.disableCryFS}" buildConfigField "boolean", "GOCRYPTFS_DISABLED", "${project.ext.disableGocryptfs}" @@ -67,6 +71,7 @@ android { buildFeatures { viewBinding true + buildConfig true } buildTypes { @@ -99,27 +104,27 @@ android { dependencies { implementation project(":libpdfviewer:app") implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation 'androidx.core:core-ktx:1.10.0' + implementation 'androidx.core:core-ktx:1.12.0' implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.constraintlayout:constraintlayout:2.1.4" - def lifecycle_version = "2.6.1" + def lifecycle_version = "2.6.2" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version" implementation "androidx.sqlite:sqlite-ktx:2.3.1" - implementation "androidx.preference:preference-ktx:1.2.0" + implementation "androidx.preference:preference-ktx:1.2.1" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation 'com.google.android.material:material:1.9.0' implementation 'com.github.bumptech.glide:glide:4.15.1' implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" - def exoplayer_version = "2.18.6" + def exoplayer_version = "2.19.1" implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version" implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version" implementation "androidx.concurrent:concurrent-futures:1.1.0" - def camerax_version = "1.3.0-alpha06" + def camerax_version = "1.3.0-rc01" implementation "androidx.camera:camera-camera2:$camerax_version" implementation "androidx.camera:camera-lifecycle:$camerax_version" implementation "androidx.camera:camera-view:$camerax_version" diff --git a/app/libcryfs b/app/libcryfs index 3c56f86..6388eaf 160000 --- a/app/libcryfs +++ b/app/libcryfs @@ -1 +1 @@ -Subproject commit 3c56f86d86afacaf4a07ae77aa3d146764d587ec +Subproject commit 6388eaf433a4196f10389921d5e346c90ee3d793 diff --git a/app/libgocryptfs b/app/libgocryptfs index ab3e788..4f32853 160000 --- a/app/libgocryptfs +++ b/app/libgocryptfs @@ -1 +1 @@ -Subproject commit ab3e7886767d31f32baebcd72ebe5f098a70d65b +Subproject commit 4f32853ae5ac70811b451cac60ed36fd5b93cbc8 diff --git a/app/src/main/java/androidx/camera/video/SucklessPendingRecording.java b/app/src/main/java/androidx/camera/video/SucklessPendingRecording.java index 4e17eb1..0d659e8 100644 --- a/app/src/main/java/androidx/camera/video/SucklessPendingRecording.java +++ b/app/src/main/java/androidx/camera/video/SucklessPendingRecording.java @@ -58,8 +58,8 @@ public final class SucklessPendingRecording { private final OutputOptions mOutputOptions; private Consumer mEventListener; private Executor mListenerExecutor; - private boolean mAudioEnabled = false; + private boolean mIsPersistent = false; SucklessPendingRecording(@NonNull Context context, @NonNull SucklessRecorder recorder, @NonNull OutputOptions options) { @@ -104,6 +104,10 @@ public final class SucklessPendingRecording { return mAudioEnabled; } + boolean isPersistent() { + return mIsPersistent; + } + /** * Enables audio to be recorded for this recording. * @@ -139,6 +143,69 @@ public final class SucklessPendingRecording { return this; } + /** + * Configures the recording to be a persistent recording. + * + *

A persistent recording will only be stopped by explicitly calling + * {@link Recording#stop()} or {@link Recording#close()} and will ignore events that would + * normally cause recording to stop, such as lifecycle events or explicit unbinding of a + * {@link VideoCapture} use case that the recording's {@link Recorder} is attached to. + * + *

Even though lifecycle events or explicit unbinding use cases won't stop a persistent + * recording, it will still stop the camera from producing data, resulting in the in-progress + * persistent recording stopping getting data until the camera stream is activated again. For + * example, when the activity goes into background, the recording will keep waiting for new + * data to be recorded until the activity is back to foreground. + * + *

A {@link Recorder} instance is recommended to be associated with a single + * {@link VideoCapture} instance, especially when using persistent recording. Otherwise, there + * might be unexpected behavior. Any in-progress persistent recording created from the same + * {@link Recorder} should be stopped before starting a new recording, even if the + * {@link Recorder} is associated with a different {@link VideoCapture}. + * + *

To switch to a different camera stream while a recording is in progress, first create + * the recording as persistent recording, then rebind the {@link VideoCapture} it's + * associated with to a different camera. The implementation may be like: + *

{@code
+     * // Prepare the Recorder and VideoCapture, then bind the VideoCapture to the back camera.
+     * Recorder recorder = Recorder.Builder().build();
+     * VideoCapture videoCapture = VideoCapture.withOutput(recorder);
+     * cameraProvider.bindToLifecycle(
+     *         lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, videoCapture);
+     *
+     * // Prepare the persistent recording and start it.
+     * Recording recording = recorder
+     *         .prepareRecording(context, outputOptions)
+     *         .asPersistentRecording()
+     *         .start(eventExecutor, eventListener);
+     *
+     * // Record from the back camera for a period of time.
+     *
+     * // Rebind the VideoCapture to the front camera.
+     * cameraProvider.unbindAll();
+     * cameraProvider.bindToLifecycle(
+     *         lifecycleOwner, CameraSelector.DEFAULT_FRONT_CAMERA, videoCapture);
+     *
+     * // Record from the front camera for a period of time.
+     *
+     * // Stop the recording explicitly.
+     * recording.stop();
+     * }
+ * + *

The audio data will still be recorded after the {@link VideoCapture} is unbound. + * {@link Recording#pause() Pause} the recording first and {@link Recording#resume() resume} it + * later to stop recording audio while rebinding use cases. + * + *

If the recording is unable to receive data from the new camera, possibly because of + * incompatible surface combination, an exception will be thrown when binding to lifecycle. + */ + @ExperimentalPersistentRecording + @NonNull + public SucklessPendingRecording asPersistentRecording() { + mIsPersistent = true; + return this; + } + /** * Starts the recording, making it an active recording. * @@ -159,7 +226,13 @@ public final class SucklessPendingRecording { * *

If the returned {@link SucklessRecording} is garbage collected, the recording will be * automatically stopped. A reference to the active recording must be maintained as long as - * the recording needs to be active. + * the recording needs to be active. If the recording is garbage collected, the + * {@link VideoRecordEvent.Finalize} event will contain error + * {@link VideoRecordEvent.Finalize#ERROR_RECORDING_GARBAGE_COLLECTED}. + * + *

The {@link Recording} will be stopped automatically if the {@link VideoCapture} its + * {@link Recorder} is attached to is unbound unless it's created + * {@link #asPersistentRecording() as a persistent recording}. * * @throws IllegalStateException if the associated Recorder currently has an unfinished * active recording. diff --git a/app/src/main/java/androidx/camera/video/SucklessRecorder.java b/app/src/main/java/androidx/camera/video/SucklessRecorder.java index 9479080..1f12fa5 100644 --- a/app/src/main/java/androidx/camera/video/SucklessRecorder.java +++ b/app/src/main/java/androidx/camera/video/SucklessRecorder.java @@ -16,6 +16,7 @@ package androidx.camera.video; +import static androidx.camera.video.AudioStats.AUDIO_AMPLITUDE_NONE; import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED; import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED; import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED; @@ -54,6 +55,8 @@ import androidx.annotation.RequiresPermission; import androidx.annotation.RestrictTo; import androidx.annotation.VisibleForTesting; import androidx.camera.core.AspectRatio; +import androidx.camera.core.CameraInfo; +import androidx.camera.core.DynamicRange; import androidx.camera.core.Logger; import androidx.camera.core.SurfaceRequest; import androidx.camera.core.impl.MutableStateObservable; @@ -76,7 +79,7 @@ import androidx.camera.video.internal.compat.Api26Impl; import androidx.camera.video.internal.compat.quirk.DeactivateEncoderSurfaceBeforeStopEncoderQuirk; import androidx.camera.video.internal.compat.quirk.DeviceQuirks; import androidx.camera.video.internal.compat.quirk.EncoderNotUsePersistentInputSurfaceQuirk; -import androidx.camera.video.internal.config.MimeInfo; +import androidx.camera.video.internal.config.AudioMimeInfo; import androidx.camera.video.internal.encoder.AudioEncoderConfig; import androidx.camera.video.internal.encoder.BufferCopiedEncodedData; import androidx.camera.video.internal.encoder.EncodeException; @@ -340,10 +343,14 @@ public final class SucklessRecorder implements VideoOutput { //////////////////////////////////////////////////////////////////////////////////////////////// // Members only accessed on mSequentialExecutor // //////////////////////////////////////////////////////////////////////////////////////////////// - private RecordingRecord mInProgressRecording = null; + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + RecordingRecord mInProgressRecording = null; @SuppressWarnings("WeakerAccess") /* synthetic accessor */ boolean mInProgressRecordingStopping = false; - private SurfaceRequest.TransformationInfo mSurfaceTransformationInfo = null; + @Nullable + private SurfaceRequest.TransformationInfo mInProgressTransformationInfo = null; + @Nullable + private SurfaceRequest.TransformationInfo mSourceTransformationInfo = null; private VideoValidatedEncoderProfilesProxy mResolvedEncoderProfiles = null; @SuppressWarnings("WeakerAccess") /* synthetic accessor */ final List> mEncodingFutures = new ArrayList<>(); @@ -424,13 +431,15 @@ public final class SucklessRecorder implements VideoOutput { @SuppressWarnings("WeakerAccess") /* synthetic accessor */ ScheduledFuture mSourceNonStreamingTimeout = null; // The Recorder has to be reset first before being configured again. - private boolean mNeedsReset = false; + private boolean mNeedsResetBeforeNextStart = false; @NonNull @SuppressWarnings("WeakerAccess") /* synthetic accessor */ VideoEncoderSession mVideoEncoderSession; @Nullable @SuppressWarnings("WeakerAccess") /* synthetic accessor */ VideoEncoderSession mVideoEncoderSessionToRelease = null; + double mAudioAmplitude = 0; + private boolean mShouldSendResumeEvent = false; //--------------------------------------------------------------------------------------------// SucklessRecorder(@Nullable Executor executor, @NonNull MediaSpec mediaSpec, @@ -487,6 +496,13 @@ public final class SucklessRecorder implements VideoOutput { mSequentialExecutor.execute(() -> onSourceStateChangedInternal(newState)); } + @RestrictTo(RestrictTo.Scope.LIBRARY) + @Override + @NonNull + public VideoCapabilities getMediaCapabilities(@NonNull CameraInfo cameraInfo) { + return getVideoCapabilities(cameraInfo); + } + @NonNull public SucklessPendingRecording prepareRecording(@NonNull Context context, @NonNull MuxerOutputOptions outputOptions) { return prepareRecordingInternal(context, outputOptions); @@ -756,7 +772,8 @@ public final class SucklessRecorder implements VideoOutput { } } - void stop(@NonNull SucklessRecording activeRecording) { + void stop(@NonNull SucklessRecording activeRecording, @VideoRecordError int error, + @Nullable Throwable errorCause) { RecordingRecord pendingRecordingToFinalize = null; synchronized (mLock) { if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording( @@ -801,7 +818,7 @@ public final class SucklessRecorder implements VideoOutput { long explicitlyStopTimeUs = TimeUnit.NANOSECONDS.toMicros(System.nanoTime()); RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord; mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord, - explicitlyStopTimeUs, ERROR_NONE, null)); + explicitlyStopTimeUs, error, errorCause)); break; case ERROR: // In an error state, the recording will already be finalized. Treat as a @@ -811,9 +828,13 @@ public final class SucklessRecorder implements VideoOutput { } if (pendingRecordingToFinalize != null) { + if (error == VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED) { + Logger.e(TAG, "Recording was stopped due to recording being garbage collected " + + "before any valid data has been produced."); + } finalizePendingRecording(pendingRecordingToFinalize, ERROR_NO_VALID_DATA, new RuntimeException("Recording was stopped before any data could be " - + "produced.")); + + "produced.", errorCause)); } } @@ -843,7 +864,8 @@ public final class SucklessRecorder implements VideoOutput { recordingToFinalize.getOutputOptions(), RecordingStats.of(/*duration=*/0L, /*bytes=*/0L, - AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause)), + AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause, + AUDIO_AMPLITUDE_NONE)), OutputResults.of(Uri.EMPTY), error, cause)); @@ -874,14 +896,15 @@ public final class SucklessRecorder implements VideoOutput { // If we're inactive and have no active surface, we'll reset the encoder directly. // Otherwise, we'll wait for the active surface's surface request listener to // reset the encoder. - requestReset(ERROR_SOURCE_INACTIVE, null); + requestReset(ERROR_SOURCE_INACTIVE, null, false); } else { // The source becomes inactive, the incoming new surface request has to be cached // and be serviced after the Recorder is reset when receiving the previous // surface request complete callback. - mNeedsReset = true; - if (mInProgressRecording != null) { - // Stop any in progress recording with "source inactive" error + mNeedsResetBeforeNextStart = true; + if (mInProgressRecording != null && !mInProgressRecording.isPersistent()) { + // Stop the in progress recording with "source inactive" error if it's not a + // persistent recording. onInProgressRecordingInternalError(mInProgressRecording, ERROR_SOURCE_INACTIVE, null); } @@ -905,7 +928,8 @@ public final class SucklessRecorder implements VideoOutput { * the surface request complete callback first. */ @ExecutedBy("mSequentialExecutor") - void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause) { + void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause, + boolean videoOnly) { boolean shouldReset = false; boolean shouldStop = false; synchronized (mLock) { @@ -927,14 +951,22 @@ public final class SucklessRecorder implements VideoOutput { case PAUSED: // Fall-through case RECORDING: + Preconditions.checkState(mInProgressRecording != null, "In-progress recording" + + " shouldn't be null when in state " + mState); if (mActiveRecordingRecord != mInProgressRecording) { throw new AssertionError("In-progress recording does not match the active" + " recording. Unable to reset encoder."); } - // If there's an active recording, stop it first then release the resources - // at onRecordingFinalized(). - shouldStop = true; - // Fall-through + // If there's an active persistent recording, reset the Recorder directly. + // Otherwise, stop the recording first then release the Recorder at + // onRecordingFinalized(). + if (isPersistentRecordingInProgress()) { + shouldReset = true; + } else { + shouldStop = true; + setState(State.RESETTING); + } + break; case STOPPING: // Already stopping. Set state to RESETTING so resources will be released once // onRecordingFinalized() runs. @@ -949,14 +981,17 @@ public final class SucklessRecorder implements VideoOutput { // These calls must not be posted to the executor to ensure they are executed inline on // the sequential executor and the state changes above are correctly handled. if (shouldReset) { - reset(); + if (videoOnly) { + resetVideo(); + } else { + reset(); + } } else if (shouldStop) { stopInternal(mInProgressRecording, Encoder.NO_TIMESTAMP, errorCode, errorCause); } } @ExecutedBy("mSequentialExecutor") - private void configureInternal(@NonNull SurfaceRequest surfaceRequest, @NonNull Timebase videoSourceTimebase) { if (surfaceRequest.isServiced()) { @@ -964,16 +999,19 @@ public final class SucklessRecorder implements VideoOutput { return; } surfaceRequest.setTransformationInfoListener(mSequentialExecutor, - (transformationInfo) -> mSurfaceTransformationInfo = transformationInfo); + (transformationInfo) -> mSourceTransformationInfo = transformationInfo); Size surfaceSize = surfaceRequest.getResolution(); // Fetch and cache nearest encoder profiles, if one exists. - LegacyVideoCapabilities capabilities = - LegacyVideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo()); - Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize); + DynamicRange dynamicRange = surfaceRequest.getDynamicRange(); + VideoCapabilities capabilities = getVideoCapabilities( + surfaceRequest.getCamera().getCameraInfo()); + Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize, + dynamicRange); Logger.d(TAG, "Using supported quality of " + highestSupportedQuality + " for surface size " + surfaceSize); if (highestSupportedQuality != Quality.NONE) { - mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality); + mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality, + dynamicRange); if (mResolvedEncoderProfiles == null) { throw new AssertionError("Camera advertised available quality but did not " + "produce EncoderProfiles for advertised quality."); @@ -986,9 +1024,14 @@ public final class SucklessRecorder implements VideoOutput { @ExecutedBy("mSequentialExecutor") private void setupVideo(@NonNull SurfaceRequest request, @NonNull Timebase timebase) { safeToCloseVideoEncoder().addListener(() -> { - if (request.isServiced() || mVideoEncoderSession.isConfiguredSurfaceRequest(request)) { + if (request.isServiced() + || (mVideoEncoderSession.isConfiguredSurfaceRequest(request) + && !isPersistentRecordingInProgress())) { + // Ignore the surface request if it's already serviced. Or the video encoder + // session is already configured, unless there's a persistent recording is running. Logger.w(TAG, "Ignore the SurfaceRequest " + request + " isServiced: " - + request.isServiced() + " VideoEncoderSession: " + mVideoEncoderSession); + + request.isServiced() + " VideoEncoderSession: " + mVideoEncoderSession + + " has been configured with a persistent in-progress recording."); return; } VideoEncoderSession videoEncoderSession = @@ -1020,6 +1063,12 @@ public final class SucklessRecorder implements VideoOutput { }, mSequentialExecutor); } + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + @ExecutedBy("mSequentialExecutor") + boolean isPersistentRecordingInProgress() { + return mInProgressRecording != null && mInProgressRecording.isPersistent(); + } + @NonNull @ExecutedBy("mSequentialExecutor") private ListenableFuture safeToCloseVideoEncoder() { @@ -1055,7 +1104,9 @@ public final class SucklessRecorder implements VideoOutput { mVideoEncoderSessionToRelease = videoEncoderSession; setLatestSurface(null); - requestReset(ERROR_SOURCE_INACTIVE, null); + // Only reset video if the in-progress recording is persistent. + requestReset(ERROR_SOURCE_INACTIVE, null, + isPersistentRecordingInProgress()); } @Override @@ -1070,17 +1121,14 @@ public final class SucklessRecorder implements VideoOutput { void onConfigured() { RecordingRecord recordingToStart = null; RecordingRecord pendingRecordingToFinalize = null; + boolean continuePersistentRecording = false; @VideoRecordError int error = ERROR_NONE; Throwable errorCause = null; - boolean startRecordingPaused = false; + boolean recordingPaused = false; synchronized (mLock) { switch (mState) { case IDLING: // Fall-through - case RECORDING: - // Fall-through - case PAUSED: - // Fall-through case RESETTING: throw new AssertionError( "Incorrectly invoke onConfigured() in state " + mState); @@ -1090,6 +1138,15 @@ public final class SucklessRecorder implements VideoOutput { + "STOPPING state when it's not waiting for a new surface."); } break; + case PAUSED: + recordingPaused = true; + // Fall-through + case RECORDING: + Preconditions.checkState(isPersistentRecordingInProgress(), + "Unexpectedly invoke onConfigured() when there's a non-persistent " + + "in-progress recording"); + continuePersistentRecording = true; + break; case CONFIGURING: setState(State.IDLING); break; @@ -1098,7 +1155,7 @@ public final class SucklessRecorder implements VideoOutput { "onConfigured() was invoked when the Recorder had encountered error"); break; case PENDING_PAUSED: - startRecordingPaused = true; + recordingPaused = true; // Fall through case PENDING_RECORDING: if (mActiveRecordingRecord != null) { @@ -1119,9 +1176,21 @@ public final class SucklessRecorder implements VideoOutput { } } - if (recordingToStart != null) { + if (continuePersistentRecording) { + updateEncoderCallbacks(mInProgressRecording, true); + mVideoEncoder.start(); + if (mShouldSendResumeEvent) { + mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume( + mInProgressRecording.getOutputOptions(), + getInProgressRecordingStats())); + mShouldSendResumeEvent = false; + } + if (recordingPaused) { + mVideoEncoder.pause(); + } + } else if (recordingToStart != null) { // Start new active recording inline on sequential executor (but unlocked). - startRecording(recordingToStart, startRecordingPaused); + startRecording(recordingToStart, recordingPaused); } else if (pendingRecordingToFinalize != null) { finalizePendingRecording(pendingRecordingToFinalize, error, errorCause); } @@ -1162,7 +1231,7 @@ public final class SucklessRecorder implements VideoOutput { throws AudioSourceAccessException, InvalidConfigException { MediaSpec mediaSpec = getObservableData(mMediaSpec); // Resolve the audio mime info - MimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles); + AudioMimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles); Timebase audioSourceTimebase = Timebase.UPTIME; // Select and create the audio source @@ -1313,8 +1382,10 @@ public final class SucklessRecorder implements VideoOutput { return; } - if (mSurfaceTransformationInfo != null) { - mediaMuxer.setOrientationHint(mSurfaceTransformationInfo.getRotationDegrees()); + SurfaceRequest.TransformationInfo transformationInfo = mSourceTransformationInfo; + if (transformationInfo != null) { + setInProgressTransformationInfo(transformationInfo); + mediaMuxer.setOrientationHint(transformationInfo.getRotationDegrees()); } mVideoTrackIndex = mediaMuxer.addTrack(mVideoOutputConfig.getMediaFormat()); @@ -1402,7 +1473,9 @@ public final class SucklessRecorder implements VideoOutput { "The Recorder doesn't support recording with audio"); } try { - setupAudio(recordingToStart); + if (!mInProgressRecording.isPersistent() || mAudioEncoder == null) { + setupAudio(recordingToStart); + } setAudioState(AudioState.ENABLED); } catch (AudioSourceAccessException | InvalidConfigException e) { Logger.e(TAG, "Unable to create audio resource with error: ", e); @@ -1419,7 +1492,7 @@ public final class SucklessRecorder implements VideoOutput { break; } - initEncoderAndAudioSourceCallbacks(recordingToStart); + updateEncoderCallbacks(recordingToStart, false); if (isAudioEnabled()) { mAudioSource.start(recordingToStart.isMuted()); mAudioEncoder.start(); @@ -1432,7 +1505,17 @@ public final class SucklessRecorder implements VideoOutput { } @ExecutedBy("mSequentialExecutor") - private void initEncoderAndAudioSourceCallbacks(@NonNull RecordingRecord recordingToStart) { + private void updateEncoderCallbacks(@NonNull RecordingRecord recordingToStart, + boolean videoOnly) { + // If there are uncompleted futures, cancel them first. + if (!mEncodingFutures.isEmpty()) { + ListenableFuture> listFuture = Futures.allAsList(mEncodingFutures); + if (!listFuture.isDone()) { + listFuture.cancel(true); + } + mEncodingFutures.clear(); + } + mEncodingFutures.add(CallbackToFutureAdapter.getFuture( completer -> { mVideoEncoder.setEncoderCallback(new EncoderCallback() { @@ -1528,7 +1611,7 @@ public final class SucklessRecorder implements VideoOutput { return "videoEncodingFuture"; })); - if (isAudioEnabled()) { + if (isAudioEnabled() && !videoOnly) { mEncodingFutures.add(CallbackToFutureAdapter.getFuture( completer -> { Consumer audioErrorConsumer = throwable -> { @@ -1568,6 +1651,11 @@ public final class SucklessRecorder implements VideoOutput { audioErrorConsumer.accept(throwable); } } + + @Override + public void onAmplitudeValue(double maxAmplitude) { + mAudioAmplitude = maxAmplitude; + } }); mAudioEncoder.setEncoderCallback(new EncoderCallback() { @@ -1654,12 +1742,16 @@ public final class SucklessRecorder implements VideoOutput { @Override public void onFailure(@NonNull Throwable t) { - Logger.d(TAG, "Encodings end with error: " + t); - // If the media muxer hasn't been set up, assume the encoding fails - // because of no valid data has been produced. - finalizeInProgressRecording( - mMediaMuxer == null ? ERROR_NO_VALID_DATA : ERROR_ENCODING_FAILED, - t); + Preconditions.checkState(mInProgressRecording != null, + "In-progress recording shouldn't be null"); + // If a persistent recording requires reconfiguring the video encoder, + // the previous encoder future has to be canceled without finalizing the + // in-progress recording. + if (!mInProgressRecording.isPersistent()) { + Logger.d(TAG, "Encodings end with error: " + t); + finalizeInProgressRecording(mMediaMuxer == null ? ERROR_NO_VALID_DATA + : ERROR_ENCODING_FAILED, t); + } } }, // Can use direct executor since completers are always completed on sequential @@ -1800,11 +1892,20 @@ public final class SucklessRecorder implements VideoOutput { if (isAudioEnabled()) { mAudioEncoder.start(); } - mVideoEncoder.start(); - - mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume( - mInProgressRecording.getOutputOptions(), - getInProgressRecordingStats())); + // If a persistent recording is resumed immediately after the VideoCapture is rebound + // to a camera, it's possible that the encoder hasn't been created yet. Then the + // encoder will be started once it's initialized. So only start the encoder when it's + // not null. + if (mVideoEncoder != null) { + mVideoEncoder.start(); + mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume( + mInProgressRecording.getOutputOptions(), + getInProgressRecordingStats())); + } else { + // Instead sending here, send the Resume event once the encoder is initialized + // and started. + mShouldSendResumeEvent = true; + } } } @@ -1898,13 +1999,12 @@ public final class SucklessRecorder implements VideoOutput { mAudioEncoder = null; mAudioOutputConfig = null; } - tryReleaseVideoEncoder(); if (mAudioSource != null) { releaseCurrentAudioSource(); } setAudioState(AudioState.INITIALIZING); - onReset(); + resetVideo(); } @SuppressWarnings("FutureReturnValueIgnored") @@ -1926,7 +2026,8 @@ public final class SucklessRecorder implements VideoOutput { } @ExecutedBy("mSequentialExecutor") - private void onReset() { + private void onResetVideo() { + boolean shouldConfigure = true; synchronized (mLock) { switch (mState) { case PENDING_PAUSED: @@ -1939,6 +2040,10 @@ public final class SucklessRecorder implements VideoOutput { case PAUSED: // Fall-through case RECORDING: + if (isPersistentRecordingInProgress()) { + shouldConfigure = false; + break; + } // Fall-through case IDLING: // Fall-through @@ -1953,14 +2058,24 @@ public final class SucklessRecorder implements VideoOutput { } } - mNeedsReset = false; + mNeedsResetBeforeNextStart = false; // If the latest surface request hasn't been serviced, use it to re-configure the Recorder. - if (mLatestSurfaceRequest != null && !mLatestSurfaceRequest.isServiced()) { + if (shouldConfigure && mLatestSurfaceRequest != null + && !mLatestSurfaceRequest.isServiced()) { configureInternal(mLatestSurfaceRequest, mVideoSourceTimebase); } } + @ExecutedBy("mSequentialExecutor") + private void resetVideo() { + if (mVideoEncoder != null) { + Logger.d(TAG, "Releasing video encoder."); + tryReleaseVideoEncoder(); + } + onResetVideo(); + } + @ExecutedBy("mSequentialExecutor") private int internalAudioStateToAudioStatsState(@NonNull AudioState audioState) { switch (audioState) { @@ -2063,7 +2178,9 @@ public final class SucklessRecorder implements VideoOutput { mRecordingStopError = ERROR_UNKNOWN; mRecordingStopErrorCause = null; mAudioErrorCause = null; + mAudioAmplitude = AUDIO_AMPLITUDE_NONE; clearPendingAudioRingBuffer(); + setInProgressTransformationInfo(null); switch (mAudioState) { case IDLING: @@ -2246,7 +2363,7 @@ public final class SucklessRecorder implements VideoOutput { startRecordingPaused = true; // Fall-through case PENDING_RECORDING: - if (mActiveRecordingRecord != null || mNeedsReset) { + if (mActiveRecordingRecord != null || mNeedsResetBeforeNextStart) { // Active recording is still finalizing or the Recorder is expected to be // reset. Pending recording will be serviced in onRecordingFinalized() or // in onReset(). @@ -2361,7 +2478,8 @@ public final class SucklessRecorder implements VideoOutput { @NonNull RecordingStats getInProgressRecordingStats() { return RecordingStats.of(mRecordingDurationNs, mRecordingBytes, - AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause)); + AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause, + mAudioAmplitude)); } @SuppressWarnings("WeakerAccess") /* synthetic accessor */ @@ -2415,7 +2533,7 @@ public final class SucklessRecorder implements VideoOutput { if (streamState == null) { streamState = internalStateToStreamState(mState); } - mStreamInfo.setState(StreamInfo.of(mStreamId, streamState)); + mStreamInfo.setState(StreamInfo.of(mStreamId, streamState, mInProgressTransformationInfo)); } @ExecutedBy("mSequentialExecutor") @@ -2437,7 +2555,20 @@ public final class SucklessRecorder implements VideoOutput { } Logger.d(TAG, "Transitioning streamId: " + mStreamId + " --> " + streamId); mStreamId = streamId; - mStreamInfo.setState(StreamInfo.of(streamId, internalStateToStreamState(mState))); + mStreamInfo.setState(StreamInfo.of(streamId, internalStateToStreamState(mState), + mInProgressTransformationInfo)); + } + + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + @ExecutedBy("mSequentialExecutor") + void setInProgressTransformationInfo( + @Nullable SurfaceRequest.TransformationInfo transformationInfo) { + Logger.d(TAG, "Update stream transformation info: " + transformationInfo); + mInProgressTransformationInfo = transformationInfo; + synchronized (mLock) { + mStreamInfo.setState(StreamInfo.of(mStreamId, internalStateToStreamState(mState), + transformationInfo)); + } } /** @@ -2460,8 +2591,8 @@ public final class SucklessRecorder implements VideoOutput { if (mNonPendingState != state) { mNonPendingState = state; - mStreamInfo.setState( - StreamInfo.of(mStreamId, internalStateToStreamState(state))); + mStreamInfo.setState(StreamInfo.of(mStreamId, internalStateToStreamState(state), + mInProgressTransformationInfo)); } } @@ -2509,6 +2640,21 @@ public final class SucklessRecorder implements VideoOutput { return defaultMuxerFormat; } + /** + * Returns the {@link VideoCapabilities} of Recorder with respect to input camera information. + * + *

{@link VideoCapabilities} provides methods to query supported dynamic ranges and + * qualities. This information can be used for things like checking if HDR is supported for + * configuring VideoCapture to record HDR video. + * + * @param cameraInfo info about the camera. + * @return VideoCapabilities with respect to the input camera info. + */ + @NonNull + public static VideoCapabilities getVideoCapabilities(@NonNull CameraInfo cameraInfo) { + return RecorderVideoCapabilities.from(cameraInfo); + } + @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java @AutoValue abstract static class RecordingRecord implements AutoCloseable { @@ -2537,6 +2683,7 @@ public final class SucklessRecorder implements VideoOutput { pendingRecording.getListenerExecutor(), pendingRecording.getEventListener(), pendingRecording.isAudioEnabled(), + pendingRecording.isPersistent(), recordingId ); } @@ -2552,6 +2699,8 @@ public final class SucklessRecorder implements VideoOutput { abstract boolean hasAudioEnabled(); + abstract boolean isPersistent(); + abstract long getRecordingId(); /** @@ -2782,7 +2931,12 @@ public final class SucklessRecorder implements VideoOutput { throw new AssertionError("One-time media muxer creation has already occurred for" + " recording " + this); } - return mediaMuxerSupplier.get(muxerOutputFormat, outputUriCreatedCallback); + + try { + return mediaMuxerSupplier.get(muxerOutputFormat, outputUriCreatedCallback); + } catch (RuntimeException e) { + throw new IOException("Failed to create MediaMuxer by " + e, e); + } } /** diff --git a/app/src/main/java/androidx/camera/video/SucklessRecording.java b/app/src/main/java/androidx/camera/video/SucklessRecording.java index fa06aac..bd48045 100644 --- a/app/src/main/java/androidx/camera/video/SucklessRecording.java +++ b/app/src/main/java/androidx/camera/video/SucklessRecording.java @@ -21,6 +21,7 @@ import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; import android.annotation.SuppressLint; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.RestrictTo; import androidx.camera.core.impl.utils.CloseGuardHelper; @@ -56,13 +57,15 @@ public final class SucklessRecording implements AutoCloseable { private final SucklessRecorder mRecorder; private final long mRecordingId; private final OutputOptions mOutputOptions; + private final boolean mIsPersistent; private final CloseGuardHelper mCloseGuard = CloseGuardHelper.create(); SucklessRecording(@NonNull SucklessRecorder recorder, long recordingId, @NonNull OutputOptions options, - boolean finalizedOnCreation) { + boolean isPersistent, boolean finalizedOnCreation) { mRecorder = recorder; mRecordingId = recordingId; mOutputOptions = options; + mIsPersistent = isPersistent; if (finalizedOnCreation) { mIsClosed.set(true); @@ -83,6 +86,7 @@ public final class SucklessRecording implements AutoCloseable { return new SucklessRecording(pendingRecording.getRecorder(), recordingId, pendingRecording.getOutputOptions(), + pendingRecording.isPersistent(), /*finalizedOnCreation=*/false); } @@ -103,6 +107,7 @@ public final class SucklessRecording implements AutoCloseable { return new SucklessRecording(pendingRecording.getRecorder(), recordingId, pendingRecording.getOutputOptions(), + pendingRecording.isPersistent(), /*finalizedOnCreation=*/true); } @@ -111,6 +116,20 @@ public final class SucklessRecording implements AutoCloseable { return mOutputOptions; } + /** + * Returns whether this recording is a persistent recording. + * + *

A persistent recording will only be stopped by explicitly calling of + * {@link Recording#stop()} and will ignore the lifecycle events or source state changes. + * Users are responsible of stopping a persistent recording. + * + * @return {@code true} if the recording is a persistent recording, otherwise {@code false}. + */ + @ExperimentalPersistentRecording + public boolean isPersistent() { + return mIsPersistent; + } + /** * Pauses the current recording if active. * @@ -196,11 +215,7 @@ public final class SucklessRecording implements AutoCloseable { */ @Override public void close() { - mCloseGuard.close(); - if (mIsClosed.getAndSet(true)) { - return; - } - mRecorder.stop(this); + stopWithError(VideoRecordEvent.Finalize.ERROR_NONE, /*errorCause=*/ null); } @Override @@ -208,7 +223,8 @@ public final class SucklessRecording implements AutoCloseable { protected void finalize() throws Throwable { try { mCloseGuard.warnIfOpen(); - stop(); + stopWithError(VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED, + new RuntimeException("Recording stopped due to being garbage collected.")); } finally { super.finalize(); } @@ -234,5 +250,14 @@ public final class SucklessRecording implements AutoCloseable { public boolean isClosed() { return mIsClosed.get(); } + + private void stopWithError(@VideoRecordEvent.Finalize.VideoRecordError int error, + @Nullable Throwable errorCause) { + mCloseGuard.close(); + if (mIsClosed.getAndSet(true)) { + return; + } + mRecorder.stop(this, error, errorCause); + } } diff --git a/app/src/main/java/androidx/camera/video/originals/base/PendingRecording.java b/app/src/main/java/androidx/camera/video/originals/base/PendingRecording.java index 92386a3..5c7011b 100644 --- a/app/src/main/java/androidx/camera/video/originals/base/PendingRecording.java +++ b/app/src/main/java/androidx/camera/video/originals/base/PendingRecording.java @@ -56,8 +56,8 @@ public final class PendingRecording { private final OutputOptions mOutputOptions; private Consumer mEventListener; private Executor mListenerExecutor; - private boolean mAudioEnabled = false; + private boolean mIsPersistent = false; PendingRecording(@NonNull Context context, @NonNull Recorder recorder, @NonNull OutputOptions options) { @@ -102,6 +102,10 @@ public final class PendingRecording { return mAudioEnabled; } + boolean isPersistent() { + return mIsPersistent; + } + /** * Enables audio to be recorded for this recording. * @@ -137,6 +141,69 @@ public final class PendingRecording { return this; } + /** + * Configures the recording to be a persistent recording. + * + *

A persistent recording will only be stopped by explicitly calling + * {@link Recording#stop()} or {@link Recording#close()} and will ignore events that would + * normally cause recording to stop, such as lifecycle events or explicit unbinding of a + * {@link VideoCapture} use case that the recording's {@link Recorder} is attached to. + * + *

Even though lifecycle events or explicit unbinding use cases won't stop a persistent + * recording, it will still stop the camera from producing data, resulting in the in-progress + * persistent recording stopping getting data until the camera stream is activated again. For + * example, when the activity goes into background, the recording will keep waiting for new + * data to be recorded until the activity is back to foreground. + * + *

A {@link Recorder} instance is recommended to be associated with a single + * {@link VideoCapture} instance, especially when using persistent recording. Otherwise, there + * might be unexpected behavior. Any in-progress persistent recording created from the same + * {@link Recorder} should be stopped before starting a new recording, even if the + * {@link Recorder} is associated with a different {@link VideoCapture}. + * + *

To switch to a different camera stream while a recording is in progress, first create + * the recording as persistent recording, then rebind the {@link VideoCapture} it's + * associated with to a different camera. The implementation may be like: + *

{@code
+     * // Prepare the Recorder and VideoCapture, then bind the VideoCapture to the back camera.
+     * Recorder recorder = Recorder.Builder().build();
+     * VideoCapture videoCapture = VideoCapture.withOutput(recorder);
+     * cameraProvider.bindToLifecycle(
+     *         lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, videoCapture);
+     *
+     * // Prepare the persistent recording and start it.
+     * Recording recording = recorder
+     *         .prepareRecording(context, outputOptions)
+     *         .asPersistentRecording()
+     *         .start(eventExecutor, eventListener);
+     *
+     * // Record from the back camera for a period of time.
+     *
+     * // Rebind the VideoCapture to the front camera.
+     * cameraProvider.unbindAll();
+     * cameraProvider.bindToLifecycle(
+     *         lifecycleOwner, CameraSelector.DEFAULT_FRONT_CAMERA, videoCapture);
+     *
+     * // Record from the front camera for a period of time.
+     *
+     * // Stop the recording explicitly.
+     * recording.stop();
+     * }
+ * + *

The audio data will still be recorded after the {@link VideoCapture} is unbound. + * {@link Recording#pause() Pause} the recording first and {@link Recording#resume() resume} it + * later to stop recording audio while rebinding use cases. + * + *

If the recording is unable to receive data from the new camera, possibly because of + * incompatible surface combination, an exception will be thrown when binding to lifecycle. + */ + @ExperimentalPersistentRecording + @NonNull + public PendingRecording asPersistentRecording() { + mIsPersistent = true; + return this; + } + /** * Starts the recording, making it an active recording. * @@ -157,7 +224,13 @@ public final class PendingRecording { * *

If the returned {@link Recording} is garbage collected, the recording will be * automatically stopped. A reference to the active recording must be maintained as long as - * the recording needs to be active. + * the recording needs to be active. If the recording is garbage collected, the + * {@link VideoRecordEvent.Finalize} event will contain error + * {@link VideoRecordEvent.Finalize#ERROR_RECORDING_GARBAGE_COLLECTED}. + * + *

The {@link Recording} will be stopped automatically if the {@link VideoCapture} its + * {@link Recorder} is attached to is unbound unless it's created + * {@link #asPersistentRecording() as a persistent recording}. * * @throws IllegalStateException if the associated Recorder currently has an unfinished * active recording. diff --git a/app/src/main/java/androidx/camera/video/originals/base/Recorder.java b/app/src/main/java/androidx/camera/video/originals/base/Recorder.java index acb2395..6680abd 100644 --- a/app/src/main/java/androidx/camera/video/originals/base/Recorder.java +++ b/app/src/main/java/androidx/camera/video/originals/base/Recorder.java @@ -16,6 +16,7 @@ package androidx.camera.video; +import static androidx.camera.video.AudioStats.AUDIO_AMPLITUDE_NONE; import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED; import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED; import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED; @@ -57,6 +58,8 @@ import androidx.annotation.RequiresPermission; import androidx.annotation.RestrictTo; import androidx.annotation.VisibleForTesting; import androidx.camera.core.AspectRatio; +import androidx.camera.core.CameraInfo; +import androidx.camera.core.DynamicRange; import androidx.camera.core.Logger; import androidx.camera.core.SurfaceRequest; import androidx.camera.core.impl.MutableStateObservable; @@ -79,7 +82,7 @@ import androidx.camera.video.internal.compat.Api26Impl; import androidx.camera.video.internal.compat.quirk.DeactivateEncoderSurfaceBeforeStopEncoderQuirk; import androidx.camera.video.internal.compat.quirk.DeviceQuirks; import androidx.camera.video.internal.compat.quirk.EncoderNotUsePersistentInputSurfaceQuirk; -import androidx.camera.video.internal.config.MimeInfo; +import androidx.camera.video.internal.config.AudioMimeInfo; import androidx.camera.video.internal.encoder.AudioEncoderConfig; import androidx.camera.video.internal.encoder.BufferCopiedEncodedData; import androidx.camera.video.internal.encoder.EncodeException; @@ -343,10 +346,14 @@ public final class Recorder implements VideoOutput { //////////////////////////////////////////////////////////////////////////////////////////////// // Members only accessed on mSequentialExecutor // //////////////////////////////////////////////////////////////////////////////////////////////// - private RecordingRecord mInProgressRecording = null; + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + RecordingRecord mInProgressRecording = null; @SuppressWarnings("WeakerAccess") /* synthetic accessor */ boolean mInProgressRecordingStopping = false; - private SurfaceRequest.TransformationInfo mSurfaceTransformationInfo = null; + @Nullable + private SurfaceRequest.TransformationInfo mInProgressTransformationInfo = null; + @Nullable + private SurfaceRequest.TransformationInfo mSourceTransformationInfo = null; private VideoValidatedEncoderProfilesProxy mResolvedEncoderProfiles = null; @SuppressWarnings("WeakerAccess") /* synthetic accessor */ final List> mEncodingFutures = new ArrayList<>(); @@ -427,13 +434,15 @@ public final class Recorder implements VideoOutput { @SuppressWarnings("WeakerAccess") /* synthetic accessor */ ScheduledFuture mSourceNonStreamingTimeout = null; // The Recorder has to be reset first before being configured again. - private boolean mNeedsReset = false; + private boolean mNeedsResetBeforeNextStart = false; @NonNull @SuppressWarnings("WeakerAccess") /* synthetic accessor */ VideoEncoderSession mVideoEncoderSession; @Nullable @SuppressWarnings("WeakerAccess") /* synthetic accessor */ VideoEncoderSession mVideoEncoderSessionToRelease = null; + double mAudioAmplitude = 0; + private boolean mShouldSendResumeEvent = false; //--------------------------------------------------------------------------------------------// Recorder(@Nullable Executor executor, @NonNull MediaSpec mediaSpec, @@ -490,6 +499,13 @@ public final class Recorder implements VideoOutput { mSequentialExecutor.execute(() -> onSourceStateChangedInternal(newState)); } + @RestrictTo(RestrictTo.Scope.LIBRARY) + @Override + @NonNull + public VideoCapabilities getMediaCapabilities(@NonNull CameraInfo cameraInfo) { + return getVideoCapabilities(cameraInfo); + } + /** * Prepares a recording that will be saved to a {@link File}. * @@ -846,7 +862,8 @@ public final class Recorder implements VideoOutput { } } - void stop(@NonNull Recording activeRecording) { + void stop(@NonNull Recording activeRecording, @VideoRecordError int error, + @Nullable Throwable errorCause) { RecordingRecord pendingRecordingToFinalize = null; synchronized (mLock) { if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording( @@ -891,7 +908,7 @@ public final class Recorder implements VideoOutput { long explicitlyStopTimeUs = TimeUnit.NANOSECONDS.toMicros(System.nanoTime()); RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord; mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord, - explicitlyStopTimeUs, ERROR_NONE, null)); + explicitlyStopTimeUs, error, errorCause)); break; case ERROR: // In an error state, the recording will already be finalized. Treat as a @@ -901,9 +918,13 @@ public final class Recorder implements VideoOutput { } if (pendingRecordingToFinalize != null) { + if (error == VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED) { + Logger.e(TAG, "Recording was stopped due to recording being garbage collected " + + "before any valid data has been produced."); + } finalizePendingRecording(pendingRecordingToFinalize, ERROR_NO_VALID_DATA, new RuntimeException("Recording was stopped before any data could be " - + "produced.")); + + "produced.", errorCause)); } } @@ -933,7 +954,8 @@ public final class Recorder implements VideoOutput { recordingToFinalize.getOutputOptions(), RecordingStats.of(/*duration=*/0L, /*bytes=*/0L, - AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause)), + AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause, + AUDIO_AMPLITUDE_NONE)), OutputResults.of(Uri.EMPTY), error, cause)); @@ -964,14 +986,15 @@ public final class Recorder implements VideoOutput { // If we're inactive and have no active surface, we'll reset the encoder directly. // Otherwise, we'll wait for the active surface's surface request listener to // reset the encoder. - requestReset(ERROR_SOURCE_INACTIVE, null); + requestReset(ERROR_SOURCE_INACTIVE, null, false); } else { // The source becomes inactive, the incoming new surface request has to be cached // and be serviced after the Recorder is reset when receiving the previous // surface request complete callback. - mNeedsReset = true; - if (mInProgressRecording != null) { - // Stop any in progress recording with "source inactive" error + mNeedsResetBeforeNextStart = true; + if (mInProgressRecording != null && !mInProgressRecording.isPersistent()) { + // Stop the in progress recording with "source inactive" error if it's not a + // persistent recording. onInProgressRecordingInternalError(mInProgressRecording, ERROR_SOURCE_INACTIVE, null); } @@ -995,7 +1018,8 @@ public final class Recorder implements VideoOutput { * the surface request complete callback first. */ @ExecutedBy("mSequentialExecutor") - void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause) { + void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause, + boolean videoOnly) { boolean shouldReset = false; boolean shouldStop = false; synchronized (mLock) { @@ -1017,14 +1041,22 @@ public final class Recorder implements VideoOutput { case PAUSED: // Fall-through case RECORDING: + Preconditions.checkState(mInProgressRecording != null, "In-progress recording" + + " shouldn't be null when in state " + mState); if (mActiveRecordingRecord != mInProgressRecording) { throw new AssertionError("In-progress recording does not match the active" + " recording. Unable to reset encoder."); } - // If there's an active recording, stop it first then release the resources - // at onRecordingFinalized(). - shouldStop = true; - // Fall-through + // If there's an active persistent recording, reset the Recorder directly. + // Otherwise, stop the recording first then release the Recorder at + // onRecordingFinalized(). + if (isPersistentRecordingInProgress()) { + shouldReset = true; + } else { + shouldStop = true; + setState(State.RESETTING); + } + break; case STOPPING: // Already stopping. Set state to RESETTING so resources will be released once // onRecordingFinalized() runs. @@ -1039,14 +1071,17 @@ public final class Recorder implements VideoOutput { // These calls must not be posted to the executor to ensure they are executed inline on // the sequential executor and the state changes above are correctly handled. if (shouldReset) { - reset(); + if (videoOnly) { + resetVideo(); + } else { + reset(); + } } else if (shouldStop) { stopInternal(mInProgressRecording, Encoder.NO_TIMESTAMP, errorCode, errorCause); } } @ExecutedBy("mSequentialExecutor") - private void configureInternal(@NonNull SurfaceRequest surfaceRequest, @NonNull Timebase videoSourceTimebase) { if (surfaceRequest.isServiced()) { @@ -1054,16 +1089,19 @@ public final class Recorder implements VideoOutput { return; } surfaceRequest.setTransformationInfoListener(mSequentialExecutor, - (transformationInfo) -> mSurfaceTransformationInfo = transformationInfo); + (transformationInfo) -> mSourceTransformationInfo = transformationInfo); Size surfaceSize = surfaceRequest.getResolution(); // Fetch and cache nearest encoder profiles, if one exists. - LegacyVideoCapabilities capabilities = - LegacyVideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo()); - Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize); + DynamicRange dynamicRange = surfaceRequest.getDynamicRange(); + VideoCapabilities capabilities = getVideoCapabilities( + surfaceRequest.getCamera().getCameraInfo()); + Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize, + dynamicRange); Logger.d(TAG, "Using supported quality of " + highestSupportedQuality + " for surface size " + surfaceSize); if (highestSupportedQuality != Quality.NONE) { - mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality); + mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality, + dynamicRange); if (mResolvedEncoderProfiles == null) { throw new AssertionError("Camera advertised available quality but did not " + "produce EncoderProfiles for advertised quality."); @@ -1076,9 +1114,14 @@ public final class Recorder implements VideoOutput { @ExecutedBy("mSequentialExecutor") private void setupVideo(@NonNull SurfaceRequest request, @NonNull Timebase timebase) { safeToCloseVideoEncoder().addListener(() -> { - if (request.isServiced() || mVideoEncoderSession.isConfiguredSurfaceRequest(request)) { + if (request.isServiced() + || (mVideoEncoderSession.isConfiguredSurfaceRequest(request) + && !isPersistentRecordingInProgress())) { + // Ignore the surface request if it's already serviced. Or the video encoder + // session is already configured, unless there's a persistent recording is running. Logger.w(TAG, "Ignore the SurfaceRequest " + request + " isServiced: " - + request.isServiced() + " VideoEncoderSession: " + mVideoEncoderSession); + + request.isServiced() + " VideoEncoderSession: " + mVideoEncoderSession + + " has been configured with a persistent in-progress recording."); return; } VideoEncoderSession videoEncoderSession = @@ -1110,6 +1153,12 @@ public final class Recorder implements VideoOutput { }, mSequentialExecutor); } + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + @ExecutedBy("mSequentialExecutor") + boolean isPersistentRecordingInProgress() { + return mInProgressRecording != null && mInProgressRecording.isPersistent(); + } + @NonNull @ExecutedBy("mSequentialExecutor") private ListenableFuture safeToCloseVideoEncoder() { @@ -1145,7 +1194,9 @@ public final class Recorder implements VideoOutput { mVideoEncoderSessionToRelease = videoEncoderSession; setLatestSurface(null); - requestReset(ERROR_SOURCE_INACTIVE, null); + // Only reset video if the in-progress recording is persistent. + requestReset(ERROR_SOURCE_INACTIVE, null, + isPersistentRecordingInProgress()); } @Override @@ -1160,17 +1211,14 @@ public final class Recorder implements VideoOutput { void onConfigured() { RecordingRecord recordingToStart = null; RecordingRecord pendingRecordingToFinalize = null; + boolean continuePersistentRecording = false; @VideoRecordError int error = ERROR_NONE; Throwable errorCause = null; - boolean startRecordingPaused = false; + boolean recordingPaused = false; synchronized (mLock) { switch (mState) { case IDLING: // Fall-through - case RECORDING: - // Fall-through - case PAUSED: - // Fall-through case RESETTING: throw new AssertionError( "Incorrectly invoke onConfigured() in state " + mState); @@ -1180,6 +1228,15 @@ public final class Recorder implements VideoOutput { + "STOPPING state when it's not waiting for a new surface."); } break; + case PAUSED: + recordingPaused = true; + // Fall-through + case RECORDING: + Preconditions.checkState(isPersistentRecordingInProgress(), + "Unexpectedly invoke onConfigured() when there's a non-persistent " + + "in-progress recording"); + continuePersistentRecording = true; + break; case CONFIGURING: setState(State.IDLING); break; @@ -1188,7 +1245,7 @@ public final class Recorder implements VideoOutput { "onConfigured() was invoked when the Recorder had encountered error"); break; case PENDING_PAUSED: - startRecordingPaused = true; + recordingPaused = true; // Fall through case PENDING_RECORDING: if (mActiveRecordingRecord != null) { @@ -1209,9 +1266,21 @@ public final class Recorder implements VideoOutput { } } - if (recordingToStart != null) { + if (continuePersistentRecording) { + updateEncoderCallbacks(mInProgressRecording, true); + mVideoEncoder.start(); + if (mShouldSendResumeEvent) { + mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume( + mInProgressRecording.getOutputOptions(), + getInProgressRecordingStats())); + mShouldSendResumeEvent = false; + } + if (recordingPaused) { + mVideoEncoder.pause(); + } + } else if (recordingToStart != null) { // Start new active recording inline on sequential executor (but unlocked). - startRecording(recordingToStart, startRecordingPaused); + startRecording(recordingToStart, recordingPaused); } else if (pendingRecordingToFinalize != null) { finalizePendingRecording(pendingRecordingToFinalize, error, errorCause); } @@ -1252,7 +1321,7 @@ public final class Recorder implements VideoOutput { throws AudioSourceAccessException, InvalidConfigException { MediaSpec mediaSpec = getObservableData(mMediaSpec); // Resolve the audio mime info - MimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles); + AudioMimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles); Timebase audioSourceTimebase = Timebase.UPTIME; // Select and create the audio source @@ -1403,8 +1472,10 @@ public final class Recorder implements VideoOutput { return; } - if (mSurfaceTransformationInfo != null) { - mediaMuxer.setOrientationHint(mSurfaceTransformationInfo.getRotationDegrees()); + SurfaceRequest.TransformationInfo transformationInfo = mSourceTransformationInfo; + if (transformationInfo != null) { + setInProgressTransformationInfo(transformationInfo); + mediaMuxer.setOrientationHint(transformationInfo.getRotationDegrees()); } Location location = recordingToStart.getOutputOptions().getLocation(); if (location != null) { @@ -1507,7 +1578,9 @@ public final class Recorder implements VideoOutput { "The Recorder doesn't support recording with audio"); } try { - setupAudio(recordingToStart); + if (!mInProgressRecording.isPersistent() || mAudioEncoder == null) { + setupAudio(recordingToStart); + } setAudioState(AudioState.ENABLED); } catch (AudioSourceAccessException | InvalidConfigException e) { Logger.e(TAG, "Unable to create audio resource with error: ", e); @@ -1524,7 +1597,7 @@ public final class Recorder implements VideoOutput { break; } - initEncoderAndAudioSourceCallbacks(recordingToStart); + updateEncoderCallbacks(recordingToStart, false); if (isAudioEnabled()) { mAudioSource.start(recordingToStart.isMuted()); mAudioEncoder.start(); @@ -1537,7 +1610,17 @@ public final class Recorder implements VideoOutput { } @ExecutedBy("mSequentialExecutor") - private void initEncoderAndAudioSourceCallbacks(@NonNull RecordingRecord recordingToStart) { + private void updateEncoderCallbacks(@NonNull RecordingRecord recordingToStart, + boolean videoOnly) { + // If there are uncompleted futures, cancel them first. + if (!mEncodingFutures.isEmpty()) { + ListenableFuture> listFuture = Futures.allAsList(mEncodingFutures); + if (!listFuture.isDone()) { + listFuture.cancel(true); + } + mEncodingFutures.clear(); + } + mEncodingFutures.add(CallbackToFutureAdapter.getFuture( completer -> { mVideoEncoder.setEncoderCallback(new EncoderCallback() { @@ -1633,7 +1716,7 @@ public final class Recorder implements VideoOutput { return "videoEncodingFuture"; })); - if (isAudioEnabled()) { + if (isAudioEnabled() && !videoOnly) { mEncodingFutures.add(CallbackToFutureAdapter.getFuture( completer -> { Consumer audioErrorConsumer = throwable -> { @@ -1673,6 +1756,11 @@ public final class Recorder implements VideoOutput { audioErrorConsumer.accept(throwable); } } + + @Override + public void onAmplitudeValue(double maxAmplitude) { + mAudioAmplitude = maxAmplitude; + } }); mAudioEncoder.setEncoderCallback(new EncoderCallback() { @@ -1759,12 +1847,16 @@ public final class Recorder implements VideoOutput { @Override public void onFailure(@NonNull Throwable t) { - Logger.d(TAG, "Encodings end with error: " + t); - // If the media muxer hasn't been set up, assume the encoding fails - // because of no valid data has been produced. - finalizeInProgressRecording( - mMediaMuxer == null ? ERROR_NO_VALID_DATA : ERROR_ENCODING_FAILED, - t); + Preconditions.checkState(mInProgressRecording != null, + "In-progress recording shouldn't be null"); + // If a persistent recording requires reconfiguring the video encoder, + // the previous encoder future has to be canceled without finalizing the + // in-progress recording. + if (!mInProgressRecording.isPersistent()) { + Logger.d(TAG, "Encodings end with error: " + t); + finalizeInProgressRecording(mMediaMuxer == null ? ERROR_NO_VALID_DATA + : ERROR_ENCODING_FAILED, t); + } } }, // Can use direct executor since completers are always completed on sequential @@ -1905,11 +1997,20 @@ public final class Recorder implements VideoOutput { if (isAudioEnabled()) { mAudioEncoder.start(); } - mVideoEncoder.start(); - - mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume( - mInProgressRecording.getOutputOptions(), - getInProgressRecordingStats())); + // If a persistent recording is resumed immediately after the VideoCapture is rebound + // to a camera, it's possible that the encoder hasn't been created yet. Then the + // encoder will be started once it's initialized. So only start the encoder when it's + // not null. + if (mVideoEncoder != null) { + mVideoEncoder.start(); + mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume( + mInProgressRecording.getOutputOptions(), + getInProgressRecordingStats())); + } else { + // Instead sending here, send the Resume event once the encoder is initialized + // and started. + mShouldSendResumeEvent = true; + } } } @@ -2003,13 +2104,12 @@ public final class Recorder implements VideoOutput { mAudioEncoder = null; mAudioOutputConfig = null; } - tryReleaseVideoEncoder(); if (mAudioSource != null) { releaseCurrentAudioSource(); } setAudioState(AudioState.INITIALIZING); - onReset(); + resetVideo(); } @SuppressWarnings("FutureReturnValueIgnored") @@ -2031,7 +2131,8 @@ public final class Recorder implements VideoOutput { } @ExecutedBy("mSequentialExecutor") - private void onReset() { + private void onResetVideo() { + boolean shouldConfigure = true; synchronized (mLock) { switch (mState) { case PENDING_PAUSED: @@ -2044,6 +2145,10 @@ public final class Recorder implements VideoOutput { case PAUSED: // Fall-through case RECORDING: + if (isPersistentRecordingInProgress()) { + shouldConfigure = false; + break; + } // Fall-through case IDLING: // Fall-through @@ -2058,14 +2163,24 @@ public final class Recorder implements VideoOutput { } } - mNeedsReset = false; + mNeedsResetBeforeNextStart = false; // If the latest surface request hasn't been serviced, use it to re-configure the Recorder. - if (mLatestSurfaceRequest != null && !mLatestSurfaceRequest.isServiced()) { + if (shouldConfigure && mLatestSurfaceRequest != null + && !mLatestSurfaceRequest.isServiced()) { configureInternal(mLatestSurfaceRequest, mVideoSourceTimebase); } } + @ExecutedBy("mSequentialExecutor") + private void resetVideo() { + if (mVideoEncoder != null) { + Logger.d(TAG, "Releasing video encoder."); + tryReleaseVideoEncoder(); + } + onResetVideo(); + } + @ExecutedBy("mSequentialExecutor") private int internalAudioStateToAudioStatsState(@NonNull AudioState audioState) { switch (audioState) { @@ -2168,7 +2283,9 @@ public final class Recorder implements VideoOutput { mRecordingStopError = ERROR_UNKNOWN; mRecordingStopErrorCause = null; mAudioErrorCause = null; + mAudioAmplitude = AUDIO_AMPLITUDE_NONE; clearPendingAudioRingBuffer(); + setInProgressTransformationInfo(null); switch (mAudioState) { case IDLING: @@ -2351,7 +2468,7 @@ public final class Recorder implements VideoOutput { startRecordingPaused = true; // Fall-through case PENDING_RECORDING: - if (mActiveRecordingRecord != null || mNeedsReset) { + if (mActiveRecordingRecord != null || mNeedsResetBeforeNextStart) { // Active recording is still finalizing or the Recorder is expected to be // reset. Pending recording will be serviced in onRecordingFinalized() or // in onReset(). @@ -2466,7 +2583,8 @@ public final class Recorder implements VideoOutput { @NonNull RecordingStats getInProgressRecordingStats() { return RecordingStats.of(mRecordingDurationNs, mRecordingBytes, - AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause)); + AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause, + mAudioAmplitude)); } @SuppressWarnings("WeakerAccess") /* synthetic accessor */ @@ -2520,7 +2638,7 @@ public final class Recorder implements VideoOutput { if (streamState == null) { streamState = internalStateToStreamState(mState); } - mStreamInfo.setState(StreamInfo.of(mStreamId, streamState)); + mStreamInfo.setState(StreamInfo.of(mStreamId, streamState, mInProgressTransformationInfo)); } @ExecutedBy("mSequentialExecutor") @@ -2542,7 +2660,20 @@ public final class Recorder implements VideoOutput { } Logger.d(TAG, "Transitioning streamId: " + mStreamId + " --> " + streamId); mStreamId = streamId; - mStreamInfo.setState(StreamInfo.of(streamId, internalStateToStreamState(mState))); + mStreamInfo.setState(StreamInfo.of(streamId, internalStateToStreamState(mState), + mInProgressTransformationInfo)); + } + + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ + @ExecutedBy("mSequentialExecutor") + void setInProgressTransformationInfo( + @Nullable SurfaceRequest.TransformationInfo transformationInfo) { + Logger.d(TAG, "Update stream transformation info: " + transformationInfo); + mInProgressTransformationInfo = transformationInfo; + synchronized (mLock) { + mStreamInfo.setState(StreamInfo.of(mStreamId, internalStateToStreamState(mState), + transformationInfo)); + } } /** @@ -2565,8 +2696,8 @@ public final class Recorder implements VideoOutput { if (mNonPendingState != state) { mNonPendingState = state; - mStreamInfo.setState( - StreamInfo.of(mStreamId, internalStateToStreamState(state))); + mStreamInfo.setState(StreamInfo.of(mStreamId, internalStateToStreamState(state), + mInProgressTransformationInfo)); } } @@ -2614,6 +2745,21 @@ public final class Recorder implements VideoOutput { return defaultMuxerFormat; } + /** + * Returns the {@link VideoCapabilities} of Recorder with respect to input camera information. + * + *

{@link VideoCapabilities} provides methods to query supported dynamic ranges and + * qualities. This information can be used for things like checking if HDR is supported for + * configuring VideoCapture to record HDR video. + * + * @param cameraInfo info about the camera. + * @return VideoCapabilities with respect to the input camera info. + */ + @NonNull + public static VideoCapabilities getVideoCapabilities(@NonNull CameraInfo cameraInfo) { + return RecorderVideoCapabilities.from(cameraInfo); + } + @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java @AutoValue abstract static class RecordingRecord implements AutoCloseable { @@ -2642,6 +2788,7 @@ public final class Recorder implements VideoOutput { pendingRecording.getListenerExecutor(), pendingRecording.getEventListener(), pendingRecording.isAudioEnabled(), + pendingRecording.isPersistent(), recordingId ); } @@ -2657,6 +2804,8 @@ public final class Recorder implements VideoOutput { abstract boolean hasAudioEnabled(); + abstract boolean isPersistent(); + abstract long getRecordingId(); /** @@ -2720,8 +2869,13 @@ public final class Recorder implements VideoOutput { // Toggle on pending status for the video file. contentValues.put(MediaStore.Video.Media.IS_PENDING, PENDING); } - outputUri = mediaStoreOutputOptions.getContentResolver().insert( - mediaStoreOutputOptions.getCollectionUri(), contentValues); + try { + outputUri = mediaStoreOutputOptions.getContentResolver().insert( + mediaStoreOutputOptions.getCollectionUri(), contentValues); + } catch (RuntimeException e) { + throw new IOException("Unable to create MediaStore entry by " + e, + e); + } if (outputUri == null) { throw new IOException("Unable to create MediaStore entry."); } @@ -2943,7 +3097,12 @@ public final class Recorder implements VideoOutput { throw new AssertionError("One-time media muxer creation has already occurred for" + " recording " + this); } - return mediaMuxerSupplier.get(muxerOutputFormat, outputUriCreatedCallback); + + try { + return mediaMuxerSupplier.get(muxerOutputFormat, outputUriCreatedCallback); + } catch (RuntimeException e) { + throw new IOException("Failed to create MediaMuxer by " + e, e); + } } /** diff --git a/app/src/main/java/androidx/camera/video/originals/base/Recording.java b/app/src/main/java/androidx/camera/video/originals/base/Recording.java index 5eb4c48..db14f96 100644 --- a/app/src/main/java/androidx/camera/video/originals/base/Recording.java +++ b/app/src/main/java/androidx/camera/video/originals/base/Recording.java @@ -19,6 +19,7 @@ package androidx.camera.video; import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.RestrictTo; import androidx.camera.core.impl.utils.CloseGuardHelper; @@ -53,13 +54,15 @@ public final class Recording implements AutoCloseable { private final Recorder mRecorder; private final long mRecordingId; private final OutputOptions mOutputOptions; + private final boolean mIsPersistent; private final CloseGuardHelper mCloseGuard = CloseGuardHelper.create(); Recording(@NonNull Recorder recorder, long recordingId, @NonNull OutputOptions options, - boolean finalizedOnCreation) { + boolean isPersistent, boolean finalizedOnCreation) { mRecorder = recorder; mRecordingId = recordingId; mOutputOptions = options; + mIsPersistent = isPersistent; if (finalizedOnCreation) { mIsClosed.set(true); @@ -80,6 +83,7 @@ public final class Recording implements AutoCloseable { return new Recording(pendingRecording.getRecorder(), recordingId, pendingRecording.getOutputOptions(), + pendingRecording.isPersistent(), /*finalizedOnCreation=*/false); } @@ -100,6 +104,7 @@ public final class Recording implements AutoCloseable { return new Recording(pendingRecording.getRecorder(), recordingId, pendingRecording.getOutputOptions(), + pendingRecording.isPersistent(), /*finalizedOnCreation=*/true); } @@ -108,6 +113,20 @@ public final class Recording implements AutoCloseable { return mOutputOptions; } + /** + * Returns whether this recording is a persistent recording. + * + *

A persistent recording will only be stopped by explicitly calling of + * {@link Recording#stop()} and will ignore the lifecycle events or source state changes. + * Users are responsible of stopping a persistent recording. + * + * @return {@code true} if the recording is a persistent recording, otherwise {@code false}. + */ + @ExperimentalPersistentRecording + public boolean isPersistent() { + return mIsPersistent; + } + /** * Pauses the current recording if active. * @@ -193,11 +212,7 @@ public final class Recording implements AutoCloseable { */ @Override public void close() { - mCloseGuard.close(); - if (mIsClosed.getAndSet(true)) { - return; - } - mRecorder.stop(this); + stopWithError(VideoRecordEvent.Finalize.ERROR_NONE, /*errorCause=*/ null); } @Override @@ -205,7 +220,8 @@ public final class Recording implements AutoCloseable { protected void finalize() throws Throwable { try { mCloseGuard.warnIfOpen(); - stop(); + stopWithError(VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED, + new RuntimeException("Recording stopped due to being garbage collected.")); } finally { super.finalize(); } @@ -231,5 +247,14 @@ public final class Recording implements AutoCloseable { public boolean isClosed() { return mIsClosed.get(); } + + private void stopWithError(@VideoRecordEvent.Finalize.VideoRecordError int error, + @Nullable Throwable errorCause) { + mCloseGuard.close(); + if (mIsClosed.getAndSet(true)) { + return; + } + mRecorder.stop(this, error, errorCause); + } } diff --git a/app/src/main/java/sushi/hardcore/droidfs/widgets/CircleClipTapView.kt b/app/src/main/java/sushi/hardcore/droidfs/widgets/CircleClipTapView.kt index 295ab4c..3ba31bb 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/widgets/CircleClipTapView.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/widgets/CircleClipTapView.kt @@ -185,7 +185,7 @@ internal class CircleClipTapView(context: Context, attrs: AttributeSet): View(co updatePathShape() } - override fun onDraw(canvas: Canvas?) { + override fun onDraw(canvas: Canvas) { super.onDraw(canvas) // Background diff --git a/build.gradle b/build.gradle index f22496b..f37707f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,11 +1,11 @@ buildscript { - ext.kotlin_version = '1.8.21' + ext.kotlin_version = '1.9.0' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' + classpath 'com.android.tools.build:gradle:8.1.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -17,6 +17,6 @@ allprojects { } } -task clean(type: Delete) { - delete rootProject.buildDir +tasks.register('clean', Delete) { + delete layout.buildDirectory } diff --git a/gradle.properties b/gradle.properties index 155fba7..52ed220 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,10 +7,6 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn @@ -18,4 +14,5 @@ android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -android.native.buildOutput=verbose \ No newline at end of file +android.native.buildOutput=verbose +android.nonTransitiveRClass=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c..ccebba7 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9452bde..7c2c8c2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ -#Wed Feb 01 20:48:39 UTC 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip -distributionSha256Sum=7ba68c54029790ab444b39d7e293d3236b2632631fb5f2e012bb28b4ff669e4b -zipStorePath=wrapper/dists +distributionSha256Sum=591855b517fc635b9e04de1d05d5e76ada3f89f5fc76f87978d1b245b4f69225 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0..79a61d4 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,105 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f..6689b85 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/libpdfviewer b/libpdfviewer index c74b374..59973a6 160000 --- a/libpdfviewer +++ b/libpdfviewer @@ -1 +1 @@ -Subproject commit c74b374ec49a1f47b9879b8fbc7b72b046ef55fd +Subproject commit 59973a6b42485b0c430123c8be649fb24689b9f3