diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..dfe62b5 --- /dev/null +++ b/TODO.md @@ -0,0 +1,26 @@ +# TODO + +Here is a list of features that it would be nice to have in DroidFS. + +## Security +- [hardened_malloc](https://github.com/GrapheneOS/hardened_malloc) compatibility ([#181](https://github.com/hardcore-sushi/DroidFS/issues/181)) +- Internal keyboard for passwords + +## UX +- File associations editor +- Modifiable CryFS scrypt parameters +- Alert dialog showing details of file operations +- Internal file browser to select volumes + +## Health +- F-Droid ABI split +- OpenSSL & FFmpeg as git submodules (useful for F-Droid) +- Remove all android:configChanges from AndroidManifest.xml +- More efficient thumbnails cache +- Guide for translators +- Usage & code documentation +- Automated tests + +## And: +- All the [feature requests on the GitHub repo](https://github.com/hardcore-sushi/DroidFS/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement) +- All the [feature requests on the Gitea repo](https://forge.chapril.org/hardcoresushi/DroidFS/issues?q=&state=open&labels=748) diff --git a/app/build.gradle b/app/build.gradle index 3a87ecf..d2aed68 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -110,16 +110,16 @@ dependencies { implementation "androidx.preference:preference-ktx:1.2.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation 'com.google.android.material:material:1.8.0' - implementation "com.github.bumptech.glide:glide:4.13.2" + implementation 'com.github.bumptech.glide:glide:4.15.1' implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" - def exoplayer_version = "2.18.5" + def exoplayer_version = "2.18.6" 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-alpha05" + def camerax_version = "1.3.0-alpha06" 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/src/main/java/androidx/camera/video/SucklessRecorder.java b/app/src/main/java/androidx/camera/video/SucklessRecorder.java index e829db7..9479080 100644 --- a/app/src/main/java/androidx/camera/video/SucklessRecorder.java +++ b/app/src/main/java/androidx/camera/video/SucklessRecorder.java @@ -223,9 +223,9 @@ public final class SucklessRecorder implements VideoOutput { */ DISABLED, /** - * The recording is being recorded with audio. + * Audio recording is enabled for the running recording. */ - ACTIVE, + ENABLED, /** * The audio encoder encountered errors. */ @@ -817,6 +817,24 @@ public final class SucklessRecorder implements VideoOutput { } } + void mute(@NonNull SucklessRecording activeRecording, boolean muted) { + synchronized (mLock) { + if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording( + activeRecording, mActiveRecordingRecord)) { + // If this Recording is no longer active, log and treat as a no-op. + // This is not technically an error since the recording can be finalized + // asynchronously. + Logger.d(TAG, + "mute() called on a recording that is no longer active: " + + activeRecording.getOutputOptions()); + return; + } + RecordingRecord finalRecordingRecord = isSameRecording(activeRecording, + mPendingRecordingRecord) ? mPendingRecordingRecord : mActiveRecordingRecord; + mSequentialExecutor.execute(() -> muteInternal(finalRecordingRecord, muted)); + } + } + private void finalizePendingRecording(@NonNull RecordingRecord recordingToFinalize, @VideoRecordError int error, @Nullable Throwable cause) { recordingToFinalize.finalizeRecording(Uri.EMPTY); @@ -949,8 +967,8 @@ public final class SucklessRecorder implements VideoOutput { (transformationInfo) -> mSurfaceTransformationInfo = transformationInfo); Size surfaceSize = surfaceRequest.getResolution(); // Fetch and cache nearest encoder profiles, if one exists. - VideoCapabilities capabilities = - VideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo()); + LegacyVideoCapabilities capabilities = + LegacyVideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo()); Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize); Logger.d(TAG, "Using supported quality of " + highestSupportedQuality + " for surface size " + surfaceSize); @@ -1368,13 +1386,13 @@ public final class SucklessRecorder implements VideoOutput { // Fall-through case ERROR_SOURCE: // Fall-through - case ACTIVE: + case ENABLED: // Fall-through case DISABLED: throw new AssertionError( "Incorrectly invoke startInternal in audio state " + mAudioState); case IDLING: - setAudioState(recordingToStart.hasAudioEnabled() ? AudioState.ACTIVE + setAudioState(recordingToStart.hasAudioEnabled() ? AudioState.ENABLED : AudioState.DISABLED); break; case INITIALIZING: @@ -1385,7 +1403,7 @@ public final class SucklessRecorder implements VideoOutput { } try { setupAudio(recordingToStart); - setAudioState(AudioState.ACTIVE); + setAudioState(AudioState.ENABLED); } catch (AudioSourceAccessException | InvalidConfigException e) { Logger.e(TAG, "Unable to create audio resource with error: ", e); AudioState audioState; @@ -1403,7 +1421,7 @@ public final class SucklessRecorder implements VideoOutput { initEncoderAndAudioSourceCallbacks(recordingToStart); if (isAudioEnabled()) { - mAudioSource.start(); + mAudioSource.start(recordingToStart.isMuted()); mAudioEncoder.start(); } mVideoEncoder.start(); @@ -1535,8 +1553,6 @@ public final class SucklessRecorder implements VideoOutput { public void onSilenceStateChanged(boolean silenced) { if (mIsAudioSourceSilenced != silenced) { mIsAudioSourceSilenced = silenced; - mAudioErrorCause = silenced ? new IllegalStateException( - "The audio source has been silenced.") : null; updateInProgressStatusEvent(); } else { Logger.w(TAG, "Audio source silenced transitions" @@ -1579,9 +1595,9 @@ public final class SucklessRecorder implements VideoOutput { @Override public void onEncodedData(@NonNull EncodedData encodedData) { if (mAudioState == AudioState.DISABLED) { - throw new AssertionError( - "Audio is not enabled but audio encoded data is " - + "produced."); + encodedData.close(); + throw new AssertionError("Audio is not enabled but audio " + + "encoded data is being produced."); } // If the media muxer doesn't yet exist, we may need to create and @@ -1847,6 +1863,19 @@ public final class SucklessRecorder implements VideoOutput { } } + @ExecutedBy("mSequentialExecutor") + private void muteInternal(@NonNull RecordingRecord recordingToMute, boolean muted) { + if (recordingToMute.isMuted() == muted) { + return; + } + recordingToMute.mute(muted); + // Only mute/unmute audio source if recording is in-progress and it is not already stopping. + if (mInProgressRecording == recordingToMute && !mInProgressRecordingStopping + && mAudioSource != null) { + mAudioSource.mute(muted); + } + } + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ static void notifyEncoderSourceStopped(@NonNull Encoder encoder) { if (encoder instanceof SucklessEncoderImpl) { @@ -1941,8 +1970,10 @@ public final class SucklessRecorder implements VideoOutput { // Audio will not be initialized until the first recording with audio enabled is // started. So if the audio state is INITIALIZING, consider the audio is disabled. return AudioStats.AUDIO_STATE_DISABLED; - case ACTIVE: - if (mIsAudioSourceSilenced) { + case ENABLED: + if (mInProgressRecording != null && mInProgressRecording.isMuted()) { + return AudioStats.AUDIO_STATE_MUTED; + } else if (mIsAudioSourceSilenced) { return AudioStats.AUDIO_STATE_SOURCE_SILENCED; } else { return AudioStats.AUDIO_STATE_ACTIVE; @@ -1971,7 +2002,7 @@ public final class SucklessRecorder implements VideoOutput { @SuppressWarnings("WeakerAccess") /* synthetic accessor */ @ExecutedBy("mSequentialExecutor") boolean isAudioEnabled() { - return mAudioState == AudioState.ACTIVE; + return mAudioState == AudioState.ENABLED; } @SuppressWarnings("WeakerAccess") /* synthetic accessor */ @@ -2043,7 +2074,7 @@ public final class SucklessRecorder implements VideoOutput { break; case DISABLED: // Fall-through - case ACTIVE: + case ENABLED: setAudioState(AudioState.IDLING); mAudioSource.stop(); break; @@ -2497,6 +2528,8 @@ public final class SucklessRecorder implements VideoOutput { /* no-op by default */ }); + private final AtomicBoolean mMuted = new AtomicBoolean(false); + @NonNull static RecordingRecord from(@NonNull SucklessPendingRecording pendingRecording, long recordingId) { return new AutoValue_SucklessRecorder_RecordingRecord( @@ -2769,6 +2802,14 @@ public final class SucklessRecorder implements VideoOutput { finalizeRecordingInternal(mRecordingFinalizer.getAndSet(null), uri); } + void mute(boolean muted) { + mMuted.set(muted); + } + + boolean isMuted() { + return mMuted.get(); + } + /** * Close this recording, as if calling {@link #finalizeRecording(Uri)} with parameter * {@link Uri#EMPTY}. diff --git a/app/src/main/java/androidx/camera/video/SucklessRecording.java b/app/src/main/java/androidx/camera/video/SucklessRecording.java index ec706b2..fa06aac 100644 --- a/app/src/main/java/androidx/camera/video/SucklessRecording.java +++ b/app/src/main/java/androidx/camera/video/SucklessRecording.java @@ -159,6 +159,25 @@ public final class SucklessRecording implements AutoCloseable { close(); } + /** + * Mutes or un-mutes the current recording. + * + *
The output file will contain an audio track even the whole recording is muted. Create a + * recording without calling {@link PendingRecording#withAudioEnabled()} to record a file + * with no audio track. + * + *
Muting or unmuting a recording that isn't created + * {@link PendingRecording#withAudioEnabled()} with audio enabled is no-op. + * + * @param muted mutes the recording if {@code true}, un-mutes otherwise. + */ + public void mute(boolean muted) { + if (mIsClosed.get()) { + throw new IllegalStateException("The recording has been stopped."); + } + mRecorder.mute(this, muted); + } + /** * Close this recording. * diff --git a/app/src/main/java/androidx/camera/video/internal/encoder/SucklessEncoderImpl.java b/app/src/main/java/androidx/camera/video/internal/encoder/SucklessEncoderImpl.java index 9280689..6a443bf 100644 --- a/app/src/main/java/androidx/camera/video/internal/encoder/SucklessEncoderImpl.java +++ b/app/src/main/java/androidx/camera/video/internal/encoder/SucklessEncoderImpl.java @@ -1675,4 +1675,3 @@ public class SucklessEncoderImpl implements Encoder { } } - diff --git a/app/src/main/java/androidx/camera/video/originals/base/EncoderImpl.java b/app/src/main/java/androidx/camera/video/originals/base/EncoderImpl.java index 6a4436e..a018630 100644 --- a/app/src/main/java/androidx/camera/video/originals/base/EncoderImpl.java +++ b/app/src/main/java/androidx/camera/video/originals/base/EncoderImpl.java @@ -1683,4 +1683,3 @@ public class EncoderImpl implements Encoder { } } - 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 06ccc55..acb2395 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 @@ -226,9 +226,9 @@ public final class Recorder implements VideoOutput { */ DISABLED, /** - * The recording is being recorded with audio. + * Audio recording is enabled for the running recording. */ - ACTIVE, + ENABLED, /** * The audio encoder encountered errors. */ @@ -907,6 +907,24 @@ public final class Recorder implements VideoOutput { } } + void mute(@NonNull Recording activeRecording, boolean muted) { + synchronized (mLock) { + if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording( + activeRecording, mActiveRecordingRecord)) { + // If this Recording is no longer active, log and treat as a no-op. + // This is not technically an error since the recording can be finalized + // asynchronously. + Logger.d(TAG, + "mute() called on a recording that is no longer active: " + + activeRecording.getOutputOptions()); + return; + } + RecordingRecord finalRecordingRecord = isSameRecording(activeRecording, + mPendingRecordingRecord) ? mPendingRecordingRecord : mActiveRecordingRecord; + mSequentialExecutor.execute(() -> muteInternal(finalRecordingRecord, muted)); + } + } + private void finalizePendingRecording(@NonNull RecordingRecord recordingToFinalize, @VideoRecordError int error, @Nullable Throwable cause) { recordingToFinalize.finalizeRecording(Uri.EMPTY); @@ -1039,8 +1057,8 @@ public final class Recorder implements VideoOutput { (transformationInfo) -> mSurfaceTransformationInfo = transformationInfo); Size surfaceSize = surfaceRequest.getResolution(); // Fetch and cache nearest encoder profiles, if one exists. - VideoCapabilities capabilities = - VideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo()); + LegacyVideoCapabilities capabilities = + LegacyVideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo()); Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize); Logger.d(TAG, "Using supported quality of " + highestSupportedQuality + " for surface size " + surfaceSize); @@ -1473,13 +1491,13 @@ public final class Recorder implements VideoOutput { // Fall-through case ERROR_SOURCE: // Fall-through - case ACTIVE: + case ENABLED: // Fall-through case DISABLED: throw new AssertionError( "Incorrectly invoke startInternal in audio state " + mAudioState); case IDLING: - setAudioState(recordingToStart.hasAudioEnabled() ? AudioState.ACTIVE + setAudioState(recordingToStart.hasAudioEnabled() ? AudioState.ENABLED : AudioState.DISABLED); break; case INITIALIZING: @@ -1490,7 +1508,7 @@ public final class Recorder implements VideoOutput { } try { setupAudio(recordingToStart); - setAudioState(AudioState.ACTIVE); + setAudioState(AudioState.ENABLED); } catch (AudioSourceAccessException | InvalidConfigException e) { Logger.e(TAG, "Unable to create audio resource with error: ", e); AudioState audioState; @@ -1508,7 +1526,7 @@ public final class Recorder implements VideoOutput { initEncoderAndAudioSourceCallbacks(recordingToStart); if (isAudioEnabled()) { - mAudioSource.start(); + mAudioSource.start(recordingToStart.isMuted()); mAudioEncoder.start(); } mVideoEncoder.start(); @@ -1640,8 +1658,6 @@ public final class Recorder implements VideoOutput { public void onSilenceStateChanged(boolean silenced) { if (mIsAudioSourceSilenced != silenced) { mIsAudioSourceSilenced = silenced; - mAudioErrorCause = silenced ? new IllegalStateException( - "The audio source has been silenced.") : null; updateInProgressStatusEvent(); } else { Logger.w(TAG, "Audio source silenced transitions" @@ -1684,9 +1700,9 @@ public final class Recorder implements VideoOutput { @Override public void onEncodedData(@NonNull EncodedData encodedData) { if (mAudioState == AudioState.DISABLED) { - throw new AssertionError( - "Audio is not enabled but audio encoded data is " - + "produced."); + encodedData.close(); + throw new AssertionError("Audio is not enabled but audio " + + "encoded data is being produced."); } // If the media muxer doesn't yet exist, we may need to create and @@ -1952,6 +1968,19 @@ public final class Recorder implements VideoOutput { } } + @ExecutedBy("mSequentialExecutor") + private void muteInternal(@NonNull RecordingRecord recordingToMute, boolean muted) { + if (recordingToMute.isMuted() == muted) { + return; + } + recordingToMute.mute(muted); + // Only mute/unmute audio source if recording is in-progress and it is not already stopping. + if (mInProgressRecording == recordingToMute && !mInProgressRecordingStopping + && mAudioSource != null) { + mAudioSource.mute(muted); + } + } + @SuppressWarnings("WeakerAccess") /* synthetic accessor */ static void notifyEncoderSourceStopped(@NonNull Encoder encoder) { if (encoder instanceof EncoderImpl) { @@ -2046,8 +2075,10 @@ public final class Recorder implements VideoOutput { // Audio will not be initialized until the first recording with audio enabled is // started. So if the audio state is INITIALIZING, consider the audio is disabled. return AudioStats.AUDIO_STATE_DISABLED; - case ACTIVE: - if (mIsAudioSourceSilenced) { + case ENABLED: + if (mInProgressRecording != null && mInProgressRecording.isMuted()) { + return AudioStats.AUDIO_STATE_MUTED; + } else if (mIsAudioSourceSilenced) { return AudioStats.AUDIO_STATE_SOURCE_SILENCED; } else { return AudioStats.AUDIO_STATE_ACTIVE; @@ -2076,7 +2107,7 @@ public final class Recorder implements VideoOutput { @SuppressWarnings("WeakerAccess") /* synthetic accessor */ @ExecutedBy("mSequentialExecutor") boolean isAudioEnabled() { - return mAudioState == AudioState.ACTIVE; + return mAudioState == AudioState.ENABLED; } @SuppressWarnings("WeakerAccess") /* synthetic accessor */ @@ -2148,7 +2179,7 @@ public final class Recorder implements VideoOutput { break; case DISABLED: // Fall-through - case ACTIVE: + case ENABLED: setAudioState(AudioState.IDLING); mAudioSource.stop(); break; @@ -2602,6 +2633,8 @@ public final class Recorder implements VideoOutput { /* no-op by default */ }); + private final AtomicBoolean mMuted = new AtomicBoolean(false); + @NonNull static RecordingRecord from(@NonNull PendingRecording pendingRecording, long recordingId) { return new AutoValue_Recorder_RecordingRecord( @@ -2930,6 +2963,14 @@ public final class Recorder implements VideoOutput { finalizeRecordingInternal(mRecordingFinalizer.getAndSet(null), uri); } + void mute(boolean muted) { + mMuted.set(muted); + } + + boolean isMuted() { + return mMuted.get(); + } + /** * Close this recording, as if calling {@link #finalizeRecording(Uri)} with parameter * {@link Uri#EMPTY}. 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 3e7d7a3..5eb4c48 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 @@ -156,6 +156,25 @@ public final class Recording implements AutoCloseable { close(); } + /** + * Mutes or un-mutes the current recording. + * + *
The output file will contain an audio track even the whole recording is muted. Create a + * recording without calling {@link PendingRecording#withAudioEnabled()} to record a file + * with no audio track. + * + *
Muting or unmuting a recording that isn't created + * {@link PendingRecording#withAudioEnabled()} with audio enabled is no-op. + * + * @param muted mutes the recording if {@code true}, un-mutes otherwise. + */ + public void mute(boolean muted) { + if (mIsClosed.get()) { + throw new IllegalStateException("The recording has been stopped."); + } + mRecorder.mute(this, muted); + } + /** * Close this recording. * diff --git a/build.gradle b/build.gradle index 430f193..f22496b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = "1.8.10" + ext.kotlin_version = '1.8.21' repositories { google() mavenCentral()