From d40b4382e9ff06fd48440805aa4d406a8d3b8d16 Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Wed, 5 Aug 2026 16:37:25 -0400 Subject: [PATCH] Support streamable stories. --- .../viewer/page/StoryViewerPageFragment.kt | 20 +++++++++++++----- .../securesms/util/MediaUtil.java | 11 ++++++---- .../main/res/layout/story_video_player.xml | 9 ++++++++ .../transfercontrols/TransferControlsTest.kt | 4 ++-- .../api/crypto/BetterCipherInputStream.kt | 21 +++++++++++++++++++ 5 files changed, 54 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index 542865d009..7ee300adb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -98,6 +98,7 @@ import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.LinkUtil import org.thoughtcrime.securesms.util.LongClickCopySpan import org.thoughtcrime.securesms.util.LongClickMovementMethod +import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.Projection import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.fragments.requireListener @@ -850,7 +851,7 @@ class StoryViewerPageFragment : private fun markViewedIfAble() { val post = viewModel.getPost() ?: return - if (post.content.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) { + if (post.content.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || post.content.transferState == AttachmentTable.TRANSFER_PROGRESS_STARTED) { if (isResumed) { viewModel.markViewed(post) } @@ -895,6 +896,7 @@ class StoryViewerPageFragment : when (post.content.transferState) { AttachmentTable.TRANSFER_PROGRESS_DONE -> { + Log.d(TAG, "Story content download is done.") storySlate.moveToState(StorySlateView.State.HIDDEN, post.id) viewModel.setIsDisplayingSlate(false) markViewedIfAble() @@ -908,10 +910,18 @@ class StoryViewerPageFragment : } AttachmentTable.TRANSFER_PROGRESS_STARTED -> { - Log.d(TAG, "Story content download is in progress.") - storySlate.moveToState(StorySlateView.State.LOADING, post.id) - sharedViewModel.setContentIsReady() - viewModel.setIsDisplayingSlate(true) + val isStreamable = post.content is StoryPost.Content.AttachmentContent && MediaUtil.isInstantVideoSupported(post.content.attachment) + if (isStreamable) { + Log.d(TAG, "Story content is streamable while download is in progress.") + storySlate.moveToState(StorySlateView.State.HIDDEN, post.id) + viewModel.setIsDisplayingSlate(false) + markViewedIfAble() + } else { + Log.d(TAG, "Story content download is in progress.") + storySlate.moveToState(StorySlateView.State.LOADING, post.id) + sharedViewModel.setContentIsReady() + viewModel.setIsDisplayingSlate(true) + } } AttachmentTable.TRANSFER_PROGRESS_FAILED -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java index 075ea997c3..0ba41b2da7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java @@ -554,10 +554,13 @@ public class MediaUtil { } public static boolean isInstantVideoSupported(Slide slide) { - final Attachment attachment = slide.asAttachment(); - final boolean isIncremental = attachment.getIncrementalDigest() != null; - final boolean hasIncrementalMacChunkSizeDefined = attachment.incrementalMacChunkSize > 0; - final boolean contentTypeSupported = isVideoType(slide.getContentType()); + return isInstantVideoSupported(slide.asAttachment()); + } + + public static boolean isInstantVideoSupported(Attachment attachment) { + final boolean isIncremental = attachment.getIncrementalDigest() != null; + final boolean hasIncrementalMacChunkSizeDefined = attachment.incrementalMacChunkSize > 0; + final boolean contentTypeSupported = isVideoType(attachment.contentType); return isIncremental && contentTypeSupported && hasIncrementalMacChunkSizeDefined; } diff --git a/app/src/main/res/layout/story_video_player.xml b/app/src/main/res/layout/story_video_player.xml index 846c916a77..6eb46c038a 100644 --- a/app/src/main/res/layout/story_video_player.xml +++ b/app/src/main/res/layout/story_video_player.xml @@ -17,4 +17,13 @@ app:shutter_background_color="@color/core_black" app:surface_type="texture_view" /> + + \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlsTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlsTest.kt index a3e3fd87a3..959ef2c7e0 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlsTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/transfercontrols/TransferControlsTest.kt @@ -35,7 +35,7 @@ class TransferControlsTest { @Before fun setUp() { mockkStatic(MediaUtil::class) - every { MediaUtil.isInstantVideoSupported(any()) } returns false + every { MediaUtil.isInstantVideoSupported(any() as Attachment) } returns false } @After @@ -185,7 +185,7 @@ class TransferControlsTest { @Test fun `gallery pending playable is corner without item count`() { - every { MediaUtil.isInstantVideoSupported(any()) } returns true + every { MediaUtil.isInstantVideoSupported(any() as Attachment) } returns true val state = stateOf(listOf(slide(AttachmentTable.TRANSFER_PROGRESS_PENDING), slide(AttachmentTable.TRANSFER_PROGRESS_PENDING))) val render = TransferControls.deriveRenderState(state) as TransferControlsRenderState.Pending assertEquals(TransferControls.Placement.CORNER, render.placement) diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/crypto/BetterCipherInputStream.kt b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/crypto/BetterCipherInputStream.kt index 1bfc15af3a..94a9d5ad84 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/crypto/BetterCipherInputStream.kt +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/crypto/BetterCipherInputStream.kt @@ -58,6 +58,27 @@ class BetterCipherInputStream( override fun markSupported(): Boolean = false + /** + * Since AES/CBC decryption is sequential, we can't just skip bytes on the underlying ciphertext stream + * so we must process every preceding ciphertext block to keep its internal chaining state correct. + */ + @Throws(IOException::class) + override fun skip(byteCount: Long): Long { + var totalSkipped = 0L + val buffer = ByteArray(4096) + + while (totalSkipped < byteCount) { + val toRead = min(buffer.size.toLong(), byteCount - totalSkipped).toInt() + val read = read(buffer, 0, toRead) + if (read == -1) { + break + } + totalSkipped += read + } + + return totalSkipped + } + @Throws(IOException::class) private fun readIncremental(outputBuffer: ByteArray, originalOffset: Int, originalLength: Int): Int { var offset = originalOffset