diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveFormGenerator.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveFormGenerator.java index 30e5d0e3d7..873336e6ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveFormGenerator.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioWaveFormGenerator.java @@ -12,7 +12,7 @@ import androidx.annotation.WorkerThread; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.media.DecryptableUriMediaInput; -import org.thoughtcrime.securesms.media.MediaInput; +import org.thoughtcrime.securesms.video.videoconverter.MediaInput; import java.io.IOException; import java.nio.ByteBuffer; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java index 838626f9db..0273d77bc8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java @@ -47,7 +47,7 @@ import org.thoughtcrime.securesms.video.InMemoryTranscoder; import org.thoughtcrime.securesms.video.StreamingTranscoder; import org.thoughtcrime.securesms.video.TranscoderCancelationSignal; import org.thoughtcrime.securesms.video.TranscoderOptions; -import org.thoughtcrime.securesms.video.VideoSourceException; +import org.thoughtcrime.securesms.video.exceptions.VideoSourceException; import org.thoughtcrime.securesms.video.videoconverter.EncodingException; import java.io.ByteArrayInputStream; diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/DecryptableUriMediaInput.java b/app/src/main/java/org/thoughtcrime/securesms/media/DecryptableUriMediaInput.java deleted file mode 100644 index bc8f984f66..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/media/DecryptableUriMediaInput.java +++ /dev/null @@ -1,52 +0,0 @@ -package org.thoughtcrime.securesms.media; - -import android.content.Context; -import android.media.MediaDataSource; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; - -import org.thoughtcrime.securesms.attachments.AttachmentId; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.mms.PartAuthority; -import org.thoughtcrime.securesms.mms.PartUriParser; -import org.thoughtcrime.securesms.providers.BlobProvider; - -import java.io.IOException; - -@RequiresApi(api = 23) -public final class DecryptableUriMediaInput { - - private DecryptableUriMediaInput() { - } - - public static @NonNull MediaInput createForUri(@NonNull Context context, @NonNull Uri uri) throws IOException { - - if (BlobProvider.isAuthority(uri)) { - return new MediaInput.MediaDataSourceMediaInput(BlobProvider.getInstance().getMediaDataSource(context, uri)); - } - - if (PartAuthority.isLocalUri(uri)) { - return createForAttachmentUri(context, uri); - } - - return new MediaInput.UriMediaInput(context, uri); - } - - private static @NonNull MediaInput createForAttachmentUri(@NonNull Context context, @NonNull Uri uri) { - AttachmentId partId = new PartUriParser(uri).getPartId(); - - if (!partId.isValid()) { - throw new AssertionError(); - } - - MediaDataSource mediaDataSource = SignalDatabase.attachments().mediaDataSourceFor(partId, true); - - if (mediaDataSource == null) { - throw new AssertionError(); - } - - return new MediaInput.MediaDataSourceMediaInput(mediaDataSource); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/DecryptableUriMediaInput.kt b/app/src/main/java/org/thoughtcrime/securesms/media/DecryptableUriMediaInput.kt new file mode 100644 index 0000000000..094a068e9e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/media/DecryptableUriMediaInput.kt @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.media + +import android.content.Context +import android.net.Uri +import androidx.annotation.RequiresApi +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments +import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.mms.PartUriParser +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.video.videoconverter.MediaInput +import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.MediaDataSourceMediaInput +import java.io.IOException + +/** + * A media input source that is decrypted on the fly. + */ +@RequiresApi(api = 23) +object DecryptableUriMediaInput { + @JvmStatic + @Throws(IOException::class) + fun createForUri(context: Context, uri: Uri): MediaInput { + if (BlobProvider.isAuthority(uri)) { + return MediaDataSourceMediaInput(BlobProvider.getInstance().getMediaDataSource(context, uri)) + } + return if (PartAuthority.isLocalUri(uri)) { + createForAttachmentUri(uri) + } else { + UriMediaInput(context, uri) + } + } + + private fun createForAttachmentUri(uri: Uri): MediaInput { + val partId = PartUriParser(uri).partId + if (!partId.isValid) { + throw AssertionError() + } + val mediaDataSource = attachments.mediaDataSourceFor(partId, true) ?: throw AssertionError() + return MediaDataSourceMediaInput(mediaDataSource) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/FileMediaInput.kt b/app/src/main/java/org/thoughtcrime/securesms/media/FileMediaInput.kt new file mode 100644 index 0000000000..94b12b61e8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/media/FileMediaInput.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.thoughtcrime.securesms.media + +import android.media.MediaExtractor +import org.thoughtcrime.securesms.video.videoconverter.MediaInput +import java.io.File +import java.io.IOException + +/** + * A media input source that the system reads directly from the file. + */ +class FileMediaInput(private val file: File) : MediaInput { + @Throws(IOException::class) + override fun createExtractor(): MediaExtractor { + val extractor = MediaExtractor() + extractor.setDataSource(file.absolutePath) + return extractor + } + + override fun close() {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaInput.java b/app/src/main/java/org/thoughtcrime/securesms/media/MediaInput.java deleted file mode 100644 index d82b80b374..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaInput.java +++ /dev/null @@ -1,83 +0,0 @@ -package org.thoughtcrime.securesms.media; - -import android.content.Context; -import android.media.MediaDataSource; -import android.media.MediaExtractor; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; - -import java.io.Closeable; -import java.io.File; -import java.io.IOException; - -public abstract class MediaInput implements Closeable { - - @NonNull - public abstract MediaExtractor createExtractor() throws IOException; - - public static class FileMediaInput extends MediaInput { - - private final File file; - - public FileMediaInput(@NonNull File file) { - this.file = file; - } - - @Override - public @NonNull MediaExtractor createExtractor() throws IOException { - final MediaExtractor extractor = new MediaExtractor(); - extractor.setDataSource(file.getAbsolutePath()); - return extractor; - } - - @Override - public void close() { - } - } - - public static class UriMediaInput extends MediaInput { - - private final Uri uri; - private final Context context; - - public UriMediaInput(@NonNull Context context, @NonNull Uri uri) { - this.uri = uri; - this.context = context; - } - - @Override - public @NonNull MediaExtractor createExtractor() throws IOException { - final MediaExtractor extractor = new MediaExtractor(); - extractor.setDataSource(context, uri, null); - return extractor; - } - - @Override - public void close() { - } - } - - @RequiresApi(23) - public static class MediaDataSourceMediaInput extends MediaInput { - - private final MediaDataSource mediaDataSource; - - public MediaDataSourceMediaInput(@NonNull MediaDataSource mediaDataSource) { - this.mediaDataSource = mediaDataSource; - } - - @Override - public @NonNull MediaExtractor createExtractor() throws IOException { - final MediaExtractor extractor = new MediaExtractor(); - extractor.setDataSource(mediaDataSource); - return extractor; - } - - @Override - public void close() throws IOException { - mediaDataSource.close(); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/UriMediaInput.kt b/app/src/main/java/org/thoughtcrime/securesms/media/UriMediaInput.kt new file mode 100644 index 0000000000..ad7332670a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/media/UriMediaInput.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.thoughtcrime.securesms.media + +import android.content.Context +import android.media.MediaExtractor +import android.net.Uri +import org.thoughtcrime.securesms.video.videoconverter.MediaInput +import java.io.IOException + +/** + * A media input source defined by a [Uri] that the system can parse and access. + */ +class UriMediaInput(private val context: Context, private val uri: Uri) : MediaInput { + @Throws(IOException::class) + override fun createExtractor(): MediaExtractor { + val extractor = MediaExtractor() + extractor.setDataSource(context, uri, null) + return extractor + } + + override fun close() {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt index 3cc7b07d8c..117eeed0aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt @@ -15,7 +15,7 @@ import org.thoughtcrime.securesms.mediasend.MediaRepository import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.StorageUtil -import org.thoughtcrime.securesms.video.VideoUtil +import org.thoughtcrime.securesms.video.videoconverter.VideoConstants import java.io.FileDescriptor import java.io.FileInputStream import java.io.IOException @@ -66,7 +66,7 @@ class MediaCaptureRepository(context: Context) { dataSupplier = { FileInputStream(fileDescriptor) }, getLength = { it.channel.size() }, createBlobBuilder = BlobProvider::forData, - mimeType = VideoUtil.RECORDED_VIDEO_CONTENT_TYPE, + mimeType = VideoConstants.RECORDED_VIDEO_CONTENT_TYPE, width = 0, height = 0 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java b/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java index 6ebc7c2add..c7b2aed1fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java @@ -15,11 +15,13 @@ import org.signal.core.util.logging.Log; import org.signal.libsignal.media.Mp4Sanitizer; import org.signal.libsignal.media.ParseException; import org.signal.libsignal.media.SanitizedMetadata; -import org.thoughtcrime.securesms.media.MediaInput; import org.thoughtcrime.securesms.mms.MediaStream; import org.thoughtcrime.securesms.util.MemoryFileDescriptor; +import org.thoughtcrime.securesms.video.exceptions.VideoSizeException; +import org.thoughtcrime.securesms.video.exceptions.VideoSourceException; import org.thoughtcrime.securesms.video.videoconverter.EncodingException; import org.thoughtcrime.securesms.video.videoconverter.MediaConverter; +import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.MediaDataSourceMediaInput; import java.io.ByteArrayInputStream; import java.io.Closeable; @@ -122,7 +124,7 @@ public final class InMemoryTranscoder implements Closeable { final MediaConverter converter = new MediaConverter(); - converter.setInput(new MediaInput.MediaDataSourceMediaInput(dataSource)); + converter.setInput(new MediaDataSourceMediaInput(dataSource)); converter.setOutput(memoryFileFileDescriptor); converter.setVideoResolution(targetQuality.getOutputResolution()); converter.setVideoBitrate(targetQuality.getTargetVideoBitRate()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/ModernEncryptedMediaDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/video/ModernEncryptedMediaDataSource.java index 867e36d18f..74c3b3508c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/ModernEncryptedMediaDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/ModernEncryptedMediaDataSource.java @@ -8,6 +8,7 @@ import androidx.annotation.RequiresApi; import org.thoughtcrime.securesms.crypto.AttachmentSecret; import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; +import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.InputStreamMediaDataSource; import java.io.File; import java.io.IOException; @@ -22,7 +23,7 @@ import java.io.InputStream; * the presence of a random part of the key supplied in the constructor. */ @RequiresApi(23) -final class ModernEncryptedMediaDataSource extends MediaDataSource { +final class ModernEncryptedMediaDataSource extends InputStreamMediaDataSource { private final AttachmentSecret attachmentSecret; private final File mediaFile; @@ -37,44 +38,15 @@ final class ModernEncryptedMediaDataSource extends MediaDataSource { } @Override - public int readAt(long position, byte[] bytes, int offset, int length) throws IOException { - if (position >= this.length) { - return -1; - } - - try (InputStream inputStream = createInputStream(position)) { - int totalRead = 0; - - while (length > 0) { - int read = inputStream.read(bytes, offset, length); - - if (read == -1) { - if (totalRead == 0) { - return -1; - } else { - return totalRead; - } - } - - length -= read; - offset += read; - totalRead += read; - } - - return totalRead; - } - } + public void close() {} @Override public long getSize() { return length; } - @Override - public void close() { - } - - private InputStream createInputStream(long position) throws IOException { + @NonNull + public InputStream createInputStream(long position) throws IOException { if (random == null) { return ModernDecryptingPartInputStream.createFor(attachmentSecret, mediaFile, position); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/TranscoderCancelationSignal.java b/app/src/main/java/org/thoughtcrime/securesms/video/TranscoderCancelationSignal.java deleted file mode 100644 index 107005d761..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/video/TranscoderCancelationSignal.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.thoughtcrime.securesms.video; - -public interface TranscoderCancelationSignal { - boolean isCanceled(); -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/TranscoderOptions.java b/app/src/main/java/org/thoughtcrime/securesms/video/TranscoderOptions.java deleted file mode 100644 index 8378759f43..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/video/TranscoderOptions.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.thoughtcrime.securesms.video; - -public final class TranscoderOptions { - final long startTimeUs; - final long endTimeUs; - - public TranscoderOptions(long startTimeUs, long endTimeUs) { - this.startTimeUs = startTimeUs; - this.endTimeUs = endTimeUs; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoSizeException.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoSizeException.java deleted file mode 100644 index fd13bdf5ca..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoSizeException.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.thoughtcrime.securesms.video; - -import java.io.IOException; - -public final class VideoSizeException extends IOException { - - VideoSizeException(String message) { - super(message); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoSourceException.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoSourceException.java deleted file mode 100644 index 88d702f396..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoSourceException.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.thoughtcrime.securesms.video; - -public final class VideoSourceException extends Exception { - - VideoSourceException(String message) { - super(message); - } - - VideoSourceException(String message, Exception inner) { - super(message, inner); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java index ea8eaa114d..b98c9e1d13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java @@ -10,45 +10,29 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.video.videoconverter.VideoConstants; import java.util.concurrent.TimeUnit; public final class VideoUtil { - public static final int AUDIO_BIT_RATE = 192_000; - public static final int VIDEO_FRAME_RATE = 30; - public static final int VIDEO_BIT_RATE = 2_000_000; - - static final int VIDEO_SHORT_WIDTH = 720; - - private static final int VIDEO_LONG_WIDTH = 1280; - private static final int VIDEO_MAX_RECORD_LENGTH_S = 60; - private static final int VIDEO_MAX_UPLOAD_LENGTH_S = (int) TimeUnit.MINUTES.toSeconds(10); - - private static final int TOTAL_BYTES_PER_SECOND = (VIDEO_BIT_RATE / 8) + (AUDIO_BIT_RATE / 8); - - public static final String VIDEO_MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC; - public static final String AUDIO_MIME_TYPE = "audio/mp4a-latm"; - - public static final String RECORDED_VIDEO_CONTENT_TYPE = MediaUtil.VIDEO_MP4; - private VideoUtil() { } public static Size getVideoRecordingSize() { return isPortrait(screenSize()) - ? new Size(VIDEO_SHORT_WIDTH, VIDEO_LONG_WIDTH) - : new Size(VIDEO_LONG_WIDTH, VIDEO_SHORT_WIDTH); + ? new Size(VideoConstants.VIDEO_SHORT_EDGE, VideoConstants.VIDEO_LONG_EDGE) + : new Size(VideoConstants.VIDEO_LONG_EDGE, VideoConstants.VIDEO_SHORT_EDGE); } public static int getMaxVideoRecordDurationInSeconds(@NonNull Context context, @NonNull MediaConstraints mediaConstraints) { long allowedSize = mediaConstraints.getCompressedVideoMaxSize(context); - int duration = (int) Math.floor((float) allowedSize / TOTAL_BYTES_PER_SECOND); + int duration = (int) Math.floor((float) allowedSize / VideoConstants.TOTAL_BYTES_PER_SECOND); - return Math.min(duration, VIDEO_MAX_RECORD_LENGTH_S); + return Math.min(duration, VideoConstants.VIDEO_MAX_RECORD_LENGTH_S); } public static int getMaxVideoUploadDurationInSeconds() { - return VIDEO_MAX_UPLOAD_LENGTH_S; + return Math.toIntExact(TimeUnit.MINUTES.toSeconds(10)); } private static Size screenSize() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java index ac367a37f4..a646512b0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsView.java @@ -15,7 +15,6 @@ import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.media.MediaInput; import java.io.IOException; import java.lang.ref.WeakReference; diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 8306e20304..f03ee07ffd 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1228,6 +1228,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -1521,6 +1529,30 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + + + + + + + + + @@ -1568,6 +1600,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -1788,6 +1836,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + @@ -4459,6 +4523,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -4860,6 +4929,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4889,6 +4966,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4907,6 +4992,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -4931,6 +5024,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + diff --git a/video/app/build.gradle.kts b/video/app/build.gradle.kts index 0b85ca7d81..e6695a1175 100644 --- a/video/app/build.gradle.kts +++ b/video/app/build.gradle.kts @@ -20,7 +20,7 @@ android { defaultConfig { applicationId = "org.thoughtcrime.video.app" - minSdk = signalMinSdkVersion + minSdk = 23 targetSdk = signalTargetSdkVersion versionCode = 1 versionName = "1.0" @@ -63,6 +63,9 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.material3) implementation(libs.bundles.media3) + implementation(project(":video")) + implementation(project(":core-util")) + implementation("androidx.work:work-runtime-ktx:2.9.0") debugImplementation(libs.androidx.compose.ui.tooling.core) debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/video/app/src/main/AndroidManifest.xml b/video/app/src/main/AndroidManifest.xml index cde6d6b0b6..b2a2c0d8de 100644 --- a/video/app/src/main/AndroidManifest.xml +++ b/video/app/src/main/AndroidManifest.xml @@ -3,8 +3,9 @@ ~ SPDX-License-Identifier: AGPL-3.0-only --> - - + + @@ -30,6 +32,10 @@ android:name=".playback.PlaybackTestActivity" android:exported="false" android:theme="@style/Theme.Signal" /> + \ No newline at end of file diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/playback/PlaybackTestActivity.kt b/video/app/src/main/java/org/thoughtcrime/video/app/playback/PlaybackTestActivity.kt index 183858f5b4..00ebae2ede 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/playback/PlaybackTestActivity.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/playback/PlaybackTestActivity.kt @@ -76,11 +76,11 @@ class PlaybackTestActivity : AppCompatActivity() { */ private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> if (uri != null) { - Log.d("PhotoPicker", "Selected URI: $uri") + Log.d("PlaybackPicker", "Selected URI: $uri") viewModel.selectedVideo = uri viewModel.updateMediaSource(this) } else { - Log.d("PhotoPicker", "No media selected") + Log.d("PlaybackPicker", "No media selected") } } } diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TestTranscoder.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TestTranscoder.kt new file mode 100644 index 0000000000..0feb05c605 --- /dev/null +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TestTranscoder.kt @@ -0,0 +1,8 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.video.app.transcode + +class TestTranscoder diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeJobSnapshot.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeJobSnapshot.kt new file mode 100644 index 0000000000..0d974011ed --- /dev/null +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeJobSnapshot.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.video.app.transcode + +import android.net.Uri +import java.util.UUID + +data class TranscodeJobSnapshot(val media: Uri, val jobId: UUID) diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt index a88542d9a3..f3a150c213 100644 --- a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt @@ -1,10 +1,117 @@ /* * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only + * 2SPDX-License-Identifier: AGPL-3.0-only */ package org.thoughtcrime.video.app.transcode +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.view.ViewGroup +import androidx.activity.compose.setContent +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.thoughtcrime.video.app.ui.composables.LabeledButton +import org.thoughtcrime.video.app.ui.theme.SignalTheme -class TranscodeTestActivity : AppCompatActivity() +class TranscodeTestActivity : AppCompatActivity() { + private val viewModel: TranscodeTestViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.initialize(this) + setContent { + SignalTheme { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + val videoUris = viewModel.selectedVideos + val outputDir = viewModel.outputDirectory + val transcodingJobs = viewModel.getTranscodingJobsAsState().collectAsState(emptyList()) + if (transcodingJobs.value.isNotEmpty()) { + transcodingJobs.value.forEach { workInfo -> + val currentProgress = workInfo.progress.getInt(TranscodeWorker.KEY_PROGRESS, -1) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text(text = "...${workInfo.id.toString().takeLast(4)}", modifier = Modifier.padding(end = 16.dp).weight(1f)) + if (workInfo.state.isFinished) { + LinearProgressIndicator(progress = 1f, trackColor = MaterialTheme.colorScheme.secondary, modifier = Modifier.weight(3f)) + } else if (currentProgress >= 0) { + LinearProgressIndicator(progress = currentProgress / 100f, modifier = Modifier.weight(3f)) + } else { + LinearProgressIndicator(modifier = Modifier.weight(3f)) + } + } + } + LabeledButton("Reset/Cancel") { viewModel.reset() } + } else if (videoUris.isEmpty()) { + LabeledButton("Select Videos") { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) } + } else if (outputDir == null) { + LabeledButton("Select Output Directory") { outputDirRequest.launch(null) } + } else { + Text(text = "Selected videos:", modifier = Modifier.align(Alignment.Start).padding(16.dp)) + videoUris.forEach { + Text(text = it.toString(), fontSize = 8.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.align(Alignment.Start).padding(horizontal = 16.dp)) + } + LabeledButton(buttonLabel = "Transcode") { + viewModel.transcode() + viewModel.selectedVideos = emptyList() + viewModel.resetOutputDirectory() + } + } + } + } + } + } + getComposeView()?.keepScreenOn = true + } + + /** + * This launches the system media picker and stores the resulting URI. + */ + private val pickMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris: List -> + if (uris.isNotEmpty()) { + Log.d("VideoPicker", "Selected URI: $uris") + viewModel.selectedVideos = uris + viewModel.resetOutputDirectory() + } else { + Log.d("VideoPicker", "No media selected") + } + } + + private val outputDirRequest = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + uri?.let { + contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + viewModel.setOutputDirectoryAndCleanFailedTranscodes(this, it) + } + } + + private fun getComposeView(): ComposeView? { + return window.decorView + .findViewById(android.R.id.content) + .getChildAt(0) as? ComposeView + } +} diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt new file mode 100644 index 0000000000..fe3b710281 --- /dev/null +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.video.app.transcode + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import androidx.work.Data +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import org.signal.core.util.readToList +import java.util.UUID +import kotlin.math.absoluteValue +import kotlin.random.Random + +class TranscodeTestRepository(context: Context) { + private val workManager = WorkManager.getInstance(context) + private val usedNotificationIds = emptySet() + + fun transcode(selectedVideos: List, outputDirectory: Uri): Map { + if (selectedVideos.isEmpty()) { + return emptyMap() + } + + val urisAndRequests = selectedVideos.map { + var notificationId = Random.nextInt().absoluteValue + while (usedNotificationIds.contains(notificationId)) { + notificationId = Random.nextInt().absoluteValue + } + val transcodeRequest = OneTimeWorkRequestBuilder() + .setInputData(createInputDataForWorkRequest(it, outputDirectory, notificationId)) + .addTag(TRANSCODING_WORK_TAG) + .build() + it to transcodeRequest + } + + val idsToUris = urisAndRequests.associateBy({ it.second.id }, { it.first }) + val requests = urisAndRequests.map { it.second } + var continuation = workManager.beginWith(requests.first()) + for (request in requests.drop(1)) { + continuation = continuation.then(request) + } + continuation.enqueue() + return idsToUris + } + + fun getTranscodingJobsAsFlow(jobIds: List): Flow> { + if (jobIds.isEmpty()) { + return emptyFlow() + } + return workManager.getWorkInfosFlow(WorkQuery.fromIds(jobIds)) + } + + /** + * Creates the input data bundle which includes the blur level to + * update the amount of blur to be applied and the Uri to operate on + * @return Data which contains the Image Uri as a String and blur level as an Integer + */ + private fun createInputDataForWorkRequest(selectedVideo: Uri, outputUri: Uri, notificationId: Int): Data { + return Data.Builder() + .putString(TranscodeWorker.KEY_INPUT_URI, selectedVideo.toString()) + .putString(TranscodeWorker.KEY_OUTPUT_URI, outputUri.toString()) + .putInt(TranscodeWorker.KEY_NOTIFICATION_ID, notificationId) + .build() + } + + fun cancelAllTranscodes() { + workManager.cancelAllWorkByTag(TRANSCODING_WORK_TAG) + workManager.pruneWork() + } + + fun cleanFailedTranscodes(context: Context, folderUri: Uri) { + val docs = queryChildDocuments(context, folderUri) + docs.filter { it.documentId.endsWith(".mp4") && it.size == 0L }.forEach { + val fileUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, it.documentId) + DocumentsContract.deleteDocument(context.contentResolver, fileUri) + } + } + + private fun queryChildDocuments(context: Context, folderUri: Uri): List { + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + folderUri, + DocumentsContract.getTreeDocumentId(folderUri) + ) + + context.contentResolver.query( + childrenUri, + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_SIZE), + null, + null, + null + ).use { cursor -> + if (cursor == null) { + return emptyList() + } + return cursor.readToList { + FileMetadata( + documentId = it.getString(0), + label = it.getString(1), + size = it.getLong(2) + ) + } + } + } + + private data class FileMetadata(val documentId: String, val label: String, val size: Long) + + companion object { + private const val TAG = "TranscodingTestRepository" + const val TRANSCODING_WORK_TAG = "transcoding" + } +} diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt new file mode 100644 index 0000000000..2bae6d444a --- /dev/null +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.video.app.transcode + +import android.content.Context +import android.net.Uri +import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.work.WorkInfo +import kotlinx.coroutines.flow.Flow +import java.util.UUID + +class TranscodeTestViewModel : ViewModel() { + private lateinit var repository: TranscodeTestRepository + private var backPressedRunnable = {} + private var transcodingJobs: Map = emptyMap() + var outputDirectory: Uri? by mutableStateOf(null) + private set + var selectedVideos: List by mutableStateOf(emptyList()) + + fun initialize(context: Context) { + repository = TranscodeTestRepository(context) + backPressedRunnable = { Toast.makeText(context, "Cancelling all transcoding jobs!", Toast.LENGTH_LONG).show() } + } + + fun transcode() { + val output = outputDirectory ?: throw IllegalStateException("No output directory selected!") + transcodingJobs = repository.transcode(selectedVideos, output) + } + + fun getTranscodingJobsAsState(): Flow> { + return repository.getTranscodingJobsAsFlow(transcodingJobs.keys.toList()) + } + + fun setOutputDirectoryAndCleanFailedTranscodes(context: Context, folderUri: Uri) { + outputDirectory = folderUri + repository.cleanFailedTranscodes(context, folderUri) + } + + fun getUriFromJobId(jobId: UUID): Uri? { + return transcodingJobs[jobId] + } + + fun reset() { + cancelAllTranscodes() + resetOutputDirectory() + selectedVideos = emptyList() + } + + fun cancelAllTranscodes() { + repository.cancelAllTranscodes() + transcodingJobs = emptyMap() + } + + fun resetOutputDirectory() { + outputDirectory = null + } +} diff --git a/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt new file mode 100644 index 0000000000..f96ede4039 --- /dev/null +++ b/video/app/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.video.app.transcode + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Context.NOTIFICATION_SERVICE +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.net.Uri +import android.os.Build +import android.os.ParcelFileDescriptor +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.documentfile.provider.DocumentFile +import androidx.media3.common.util.UnstableApi +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import org.signal.core.util.getLength +import org.thoughtcrime.securesms.video.StreamingTranscoder +import org.thoughtcrime.securesms.video.videoconverter.VideoConstants +import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.InputStreamMediaDataSource +import org.thoughtcrime.video.app.R +import java.io.FileOutputStream +import java.io.InputStream +import java.time.Instant + +class TranscodeWorker(private val ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { + @UnstableApi + override suspend fun doWork(): Result { + val notificationId = inputData.getInt(KEY_NOTIFICATION_ID, -1) + if (notificationId < 0) { + Log.w(TAG, "Notification ID was null!") + return Result.failure() + } + + val inputUri = inputData.getString(KEY_INPUT_URI) + if (inputUri == null) { + Log.w(TAG, "Input URI was null!") + return Result.failure() + } + + val outputDirUri = inputData.getString(KEY_OUTPUT_URI) + if (outputDirUri == null) { + Log.w(TAG, "Output URI was null!") + return Result.failure() + } + + val input = DocumentFile.fromSingleUri(ctx, Uri.parse(inputUri))?.name + + if (input == null) { + Log.w(TAG, "Could not read input file name!") + return Result.failure() + } + + val outputFileUri = createFile(Uri.parse(outputDirUri), "transcoded-${Instant.now()}-$input$OUTPUT_FILE_EXTENSION") + + if (outputFileUri == null) { + Log.w(TAG, "Could not create output file!") + return Result.failure() + } + + val datasource = WorkerMediaDataSource(ctx, Uri.parse(inputUri)) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + Log.w(TAG, "Transcoder is only supported on API 26+!") + return Result.failure() + } + val transcoder = StreamingTranscoder(datasource, null, 50 * 1024 * 1024) // TODO: set options + setForeground(createForegroundInfo(-1, notificationId)) + ctx.contentResolver.openFileDescriptor(outputFileUri, "w").use { it: ParcelFileDescriptor? -> + if (it == null) { + Log.w(TAG, "Could not open output file for writing!") + return Result.failure() + } + transcoder.transcode( + { percent: Int -> + setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, percent).build()) + setForegroundAsync(createForegroundInfo(percent, notificationId)) + }, + FileOutputStream(it.fileDescriptor), + { isStopped } + ) + return Result.success() + } + } + + private fun createForegroundInfo(progress: Int, notificationId: Int): ForegroundInfo { + val id = applicationContext.getString(R.string.notification_channel_id) + val title = applicationContext.getString(R.string.notification_title) + val cancel = applicationContext.getString(R.string.cancel_transcode) + val intent = WorkManager.getInstance(applicationContext) + .createCancelPendingIntent(getId()) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = applicationContext.getString(R.string.channel_name) + val descriptionText = applicationContext.getString(R.string.channel_description) + val importance = NotificationManager.IMPORTANCE_LOW + val mChannel = NotificationChannel(id, name, importance) + mChannel.description = descriptionText + val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(mChannel) + } + + val notification = NotificationCompat.Builder(applicationContext, id) + .setContentTitle(title) + .setTicker(title) + .setProgress(100, progress, progress >= 0) + .setSmallIcon(R.drawable.ic_work_notification) + .setOngoing(true) + .addAction(android.R.drawable.ic_delete, cancel, intent) + .build() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(notificationId, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(notificationId, notification) + } + } + + private fun createFile(treeUri: Uri, filename: String): Uri? { + return DocumentFile.fromTreeUri(ctx, treeUri)?.createFile(VideoConstants.VIDEO_MIME_TYPE, filename)?.uri + } + + private class WorkerMediaDataSource(context: Context, private val uri: Uri) : InputStreamMediaDataSource() { + + private val contentResolver = context.contentResolver + private val size = contentResolver.getLength(uri) ?: throw IllegalStateException() + + private var inputStream: InputStream? = null + + override fun close() { + inputStream?.close() + } + + override fun getSize(): Long { + return size + } + + override fun createInputStream(position: Long): InputStream { + inputStream?.close() + val openedInputStream = contentResolver.openInputStream(uri) ?: throw IllegalStateException() + openedInputStream.skip(position) + inputStream = openedInputStream + return openedInputStream + } + } + + companion object { + private const val TAG = "TranscodeWorker" + private const val OUTPUT_FILE_EXTENSION = ".mp4" + const val KEY_INPUT_URI = "input_uri" + const val KEY_OUTPUT_URI = "output_uri" + const val KEY_PROGRESS = "progress" + const val KEY_NOTIFICATION_ID = "notification_id" + } +} diff --git a/video/app/src/main/res/drawable-anydpi-v24/ic_work_notification.xml b/video/app/src/main/res/drawable-anydpi-v24/ic_work_notification.xml new file mode 100644 index 0000000000..6f92d40c03 --- /dev/null +++ b/video/app/src/main/res/drawable-anydpi-v24/ic_work_notification.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/video/app/src/main/res/drawable-hdpi/ic_work_notification.png b/video/app/src/main/res/drawable-hdpi/ic_work_notification.png new file mode 100644 index 0000000000..8e46a11c9d Binary files /dev/null and b/video/app/src/main/res/drawable-hdpi/ic_work_notification.png differ diff --git a/video/app/src/main/res/drawable-mdpi/ic_work_notification.png b/video/app/src/main/res/drawable-mdpi/ic_work_notification.png new file mode 100644 index 0000000000..32067e0c27 Binary files /dev/null and b/video/app/src/main/res/drawable-mdpi/ic_work_notification.png differ diff --git a/video/app/src/main/res/drawable-xhdpi/ic_work_notification.png b/video/app/src/main/res/drawable-xhdpi/ic_work_notification.png new file mode 100644 index 0000000000..bc21cccd24 Binary files /dev/null and b/video/app/src/main/res/drawable-xhdpi/ic_work_notification.png differ diff --git a/video/app/src/main/res/drawable-xxhdpi/ic_work_notification.png b/video/app/src/main/res/drawable-xxhdpi/ic_work_notification.png new file mode 100644 index 0000000000..a0753ae4c2 Binary files /dev/null and b/video/app/src/main/res/drawable-xxhdpi/ic_work_notification.png differ diff --git a/video/app/src/main/res/values/strings.xml b/video/app/src/main/res/values/strings.xml index ffbef093bf..6534313baf 100644 --- a/video/app/src/main/res/values/strings.xml +++ b/video/app/src/main/res/values/strings.xml @@ -5,4 +5,9 @@ Video Framework Tester + transcode-progress + Encoding video… + Cancel + Transcoding progress updates. + Persistent notifications that allow the transcode job to complete when the app is in the background. \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java b/video/lib/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java similarity index 96% rename from app/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java rename to video/lib/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java index 8f1690648f..1b752d090f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/StreamingTranscoder.java @@ -8,9 +8,11 @@ import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.media.MediaInput; +import org.thoughtcrime.securesms.video.exceptions.VideoSizeException; +import org.thoughtcrime.securesms.video.exceptions.VideoSourceException; import org.thoughtcrime.securesms.video.videoconverter.EncodingException; import org.thoughtcrime.securesms.video.videoconverter.MediaConverter; +import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.MediaDataSourceMediaInput; import java.io.FilterOutputStream; import java.io.IOException; @@ -103,7 +105,7 @@ public final class StreamingTranscoder { final MediaConverter converter = new MediaConverter(); final LimitedSizeOutputStream limitedSizeOutputStream = new LimitedSizeOutputStream(stream, upperSizeLimit); - converter.setInput(new MediaInput.MediaDataSourceMediaInput(dataSource)); + converter.setInput(new MediaDataSourceMediaInput(dataSource)); converter.setOutput(limitedSizeOutputStream); converter.setVideoResolution(targetQuality.getOutputResolution()); converter.setVideoBitrate(targetQuality.getTargetVideoBitRate()); diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/TranscoderCancelationSignal.kt b/video/lib/src/main/java/org/thoughtcrime/securesms/video/TranscoderCancelationSignal.kt new file mode 100644 index 0000000000..96bdd3c5d9 --- /dev/null +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/TranscoderCancelationSignal.kt @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.video + +fun interface TranscoderCancelationSignal { + fun isCanceled(): Boolean +} diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/TranscoderOptions.kt b/video/lib/src/main/java/org/thoughtcrime/securesms/video/TranscoderOptions.kt new file mode 100644 index 0000000000..9b761829a1 --- /dev/null +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/TranscoderOptions.kt @@ -0,0 +1,3 @@ +package org.thoughtcrime.securesms.video + +data class TranscoderOptions(@JvmField val startTimeUs: Long, @JvmField val endTimeUs: Long) diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java b/video/lib/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java similarity index 90% rename from app/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java rename to video/lib/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java index 47fe76fea3..a7304de8ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java @@ -1,15 +1,17 @@ package org.thoughtcrime.securesms.video; +import org.thoughtcrime.securesms.video.videoconverter.VideoConstants; + /** * Calculates a target quality output for a video to fit within a specified size. */ public final class VideoBitRateCalculator { - private static final int MAXIMUM_TARGET_VIDEO_BITRATE = VideoUtil.VIDEO_BIT_RATE; + private static final int MAXIMUM_TARGET_VIDEO_BITRATE = VideoConstants.VIDEO_BIT_RATE; private static final int LOW_RES_TARGET_VIDEO_BITRATE = 1_750_000; private static final int MINIMUM_TARGET_VIDEO_BITRATE = 500_000; - private static final int AUDIO_BITRATE = VideoUtil.AUDIO_BIT_RATE; - private static final int OUTPUT_FORMAT = VideoUtil.VIDEO_SHORT_WIDTH; + private static final int AUDIO_BITRATE = VideoConstants.AUDIO_BIT_RATE; + private static final int OUTPUT_FORMAT = VideoConstants.VIDEO_SHORT_EDGE; private static final int LOW_RES_OUTPUT_FORMAT = 480; private final long upperFileSizeLimitWithMargin; diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/exceptions/VideoSizeException.kt b/video/lib/src/main/java/org/thoughtcrime/securesms/video/exceptions/VideoSizeException.kt new file mode 100644 index 0000000000..068ba7dbaf --- /dev/null +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/exceptions/VideoSizeException.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.thoughtcrime.securesms.video.exceptions + +import java.io.IOException + +/** + * Exception to denote when video processing has been unable to meet its output file size requirements. + */ +class VideoSizeException internal constructor(message: String?) : IOException(message) diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/exceptions/VideoSourceException.kt b/video/lib/src/main/java/org/thoughtcrime/securesms/video/exceptions/VideoSourceException.kt new file mode 100644 index 0000000000..3c7226a68a --- /dev/null +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/exceptions/VideoSourceException.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.thoughtcrime.securesms.video.exceptions + +/** + * Exception to denote when video processing has had an issue with its source input. + */ +class VideoSourceException : Exception { + internal constructor(message: String?) : super(message) + internal constructor(message: String?, inner: Exception?) : super(message, inner) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/AudioTrackConverter.java b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/AudioTrackConverter.java similarity index 96% rename from app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/AudioTrackConverter.java rename to video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/AudioTrackConverter.java index 5b348ca3b2..38b97a4fe8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/AudioTrackConverter.java +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/AudioTrackConverter.java @@ -9,8 +9,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.media.MediaInput; -import org.thoughtcrime.securesms.video.VideoUtil; import java.io.FileNotFoundException; import java.io.IOException; @@ -22,7 +20,7 @@ final class AudioTrackConverter { private static final String TAG = "media-converter"; private static final boolean VERBOSE = false; // lots of logging - private static final String OUTPUT_AUDIO_MIME_TYPE = VideoUtil.AUDIO_MIME_TYPE; // Advanced Audio Coding + private static final String OUTPUT_AUDIO_MIME_TYPE = VideoConstants.AUDIO_MIME_TYPE; // Advanced Audio Coding private static final int OUTPUT_AUDIO_AAC_PROFILE = MediaCodecInfo.CodecProfileLevel.AACObjectLC; //MediaCodecInfo.CodecProfileLevel.AACObjectHE; private static final int TIMEOUT_USEC = 10000; diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java similarity index 99% rename from app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java rename to video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java index 1c022dca1d..861ec2e6df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaConverter.java @@ -29,7 +29,6 @@ import androidx.annotation.StringDef; import androidx.annotation.WorkerThread; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.media.MediaInput; import org.thoughtcrime.securesms.video.videoconverter.muxer.StreamingMuxer; import java.io.File; diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaInput.kt b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaInput.kt new file mode 100644 index 0000000000..1cd0cc4ada --- /dev/null +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/MediaInput.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.video.videoconverter + +import android.media.MediaExtractor +import java.io.Closeable +import java.io.IOException + +/** + * Abstraction over the different sources of media input for transcoding. + */ +interface MediaInput : Closeable { + @Throws(IOException::class) + fun createExtractor(): MediaExtractor +} diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoConstants.kt b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoConstants.kt new file mode 100644 index 0000000000..ce40041b5a --- /dev/null +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoConstants.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.video.videoconverter + +import android.media.MediaFormat +object VideoConstants { + const val AUDIO_BIT_RATE = 192000 + const val VIDEO_FRAME_RATE = 30 + const val VIDEO_BIT_RATE = 2000000 + const val VIDEO_SHORT_EDGE = 720 + const val VIDEO_LONG_EDGE = 1280 + const val VIDEO_MAX_RECORD_LENGTH_S = 60 + const val TOTAL_BYTES_PER_SECOND = VIDEO_BIT_RATE / 8 + AUDIO_BIT_RATE / 8 + const val VIDEO_MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC + const val AUDIO_MIME_TYPE = MediaFormat.MIMETYPE_AUDIO_AAC + const val RECORDED_VIDEO_CONTENT_TYPE: String = "video/mp4" +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsExtractor.java b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsExtractor.java similarity index 93% rename from app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsExtractor.java rename to video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsExtractor.java index a5187cea80..c9a458c142 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsExtractor.java +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsExtractor.java @@ -10,7 +10,6 @@ import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.media.MediaInput; import java.nio.ByteBuffer; import java.nio.ByteOrder; @@ -40,7 +39,8 @@ final class VideoThumbnailsExtractor { extractor = input.createExtractor(); MediaFormat mediaFormat = null; for (int index = 0; index < extractor.getTrackCount(); ++index) { - if (extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME).startsWith("video/")) { + final String mimeType = extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME); + if (mimeType != null && mimeType.startsWith("video/")) { extractor.selectTrack(index); mediaFormat = extractor.getTrackFormat(index); break; @@ -48,6 +48,10 @@ final class VideoThumbnailsExtractor { } if (mediaFormat != null) { final String mime = mediaFormat.getString(MediaFormat.KEY_MIME); + if (mime == null) { + throw new IllegalArgumentException("Mime type for MediaFormat was null: \t" + mediaFormat); + } + final int rotation = mediaFormat.containsKey(MediaFormat.KEY_ROTATION) ? mediaFormat.getInteger(MediaFormat.KEY_ROTATION) : 0; final int width = mediaFormat.getInteger(MediaFormat.KEY_WIDTH); final int height = mediaFormat.getInteger(MediaFormat.KEY_HEIGHT); diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoTrackConverter.java b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoTrackConverter.java similarity index 97% rename from app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoTrackConverter.java rename to video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoTrackConverter.java index f9647441dd..88374a191a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoTrackConverter.java +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoTrackConverter.java @@ -11,7 +11,6 @@ import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.media.MediaInput; import java.io.FileNotFoundException; import java.io.IOException; diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/mediadatasource/InputStreamMediaDataSource.kt b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/mediadatasource/InputStreamMediaDataSource.kt new file mode 100644 index 0000000000..db62e28c1b --- /dev/null +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/mediadatasource/InputStreamMediaDataSource.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.video.videoconverter.mediadatasource + +import android.media.MediaDataSource +import androidx.annotation.RequiresApi +import java.io.IOException +import java.io.InputStream + +/** + * Extend this class in order to be able to use the system media framework with any arbitrary [InputStream] of bytes. + */ +@RequiresApi(23) +abstract class InputStreamMediaDataSource : MediaDataSource() { + @Throws(IOException::class) + override fun readAt(position: Long, bytes: ByteArray?, offset: Int, length: Int): Int { + if (position >= size) { + return -1 + } + + createInputStream(position).use { inputStream -> + var totalRead = 0 + while (totalRead < length) { + val read: Int = inputStream.read(bytes, offset + totalRead, length - totalRead) + if (read == -1) { + return if (totalRead == 0) { + -1 + } else { + totalRead + } + } + totalRead += read + } + return totalRead + } + } + + abstract override fun close() + + abstract override fun getSize(): Long + + @Throws(IOException::class) + abstract fun createInputStream(position: Long): InputStream +} diff --git a/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/mediadatasource/MediaDataSourceMediaInput.kt b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/mediadatasource/MediaDataSourceMediaInput.kt new file mode 100644 index 0000000000..3a72fd58bc --- /dev/null +++ b/video/lib/src/main/java/org/thoughtcrime/securesms/video/videoconverter/mediadatasource/MediaDataSourceMediaInput.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.video.videoconverter.mediadatasource + +import android.media.MediaDataSource +import android.media.MediaExtractor +import androidx.annotation.RequiresApi +import org.thoughtcrime.securesms.video.videoconverter.MediaInput +import java.io.IOException + +/** + * [MediaInput] implementation that adds support for the system framework's media data source. + */ +@RequiresApi(23) +class MediaDataSourceMediaInput(private val mediaDataSource: MediaDataSource) : MediaInput { + @Throws(IOException::class) + override fun createExtractor(): MediaExtractor { + return MediaExtractor().apply { + setDataSource(mediaDataSource) + } + } + + @Throws(IOException::class) + override fun close() { + mediaDataSource.close() + } +}