diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.kt index 9427d63beb..e92c3d3edc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewFragment.kt @@ -163,6 +163,7 @@ class MediaPreviewFragment : val startingAttachmentId = PartAuthority.requireAttachmentId(args.initialMediaUri) val threadId = args.threadId val appContext = requireContext().applicationContext + viewModel.fetchInitialAttachment(args) viewModel.fetchAttachments(appContext, startingAttachmentId, threadId, sorting) val dbObserver = DatabaseObserver.Observer { viewModel.refetchAttachments(appContext, startingAttachmentId, threadId, sorting) } AppDependencies.databaseObserver.registerAttachmentUpdatedObserver(dbObserver) @@ -229,26 +230,43 @@ class MediaPreviewFragment : } when (currentState.loadState) { MediaPreviewState.LoadState.DATA_LOADED -> bindDataLoadedState(currentState) - MediaPreviewState.LoadState.MEDIA_READY -> bindMediaReadyState(currentState) + MediaPreviewState.LoadState.MEDIA_READY -> { + // The full attachment window can arrive after the initially-opened media has already decoded. + if (syncPagerItems(currentState)) { + bindMediaReadyState(currentState) + } + } else -> Unit } } - private fun bindDataLoadedState(currentState: MediaPreviewState) { + /** + * Pushes the current record set into the pager. Both calls are self-guarded, so this is a no-op unless the + * backing items or the selected position actually changed. + */ + private fun syncPagerItems(currentState: MediaPreviewState): Boolean { val currentPosition = currentState.position - val backingItems = currentState.mediaRecords.mapNotNull { it.attachment } if (backingItems.isEmpty() || currentPosition < 0) { onMediaNotAvailable() - return + return false } + pagerAdapter.updateBackingItems(backingItems) if (binding.mediaPager.currentItem != currentPosition) { binding.mediaPager.setCurrentItem(currentPosition, false) } - val currentItem: MediaTable.MediaRecord = currentState.mediaRecords[currentPosition] + return true + } + + private fun bindDataLoadedState(currentState: MediaPreviewState) { + if (!syncPagerItems(currentState)) { + return + } + + val currentItem: MediaTable.MediaRecord = currentState.mediaRecords[currentState.position] bindTextViews(currentItem, currentState.showThread, currentState.messageBodies) bindMenuItems(currentItem) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt index a2492387d4..a1fb110c8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt @@ -6,6 +6,7 @@ import android.text.SpannableString import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Maybe import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.models.database.AttachmentId @@ -45,6 +46,8 @@ class MediaPreviewRepository { */ fun getAttachments(context: Context, startingAttachmentId: AttachmentId, threadId: Long, sorting: Sorting, limit: Int = 500): Flowable { return Single.fromCallable { + val stopwatch = Stopwatch("Attachment Window") + media.getGalleryMediaForThread(threadId, sorting).use { cursor -> val mediaRecords = mutableListOf() var startingRow = -1 @@ -54,6 +57,7 @@ class MediaPreviewRepository { break } } + stopwatch.split("find starting row") var itemPosition = -1 if (startingRow >= 0) { @@ -64,10 +68,7 @@ class MediaPreviewRepository { for (i in 0..limit) { val element = MediaTable.MediaRecord.from(cursor) - if (element.attachment?.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || - element.attachment?.transferState == AttachmentTable.TRANSFER_PROGRESS_STARTED || - element.attachment?.thumbnailUri != null - ) { + if (element.attachment?.isDisplayable() == true) { mediaRecords.add(element) if (startingAttachmentId.id == cursor.requireLong(AttachmentTable.ID)) { @@ -84,12 +85,32 @@ class MediaPreviewRepository { Log.w(TAG, "Unable to find target image for $startingAttachmentId") } } + stopwatch.split("build window of ${mediaRecords.size}") + stopwatch.stop(TAG) Result(if (mediaRecords.isNotEmpty()) itemPosition.coerceIn(mediaRecords.indices) else itemPosition, mediaRecords) } }.subscribeOn(Schedulers.io()).toFlowable() } + /** + * Primary-key read of a single attachment, so the tapped media can be rendered without waiting on + * [getAttachments] to sort and materialize the whole attachment window. Empty when the id does not + * name a displayable attachment, which is the normal case for previews of draft media. + */ + fun getInitialAttachment(attachmentId: AttachmentId): Maybe { + return Maybe.fromCallable { + SignalDatabase.attachments.getAttachment(attachmentId)?.takeIf { it.isDisplayable() } + }.subscribeOn(Schedulers.io()) + } + + /** Matches the filter [getAttachments] applies when building its window, so both agree on what can be paged to. */ + private fun DatabaseAttachment.isDisplayable(): Boolean { + return transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || + transferState == AttachmentTable.TRANSFER_PROGRESS_STARTED || + thumbnailUri != null + } + fun resolveMessageBodies(context: Context, messageIds: Set): Single> { return Single.fromCallable { SignalDatabase.messages.getMessages(messageIds).toList().withAttachments() diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.kt index 7a89f07a9c..9e455e94d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.kt @@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.database.MediaTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity +import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.mms.PartUriParser import org.thoughtcrime.securesms.notifications.NotificationChannels import org.thoughtcrime.securesms.notifications.NotificationIds @@ -68,6 +69,45 @@ class MediaPreviewViewModel : ViewModel() { return currentPosition in store.state.mediaRecords.indices && store.state.mediaRecords[currentPosition].toMedia()?.uri == initialMediaUri } + /** + * Publishes the tapped attachment on its own, ahead of [fetchAttachments], so the full-resolution decode is not + * gated on the whole-thread gallery query. Everything the record needs beyond the attachment row itself is + * already in [args]. + * + * Only ever applies to a fresh load: the [MediaPreviewState.LoadState.INIT] check is re-run inside the state + * update so a single-item result can never replace an already-loaded window, whether because the full query won + * the race or because [refetchAttachments] is rebuilding it underneath a browsing user. + */ + fun fetchInitialAttachment(args: MediaIntentFactory.MediaPreviewArgs) { + if (store.state.loadState != MediaPreviewState.LoadState.INIT) { + return + } + + disposables += repository.getInitialAttachment(PartAuthority.requireAttachmentId(args.initialMediaUri)).subscribe { attachment -> + store.update { oldState -> + if (oldState.loadState != MediaPreviewState.LoadState.INIT) { + return@update oldState + } + + val record = MediaTable.MediaRecord( + attachment = attachment, + recipientId = args.fromRecipientId, + threadRecipientId = args.threadRecipientId, + threadId = args.threadId, + messageId = args.messageId, + date = args.date, + isOutgoing = args.outgoing + ) + + oldState.copy( + position = 0, + mediaRecords = listOf(record), + loadState = MediaPreviewState.LoadState.DATA_LOADED + ) + } + } + } + fun fetchAttachments(context: Context, startingAttachmentId: AttachmentId, threadId: Long, sorting: MediaTable.Sorting, forceRefresh: Boolean = false) { if (store.state.loadState == MediaPreviewState.LoadState.INIT || forceRefresh) { disposables += repository.getAttachments(context, startingAttachmentId, threadId, sorting).subscribe { result -> @@ -80,19 +120,26 @@ class MediaPreviewViewModel : ViewModel() { } acc } + // Never downgrade a MEDIA_READY state: the initial attachment may already have finished decoding. + val loadState = if (oldState.loadState == MediaPreviewState.LoadState.MEDIA_READY) { + MediaPreviewState.LoadState.MEDIA_READY + } else { + MediaPreviewState.LoadState.DATA_LOADED + } + if (oldState.leftIsRecent) { oldState.copy( position = result.initialPosition, mediaRecords = result.records, albums = albums, - loadState = MediaPreviewState.LoadState.DATA_LOADED + loadState = loadState ) } else { oldState.copy( position = result.records.size - result.initialPosition - 1, mediaRecords = result.records.reversed(), albums = albums.mapValues { it.value.reversed() }, - loadState = MediaPreviewState.LoadState.DATA_LOADED + loadState = loadState ) } }