Add fixes for streamable videos.

This commit is contained in:
Michelle Tang
2025-04-24 15:48:51 -04:00
committed by Cody Henthorne
parent 3aefd3bdc6
commit 7043558657
5 changed files with 218 additions and 4 deletions

View File

@@ -19,6 +19,7 @@ package org.thoughtcrime.securesms.video;
import android.content.Context; import android.content.Context;
import android.content.res.TypedArray; import android.content.res.TypedArray;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.view.View;
import android.view.Window; import android.view.Window;
import android.view.WindowManager; import android.view.WindowManager;
import android.widget.FrameLayout; import android.widget.FrameLayout;
@@ -55,6 +56,7 @@ public class VideoPlayer extends FrameLayout {
private static final String TAG = Log.tag(VideoPlayer.class); private static final String TAG = Log.tag(VideoPlayer.class);
private final PlayerView exoView; private final PlayerView exoView;
private final View progressBar;
private final DefaultMediaSourceFactory mediaSourceFactory; private final DefaultMediaSourceFactory mediaSourceFactory;
private ExoPlayer exoPlayer; private ExoPlayer exoPlayer;
@@ -89,6 +91,7 @@ public class VideoPlayer extends FrameLayout {
this.mediaSourceFactory = new DefaultMediaSourceFactory(context); this.mediaSourceFactory = new DefaultMediaSourceFactory(context);
this.exoView = findViewById(R.id.video_view); this.exoView = findViewById(R.id.video_view);
this.progressBar = findViewById(R.id.progress_bar);
this.exoControls = createPlayerControls(getContext()); this.exoControls = createPlayerControls(getContext());
this.exoPlayerListener = new ExoPlayerListener(); this.exoPlayerListener = new ExoPlayerListener();
@@ -113,6 +116,13 @@ public class VideoPlayer extends FrameLayout {
} }
private void onPlaybackStateChanged(boolean playWhenReady, int playbackState) { private void onPlaybackStateChanged(boolean playWhenReady, int playbackState) {
if (progressBar != null) {
if (playbackState == Player.STATE_BUFFERING) {
progressBar.setVisibility(View.VISIBLE);
} else {
progressBar.setVisibility(View.GONE);
}
}
if (playerCallback != null) { if (playerCallback != null) {
switch (playbackState) { switch (playbackState) {
case Player.STATE_READY: case Player.STATE_READY:

View File

@@ -14,22 +14,22 @@ import androidx.media3.datasource.TransferListener;
import org.signal.core.util.logging.Log; import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.InvalidMessageException; import org.signal.libsignal.protocol.InvalidMessageException;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.backup.v2.DatabaseAttachmentArchiveUtil; import org.thoughtcrime.securesms.backup.v2.DatabaseAttachmentArchiveUtil;
import org.thoughtcrime.securesms.database.AttachmentTable; import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.PartUriParser; import org.thoughtcrime.securesms.mms.PartUriParser;
import org.signal.core.util.Base64; import org.signal.core.util.Base64;
import org.whispersystems.signalservice.api.backup.MediaId;
import org.whispersystems.signalservice.api.backup.MediaName; import org.whispersystems.signalservice.api.backup.MediaName;
import org.whispersystems.signalservice.api.backup.MediaRootBackupKey; import org.whispersystems.signalservice.api.backup.MediaRootBackupKey;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream; import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil; import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.signal.core.util.stream.TailerInputStream;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream; import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
import java.io.EOFException; import java.io.EOFException;
import java.io.File; import java.io.File;
import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.Collections; import java.util.Collections;
@@ -88,14 +88,16 @@ class PartDataSource implements DataSource {
} else { } else {
final File transferFile = attachmentDatabase.getOrCreateTransferFile(attachment.attachmentId); final File transferFile = attachmentDatabase.getOrCreateTransferFile(attachment.attachmentId);
try { try {
this.inputStream = AttachmentCipherInputStream.createForAttachment(transferFile, attachment.size, decode, attachment.remoteDigest, attachment.getIncrementalDigest(), attachment.incrementalMacChunkSize); long streamLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(attachment.size));
AttachmentCipherInputStream.StreamSupplier streamSupplier = () -> new TailerInputStream(() -> new FileInputStream(transferFile), streamLength);
this.inputStream = AttachmentCipherInputStream.createForAttachment(streamSupplier, streamLength, attachment.size, decode, attachment.remoteDigest, attachment.getIncrementalDigest(), attachment.incrementalMacChunkSize, false);
} catch (InvalidMessageException e) { } catch (InvalidMessageException e) {
throw new IOException("Error decrypting attachment stream!", e); throw new IOException("Error decrypting attachment stream!", e);
} }
} }
long skipped = 0; long skipped = 0;
while (skipped < dataSpec.position) { while (skipped < dataSpec.position) {
skipped += this.inputStream.read(); skipped += this.inputStream.skip(dataSpec.position - skipped);
} }
Log.d(TAG, "Successfully loaded partial attachment file."); Log.d(TAG, "Successfully loaded partial attachment file.");

View File

@@ -17,4 +17,13 @@
app:surface_type="texture_view" app:surface_type="texture_view"
app:player_layout_id="@layout/media_preview_exoplayer_layout"/> app:player_layout_id="@layout/media_preview_exoplayer_layout"/>
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
style="?android:attr/progressBarStyleLarge"
android:indeterminateTint="@color/signal_colorOnSurfaceVariant"
android:indeterminate="true" />
</FrameLayout> </FrameLayout>

View File

@@ -0,0 +1,87 @@
package org.signal.core.util.stream
import org.signal.core.util.logging.Log
import java.io.FilterInputStream
import java.io.IOException
import java.io.InputStream
/**
* Input stream that reads a file that is actively being written to.
* Will read or wait to read (for the bytes to be available) until it reaches the end [bytesLength]
* A use case is streamable video where we want to play the video while the file is still downloading
*/
class TailerInputStream(private val streamFactory: StreamFactory, private val bytesLength: Long) : FilterInputStream(streamFactory.openStream()) {
private val TAG = Log.tag(TailerInputStream::class)
/** Tracks where we are in the file */
private var position: Long = 0
private var currentStream: InputStream
get() = this.`in`
set(input) {
this.`in` = input
}
override fun skip(requestedSkipCount: Long): Long {
val bytesSkipped = this.currentStream.skip(requestedSkipCount)
this.position += bytesSkipped
return bytesSkipped
}
override fun read(): Int {
val bytes = ByteArray(1)
var result = this.read(bytes)
while (result == 0) {
result = this.read(bytes)
}
if (result == -1) {
return result
}
return bytes[0].toInt() and 0xFF
}
override fun read(destination: ByteArray): Int {
return this.read(destination = destination, offset = 0, length = destination.size)
}
override fun read(destination: ByteArray, offset: Int, length: Int): Int {
// Checking if we reached the end of the file (bytesLength)
if (position >= bytesLength) {
return -1
}
var bytesRead = this.currentStream.read(destination, offset, length)
// If we haven't read any bytes, but we aren't at the end of the file,
// we close the stream, wait, and then try again
while (bytesRead < 0 && position < bytesLength) {
this.currentStream.close()
try {
Thread.sleep(100)
} catch (e: InterruptedException) {
Log.w(TAG, "Ignoring interrupted exception while waiting for input stream", e)
}
this.currentStream = streamFactory.openStream()
// After reopening the file, we skip to the position we were at last time
this.currentStream.skip(this.position)
bytesRead = this.currentStream.read(destination, offset, length)
}
// Update current position with bytes read
if (bytesRead > 0) {
position += bytesRead
}
return bytesRead
}
}
fun interface StreamFactory {
@Throws(IOException::class)
fun openStream(): InputStream
}

View File

@@ -0,0 +1,106 @@
package org.signal.core.util.stream
import org.junit.Assert.assertEquals
import org.junit.Test
import org.signal.core.util.readFully
class TailerInputStreamTest {
@Test
fun `when I provide an incomplete stream and a known bytesLength, I can read the stream until bytesLength is reached`() {
var currentBytesLength = 0
val inputStream = TailerInputStream(
streamFactory = {
currentBytesLength += 10
ByteArray(currentBytesLength).inputStream()
},
bytesLength = 50
)
val data = inputStream.readFully()
assertEquals(50, data.size)
}
@Test
fun `when I provide an incomplete stream and a known bytesLength, I can read the stream one byte at a time until bytesLength is reached`() {
var currentBytesLength = 0
val inputStream = TailerInputStream(
streamFactory = {
currentBytesLength += 10
ByteArray(currentBytesLength).inputStream()
},
bytesLength = 20
)
var count = 0
var lastRead = inputStream.read()
while (lastRead != -1) {
count++
lastRead = inputStream.read()
}
assertEquals(20, count)
}
@Test
fun `when I provide a complete stream and a known bytesLength, I can read the stream until bytesLength is reached`() {
val inputStream = TailerInputStream(
streamFactory = { ByteArray(50).inputStream() },
bytesLength = 50
)
val data = inputStream.readFully()
assertEquals(50, data.size)
}
@Test
fun `when I provide a complete stream and a known bytesLength, I can read the stream one byte at a time until bytesLength is reached`() {
val inputStream = TailerInputStream(
streamFactory = { ByteArray(20).inputStream() },
bytesLength = 20
)
var count = 0
var lastRead = inputStream.read()
while (lastRead != -1) {
count++
lastRead = inputStream.read()
}
assertEquals(20, count)
}
@Test
fun `when I skip bytes, I still read until the end of bytesLength`() {
var currentBytesLength = 0
val inputStream = TailerInputStream(
streamFactory = {
currentBytesLength += 10
ByteArray(currentBytesLength).inputStream()
},
bytesLength = 50
)
inputStream.skip(5)
val data = inputStream.readFully()
assertEquals(45, data.size)
}
@Test
fun `when I skip more bytes than available, I can still read until the end of bytesLength`() {
var currentBytesLength = 0
val inputStream = TailerInputStream(
streamFactory = {
currentBytesLength += 10
ByteArray(currentBytesLength).inputStream()
},
bytesLength = 50
)
inputStream.skip(15)
val data = inputStream.readFully()
assertEquals(40, data.size)
}
}