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()
+ }
+}