diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index 9cfcd7301c..f39c0924d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -189,10 +189,10 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns public abstract boolean isStory(long messageId); public abstract @NonNull Reader getOutgoingStoriesTo(@NonNull RecipientId recipientId); - public abstract @NonNull Reader getAllOutgoingStories(boolean reverse); + public abstract @NonNull Reader getAllOutgoingStories(boolean reverse, int limit); public abstract @NonNull Reader getAllOutgoingStoriesAt(long sentTimestamp); public abstract @NonNull List getOrderedStoryRecipientsAndIds(); - public abstract @NonNull Reader getAllStoriesFor(@NonNull RecipientId recipientId); + public abstract @NonNull Reader getAllStoriesFor(@NonNull RecipientId recipientId, int limit); public abstract @NonNull MessageId getStoryId(@NonNull RecipientId authorId, long sentTimestamp) throws NoSuchMessageException; public abstract int getNumberOfStoryReplies(long parentStoryId); public abstract @NonNull List getUnreadStoryThreadRecipientIds(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index b2e053b590..8d15b8b969 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -620,10 +620,10 @@ public class MmsDatabase extends MessageDatabase { } @Override - public @NonNull MessageDatabase.Reader getAllOutgoingStories(boolean reverse) { + public @NonNull MessageDatabase.Reader getAllOutgoingStories(boolean reverse, int limit) { String where = IS_STORY_CLAUSE + " AND (" + getOutgoingTypeClause() + ")"; - return new Reader(rawQuery(where, null, reverse, -1L)); + return new Reader(rawQuery(where, null, reverse, limit)); } @Override @@ -636,11 +636,11 @@ public class MmsDatabase extends MessageDatabase { } @Override - public @NonNull MessageDatabase.Reader getAllStoriesFor(@NonNull RecipientId recipientId) { + public @NonNull MessageDatabase.Reader getAllStoriesFor(@NonNull RecipientId recipientId, int limit) { long threadId = SignalDatabase.threads().getThreadIdIfExistsFor(recipientId); String where = IS_STORY_CLAUSE + " AND " + THREAD_ID_WHERE; String[] whereArgs = SqlUtil.buildArgs(threadId); - Cursor cursor = rawQuery(where, whereArgs, false, -1L); + Cursor cursor = rawQuery(where, whereArgs, false, limit); return new Reader(cursor); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 93f29bf277..21331ef85d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -1403,7 +1403,7 @@ public class SmsDatabase extends MessageDatabase { } @Override - public @NonNull MessageDatabase.Reader getAllOutgoingStories(boolean reverse) { + public @NonNull MessageDatabase.Reader getAllOutgoingStories(boolean reverse, int limit) { throw new UnsupportedOperationException(); } @@ -1418,7 +1418,7 @@ public class SmsDatabase extends MessageDatabase { } @Override - public @NonNull MessageDatabase.Reader getAllStoriesFor(@NonNull RecipientId recipientId) { + public @NonNull MessageDatabase.Reader getAllStoriesFor(@NonNull RecipientId recipientId, int limit) { throw new UnsupportedOperationException(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StoryOnboardingDownloadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StoryOnboardingDownloadJob.kt index 44a38549bd..4f75a4a867 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StoryOnboardingDownloadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StoryOnboardingDownloadJob.kt @@ -79,7 +79,7 @@ class StoryOnboardingDownloadJob private constructor(parameters: Parameters) : B throw Exception("No release channel recipient.") } - SignalDatabase.mms.getAllStoriesFor(releaseChannelRecipientId).use { reader -> + SignalDatabase.mms.getAllStoriesFor(releaseChannelRecipientId, -1).use { reader -> reader.forEach { messageRecord -> SignalDatabase.mms.deleteMessage(messageRecord.id) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index c05bfa80a4..fe40f08ba6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -1478,7 +1478,7 @@ public final class MessageContentProcessor { } if (insertResult.isPresent()) { - Stories.enqueueNextStoriesForDownload(threadRecipient.getId(), false); + Stories.enqueueNextStoriesForDownload(threadRecipient.getId(), false, FeatureFlags.storiesAutoDownloadMaximum()); ApplicationDependencies.getExpireStoriesManager().scheduleIfNecessary(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt index 9040a92f7b..40bedb492a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt @@ -45,6 +45,8 @@ import kotlin.math.min object Stories { + private val TAG = Log.tag(Stories::class.java) + const val MAX_BODY_SIZE = 700 @JvmField @@ -97,16 +99,18 @@ object Stories { @JvmStatic @WorkerThread - fun enqueueNextStoriesForDownload(recipientId: RecipientId, ignoreAutoDownloadConstraints: Boolean = false) { + fun enqueueNextStoriesForDownload(recipientId: RecipientId, force: Boolean = false, limit: Int) { val recipient = Recipient.resolved(recipientId) - if (!recipient.isSelf && (recipient.shouldHideStory() || !recipient.hasViewedStory())) { + if (!force && !recipient.isSelf && (recipient.shouldHideStory() || !recipient.hasViewedStory())) { return } - val unreadStoriesReader = SignalDatabase.mms.getUnreadStories(recipientId, FeatureFlags.storiesAutoDownloadMaximum()) - while (unreadStoriesReader.next != null) { - val record = unreadStoriesReader.current as MmsMessageRecord - enqueueAttachmentsFromStoryForDownloadSync(record, ignoreAutoDownloadConstraints) + Log.d(TAG, "Enqueuing downloads for up to $limit stories for $recipientId (force: $force)") + SignalDatabase.mms.getUnreadStories(recipientId, limit).use { + while (it.next != null) { + val record = it.current as MmsMessageRecord + enqueueAttachmentsFromStoryForDownloadSync(record, force) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StorySlateView.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StorySlateView.kt index 76ec86fdea..ba23de8eb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StorySlateView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StorySlateView.kt @@ -42,6 +42,7 @@ class StorySlateView @JvmOverloads constructor( private val background: ImageView = findViewById(R.id.background) private val loadingSpinner: View = findViewById(R.id.loading_spinner) private val errorCircle: View = findViewById(R.id.error_circle) + private val errorBackground: View = findViewById(R.id.stories_error_background) private val unavailableText: View = findViewById(R.id.unavailable) private val errorText: TextView = findViewById(R.id.error_text) @@ -87,6 +88,7 @@ class StorySlateView @JvmOverloads constructor( background.visible = true loadingSpinner.visible = true errorCircle.visible = false + errorBackground.visible = false unavailableText.visible = false errorText.visible = false } @@ -97,6 +99,7 @@ class StorySlateView @JvmOverloads constructor( background.visible = true loadingSpinner.visible = false errorCircle.visible = true + errorBackground.visible = true unavailableText.visible = false errorText.visible = true @@ -113,6 +116,7 @@ class StorySlateView @JvmOverloads constructor( background.visible = true loadingSpinner.visible = false errorCircle.visible = false + errorBackground.visible = false unavailableText.visible = true errorText.visible = false } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesRepository.kt index 763a9bf9f9..49954659aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesRepository.kt @@ -26,7 +26,7 @@ class MyStoriesRepository(context: Context) { return Observable.create { emitter -> fun refresh() { val storiesMap = mutableMapOf>() - SignalDatabase.mms.getAllOutgoingStories(true).use { + SignalDatabase.mms.getAllOutgoingStories(true, -1).use { while (it.next != null) { val messageRecord = it.current val currentList = storiesMap[messageRecord.recipient] ?: emptyList() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt index fc8bc4b9ea..22699a3944 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt @@ -9,6 +9,8 @@ import android.view.KeyEvent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatDelegate import androidx.media.AudioManagerCompat +import com.bumptech.glide.Glide +import com.bumptech.glide.MemoryCategory import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController @@ -31,6 +33,7 @@ class StoryViewerActivity : PassphraseRequiredActivity(), VoiceNoteMediaControll override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { StoryMutePolicy.initialize() + Glide.get(this).setMemoryCategory(MemoryCategory.HIGH) supportPostponeEnterTransition() @@ -44,6 +47,11 @@ class StoryViewerActivity : PassphraseRequiredActivity(), VoiceNoteMediaControll } } + override fun onDestroy() { + super.onDestroy() + Glide.get(this).setMemoryCategory(MemoryCategory.NORMAL) + } + override fun onResume() { super.onResume() if (StoryMutePolicy.isContentMuted) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt index 5cf59b6579..92cba4a056 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt @@ -10,12 +10,16 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.stories.StoryViewerArgs import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageFragment +import org.thoughtcrime.securesms.stories.viewer.reply.StoriesSharedElementCrossFaderView import org.thoughtcrime.securesms.util.LifecycleDisposable /** * Fragment which manages a vertical pager fragment of stories. */ -class StoryViewerFragment : Fragment(R.layout.stories_viewer_fragment), StoryViewerPageFragment.Callback { +class StoryViewerFragment : + Fragment(R.layout.stories_viewer_fragment), + StoryViewerPageFragment.Callback, + StoriesSharedElementCrossFaderView.Callback { private val onPageChanged = OnPageChanged() @@ -31,9 +35,16 @@ class StoryViewerFragment : Fragment(R.layout.stories_viewer_fragment), StoryVie private val storyViewerArgs: StoryViewerArgs by lazy { requireArguments().getParcelable(ARGS)!! } + private lateinit var storyCrossfader: StoriesSharedElementCrossFaderView + + private var pagerOnPageSelectedLock: Boolean = false + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + storyCrossfader = view.findViewById(R.id.story_content_crossfader) storyPager = view.findViewById(R.id.story_item_pager) + storyCrossfader.callback = this + val adapter = StoryViewerPagerAdapter( this, storyViewerArgs.storyId, @@ -45,24 +56,38 @@ class StoryViewerFragment : Fragment(R.layout.stories_viewer_fragment), StoryVie storyPager.adapter = adapter storyPager.overScrollMode = ViewPager2.OVER_SCROLL_NEVER - viewModel.isChildScrolling.observe(viewLifecycleOwner) { - storyPager.isUserInputEnabled = !it + lifecycleDisposable += viewModel.allowParentScrolling.observeOn(AndroidSchedulers.mainThread()).subscribe { + storyPager.isUserInputEnabled = it } + storyPager.offscreenPageLimit = 1 + lifecycleDisposable.bindTo(viewLifecycleOwner) lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state -> adapter.setPages(state.pages) if (state.pages.isNotEmpty() && storyPager.currentItem != state.page) { + pagerOnPageSelectedLock = true storyPager.setCurrentItem(state.page, state.previousPage > -1) + pagerOnPageSelectedLock = false if (state.page >= state.pages.size) { requireActivity().onBackPressed() } } - if (state.loadState.isCrossfaderReady) { + when (state.crossfadeSource) { + is StoryViewerState.CrossfadeSource.TextModel -> storyCrossfader.setSourceView(state.crossfadeSource.storyTextPostModel) + is StoryViewerState.CrossfadeSource.ImageUri -> storyCrossfader.setSourceView(state.crossfadeSource.imageUri, state.crossfadeSource.imageBlur) + } + + if (state.crossfadeTarget is StoryViewerState.CrossfadeTarget.Record) { + storyCrossfader.setTargetView(state.crossfadeTarget.messageRecord) requireActivity().supportStartPostponedEnterTransition() } + + if (state.loadState.isReady()) { + storyCrossfader.alpha = 0f + } } } @@ -90,9 +115,22 @@ class StoryViewerFragment : Fragment(R.layout.stories_viewer_fragment), StoryVie viewModel.onRecipientHidden() } + override fun onReadyToAnimate() { + } + + override fun onAnimationStarted() { + viewModel.setCrossfaderIsReady(false) + } + + override fun onAnimationFinished() { + viewModel.setCrossfaderIsReady(true) + } + inner class OnPageChanged : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { - viewModel.setSelectedPage(position) + if (!pagerOnPageSelectedLock) { + viewModel.setSelectedPage(position) + } } override fun onPageScrollStateChanged(state: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt index 3fdaa846bc..87b90efe02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt @@ -2,8 +2,10 @@ package org.thoughtcrime.securesms.stories.viewer import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.MessageDatabase import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient @@ -13,6 +15,27 @@ import org.thoughtcrime.securesms.recipients.RecipientId * Open for testing */ open class StoryViewerRepository { + fun getFirstStory(recipientId: RecipientId, unviewedOnly: Boolean, storyId: Long): Single { + return if (storyId > 0) { + Single.fromCallable { + SignalDatabase.mms.getMessageRecord(storyId) as MmsMessageRecord + } + } else { + Single.fromCallable { + val recipient = Recipient.resolved(recipientId) + val reader: MessageDatabase.Reader = if (recipient.isMyStory || recipient.isSelf) { + SignalDatabase.mms.getAllOutgoingStories(false, 1) + } else if (unviewedOnly) { + SignalDatabase.mms.getUnreadStories(recipientId, 1) + } else { + SignalDatabase.mms.getAllStoriesFor(recipientId, 1) + } + + reader.use { it.next } as MmsMessageRecord + } + } + } + fun getStories(hiddenStories: Boolean, unviewedOnly: Boolean): Single> { return Single.create> { emitter -> val myStoriesId = SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerState.kt index bc732945de..e4898f6bae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerState.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.stories.viewer import android.net.Uri import org.thoughtcrime.securesms.blurhash.BlurHash +import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.stories.StoryTextPostModel @@ -10,6 +11,7 @@ data class StoryViewerState( val previousPage: Int = -1, val page: Int = -1, val crossfadeSource: CrossfadeSource, + val crossfadeTarget: CrossfadeTarget? = null, val loadState: LoadState = LoadState() ) { sealed class CrossfadeSource { @@ -18,6 +20,11 @@ data class StoryViewerState( class TextModel(val storyTextPostModel: StoryTextPostModel) : CrossfadeSource() } + sealed class CrossfadeTarget { + object None : CrossfadeTarget() + data class Record(val messageRecord: MmsMessageRecord) : CrossfadeTarget() + } + data class LoadState( val isContentReady: Boolean = false, val isCrossfaderReady: Boolean = false diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt index 1709962342..6de03e61f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt @@ -5,11 +5,16 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.stories.StoryViewerArgs +import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.rx.RxStore import kotlin.math.max @@ -36,8 +41,11 @@ class StoryViewerViewModel( private val scrollStatePublisher: MutableLiveData = MutableLiveData(false) val isScrolling: LiveData = scrollStatePublisher - private val childScrollStatePublisher: MutableLiveData = MutableLiveData(false) - val isChildScrolling: LiveData = childScrollStatePublisher + private val childScrollStatePublisher: BehaviorSubject = BehaviorSubject.createDefault(false) + val allowParentScrolling: Observable = Observable.combineLatest( + childScrollStatePublisher.distinctUntilChanged(), + state.toObservable().map { it.loadState.isReady() }.distinctUntilChanged() + ) { a, b -> !a && b } var hasConsumedInitialState = false private set @@ -46,6 +54,12 @@ class StoryViewerViewModel( refresh() } + fun setCrossfadeTarget(messageRecord: MmsMessageRecord) { + store.update { + it.copy(crossfadeTarget = StoryViewerState.CrossfadeTarget.Record(messageRecord)) + } + } + fun consumeInitialState() { hasConsumedInitialState = true } @@ -56,9 +70,9 @@ class StoryViewerViewModel( } } - fun setCrossfaderIsReady() { + fun setCrossfaderIsReady(isReady: Boolean) { store.update { - it.copy(loadState = it.loadState.copy(isCrossfaderReady = true)) + it.copy(loadState = it.loadState.copy(isCrossfaderReady = isReady)) } } @@ -79,6 +93,13 @@ class StoryViewerViewModel( private fun refresh() { disposables.clear() + disposables += repository.getFirstStory(storyViewerArgs.recipientId, storyViewerArgs.isUnviewedOnly, storyViewerArgs.storyId).subscribe { record -> + store.update { + it.copy( + crossfadeTarget = StoryViewerState.CrossfadeTarget.Record(record) + ) + } + } disposables += getStories().subscribe { recipientIds -> store.update { val page: Int = if (it.pages.isNotEmpty()) { @@ -97,6 +118,19 @@ class StoryViewerViewModel( updatePages(it.copy(pages = recipientIds), page) } } + disposables += state + .map { + if ((it.page + 1) in it.pages.indices) { + it.pages[it.page + 1] + } else { + RecipientId.UNKNOWN + } + } + .filter { it != RecipientId.UNKNOWN } + .distinctUntilChanged() + .subscribe { + Stories.enqueueNextStoriesForDownload(it, true, FeatureFlags.storiesAutoDownloadMaximum()) + } } override fun onCleared() { @@ -161,7 +195,7 @@ class StoryViewerViewModel( } fun setIsChildScrolling(isChildScrolling: Boolean) { - childScrollStatePublisher.value = isChildScrolling + childScrollStatePublisher.onNext(isChildScrolling) } class Factory( diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryCache.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryCache.kt new file mode 100644 index 0000000000..e9c72f1bfe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryCache.kt @@ -0,0 +1,131 @@ +package org.thoughtcrime.securesms.stories.viewer.page + +import android.graphics.drawable.Drawable +import android.net.Uri +import com.bumptech.glide.Priority +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.database.AttachmentDatabase +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader +import org.thoughtcrime.securesms.mms.GlideRequests +import org.thoughtcrime.securesms.util.MediaUtil + +/** + * StoryCache loads attachment drawables into memory and holds onto them until it is cleared. This class only + * works with Images. + */ +class StoryCache( + private val glideRequests: GlideRequests, + private val storySize: StoryDisplay.Size +) { + private val cache = mutableMapOf() + + /** + * Load the given list of attachments into memory. This will automatically filter out any that are not yet + * downloaded, not images, or already in progress. + */ + fun prefetch(attachments: List) { + val prefetchableAttachments: List = attachments + .asSequence() + .filter { it.uri != null && it.uri !in cache } + .filter { MediaUtil.isImage(it) } + .filter { it.transferState == AttachmentDatabase.TRANSFER_PROGRESS_DONE } + .toList() + + val newMappings: Map = prefetchableAttachments.associateWith { attachment -> + val imageTarget = glideRequests + .load(DecryptableStreamUriLoader.DecryptableUri(attachment.uri!!)) + .priority(Priority.HIGH) + .into(StoryCacheTarget(attachment.uri!!, storySize)) + + val blurTarget = if (attachment.blurHash != null) { + glideRequests + .load(attachment.blurHash) + .priority(Priority.HIGH) + .into(StoryCacheTarget(attachment.uri!!, storySize)) + } else { + null + } + + StoryCacheValue(imageTarget, blurTarget) + }.mapKeys { it.key.uri!! } + + cache.putAll(newMappings) + } + + /** + * Clears and cancels all cached values. + */ + fun clear() { + val values = ArrayList(cache.values) + + values.forEach { value -> + glideRequests.clear(value.imageTarget) + value.blurTarget?.let { glideRequests.clear(it) } + } + + cache.clear() + } + + /** + * Get the appropriate cache value from the cache if it exists. + * Since this is only used for images, we don't need to worry about transform properties. + */ + fun getFromCache(uri: Uri): StoryCacheValue? { + return cache[uri] + } + + /** + * Represents the load targets for an image and blur. + */ + data class StoryCacheValue(val imageTarget: StoryCacheTarget, val blurTarget: StoryCacheTarget?) + + /** + * A custom glide target for loading a drawable. Placeholder immediately clears, and we don't want to do that, so we use this instead. + */ + inner class StoryCacheTarget(val uri: Uri, size: StoryDisplay.Size) : CustomTarget(size.width, size.height) { + + private var resource: Drawable? = null + private var isFailed: Boolean = false + + private val listeners = mutableSetOf() + + fun addListener(listener: Listener) { + listeners.add(listener) + resource?.let { listener.onResourceReady(it) } + if (isFailed) { + listener.onLoadFailed() + } + } + + fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + override fun onResourceReady(resource: Drawable, transition: Transition?) { + this.resource = resource + listeners.forEach { it.onResourceReady(resource) } + } + + override fun onLoadFailed(errorDrawable: Drawable?) { + super.onLoadFailed(errorDrawable) + isFailed = true + listeners.forEach { it.onLoadFailed() } + } + + override fun onLoadCleared(placeholder: Drawable?) { + resource = null + isFailed = false + cache.remove(uri) + } + } + + /** + * Feedback from a target for when it's data is loaded or failed. + */ + interface Listener { + fun onResourceReady(resource: Drawable) + fun onLoadFailed() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryDisplay.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryDisplay.kt index 8d1b972f3e..d8bfa6d5db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryDisplay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryDisplay.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.stories.viewer.page +import android.content.res.Resources + /** * Given the size of our display, we render the story overlay / crop in one of 3 ways. */ @@ -32,5 +34,24 @@ enum class StoryDisplay { else -> MEDIUM } } + + fun getStorySize(resources: Resources): Size { + val width = resources.displayMetrics.widthPixels.toFloat() + val height = resources.displayMetrics.heightPixels.toFloat() + val storyDisplay = getStoryDisplay(width, height) + + val (imageWidth, imageHeight) = when (storyDisplay) { + LARGE -> width to width * 16 / 9 + MEDIUM -> width to width * 16 / 9 + SMALL -> width to height + } + + return Size(imageWidth.toInt(), imageHeight.toInt()) + } } + + /** + * Android Size() is limited to API 21+ + */ + data class Size(val width: Int, val height: Int) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryImageContentFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryImageContentFragment.kt new file mode 100644 index 0000000000..5f3ed7d642 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryImageContentFragment.kt @@ -0,0 +1,182 @@ +package org.thoughtcrime.securesms.stories.viewer.page + +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.ImageView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.blurhash.BlurHash +import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.util.fragments.requireListener + +class StoryImageContentFragment : Fragment(R.layout.stories_image_content_fragment) { + + private var blurState: LoadState = LoadState.INIT + private var imageState: LoadState = LoadState.INIT + + private val parentViewModel: StoryViewerPageViewModel by viewModels( + ownerProducer = { requireParentFragment() } + ) + + private lateinit var imageView: ImageView + private lateinit var blur: ImageView + + private val imageListener = object : StoryCache.Listener { + override fun onResourceReady(resource: Drawable) { + imageView.setImageDrawable(resource) + imageState = LoadState.READY + notifyListeners() + } + + override fun onLoadFailed() { + imageState = LoadState.FAILED + notifyListeners() + } + } + + private val blurListener = object : StoryCache.Listener { + override fun onResourceReady(resource: Drawable) { + blur.setImageDrawable(resource) + blurState = LoadState.READY + notifyListeners() + } + + override fun onLoadFailed() { + blurState = LoadState.FAILED + notifyListeners() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + imageView = view.findViewById(R.id.image) + blur = view.findViewById(R.id.blur) + + val storySize = StoryDisplay.getStorySize(resources) + val blurHash: BlurHash? = requireArguments().getParcelable(BLUR) + val uri: Uri = requireArguments().getParcelable(URI)!! + + val cacheValue: StoryCache.StoryCacheValue? = parentViewModel.storyCache.getFromCache(uri) + if (cacheValue != null) { + loadViaCache(cacheValue) + } else { + loadViaGlide(blurHash, storySize) + } + } + + private fun loadViaCache(cacheValue: StoryCache.StoryCacheValue) { + Log.d(TAG, "Attachment in cache. Loading via cache...") + val blurTarget = cacheValue.blurTarget + if (blurTarget != null) { + blurTarget.addListener(blurListener) + viewLifecycleOwner.lifecycle.addObserver(OnDestroy { blurTarget.removeListener(blurListener) }) + } else { + blurState = LoadState.FAILED + notifyListeners() + } + + val imageTarget = cacheValue.imageTarget + imageTarget.addListener(imageListener) + viewLifecycleOwner.lifecycle.addObserver(OnDestroy { imageTarget.removeListener(blurListener) }) + } + + private fun loadViaGlide(blurHash: BlurHash?, storySize: StoryDisplay.Size) { + Log.d(TAG, "Attachment not in cache. Loading via glide...") + if (blurHash != null) { + GlideApp.with(blur) + .load(blurHash) + .override(storySize.width, storySize.height) + .addListener(object : RequestListener { + override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { + blurState = LoadState.FAILED + notifyListeners() + return false + } + + override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { + blurState = LoadState.READY + notifyListeners() + return false + } + }) + .into(blur) + } else { + blurState = LoadState.FAILED + notifyListeners() + } + + GlideApp.with(imageView) + .load(DecryptableStreamUriLoader.DecryptableUri(requireArguments().getParcelable(URI)!!)) + .override(storySize.width, storySize.height) + .addListener(object : RequestListener { + override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { + imageState = LoadState.FAILED + notifyListeners() + return false + } + + override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { + imageState = LoadState.READY + notifyListeners() + return false + } + }) + .into(imageView) + } + + private fun notifyListeners() { + if (isDetached) { + Log.w(TAG, "Fragment is detached, dropping notify call.") + return + } + + if (blurState != LoadState.INIT && imageState != LoadState.INIT) { + if (imageState == LoadState.FAILED) { + requireListener().mediaNotAvailable() + } else { + requireListener().onMediaReady() + } + } + } + + private inner class OnDestroy(private val onDestroy: () -> Unit) : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + onDestroy() + } + } + + enum class LoadState { + INIT, + READY, + FAILED + } + + companion object { + + private val TAG = Log.tag(StoryImageContentFragment::class.java) + + private const val URI = MediaPreviewFragment.DATA_URI + private const val BLUR = "blur_hash" + + fun create(attachment: Attachment): StoryImageContentFragment { + return StoryImageContentFragment().apply { + arguments = Bundle().apply { + putParcelable(URI, attachment.uri!!) + putParcelable(BLUR, attachment.blurHash) + } + } + } + } +} 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 c2f6d7d93c..2ab992e095 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 @@ -17,7 +17,6 @@ import android.view.MotionEvent import android.view.View import android.view.animation.Interpolator import android.widget.FrameLayout -import android.widget.ImageView import android.widget.TextView import androidx.cardview.widget.CardView import androidx.constraintlayout.widget.ConstraintLayout @@ -46,7 +45,6 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord -import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment import org.thoughtcrime.securesms.mediapreview.VideoControlsDelegate @@ -57,10 +55,8 @@ import org.thoughtcrime.securesms.stories.StoryFirstTimeNavigationView import org.thoughtcrime.securesms.stories.StorySlateView import org.thoughtcrime.securesms.stories.StoryVolumeOverlayView import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu -import org.thoughtcrime.securesms.stories.viewer.StoryViewerState import org.thoughtcrime.securesms.stories.viewer.StoryViewerViewModel import org.thoughtcrime.securesms.stories.viewer.StoryVolumeViewModel -import org.thoughtcrime.securesms.stories.viewer.reply.StoriesSharedElementCrossFaderView import org.thoughtcrime.securesms.stories.viewer.reply.direct.StoryDirectReplyDialogFragment import org.thoughtcrime.securesms.stories.viewer.reply.group.StoryGroupReplyBottomSheetDialogFragment import org.thoughtcrime.securesms.stories.viewer.reply.reaction.OnReactionSentView @@ -89,16 +85,13 @@ class StoryViewerPageFragment : MultiselectForwardBottomSheet.Callback, StorySlateView.Callback, StoryTextPostPreviewFragment.Callback, - StoriesSharedElementCrossFaderView.Callback, StoryFirstTimeNavigationView.Callback { - private val activityViewModel: StoryVolumeViewModel by viewModels(ownerProducer = { requireActivity() }) + private val storyVolumeViewModel: StoryVolumeViewModel by viewModels(ownerProducer = { requireActivity() }) private lateinit var progressBar: SegmentedProgressBar private lateinit var storySlate: StorySlateView private lateinit var viewsAndReplies: MaterialButton - private lateinit var storyCrossfader: StoriesSharedElementCrossFaderView - private lateinit var blurContainer: ImageView private lateinit var storyCaptionContainer: FrameLayout private lateinit var storyContentContainer: FrameLayout private lateinit var storyFirstTimeNavigationViewStub: StoryFirstNavigationStub @@ -114,7 +107,18 @@ class StoryViewerPageFragment : private val viewModel: StoryViewerPageViewModel by viewModels( factoryProducer = { - StoryViewerPageViewModel.Factory(storyRecipientId, initialStoryId, isUnviewedOnly, StoryViewerPageRepository(requireContext())) + StoryViewerPageViewModel.Factory( + storyRecipientId, + initialStoryId, + isUnviewedOnly, + StoryViewerPageRepository( + requireContext() + ), + StoryCache( + GlideApp.with(requireActivity()), + StoryDisplay.getStorySize(resources) + ) + ) } ) @@ -146,7 +150,7 @@ class StoryViewerPageFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { callback = requireListener() - if (activityViewModel.snapshot.isMuted) { + if (storyVolumeViewModel.snapshot.isMuted) { videoControlsDelegate.mute() } else { videoControlsDelegate.unmute() @@ -169,17 +173,14 @@ class StoryViewerPageFragment : val storyGradientBottom: View = view.findViewById(R.id.story_gradient_bottom) val storyVolumeOverlayView: StoryVolumeOverlayView = view.findViewById(R.id.story_volume_overlay) - blurContainer = view.findViewById(R.id.story_blur_container) storyContentContainer = view.findViewById(R.id.story_content_container) storyCaptionContainer = view.findViewById(R.id.story_caption_container) storySlate = view.findViewById(R.id.story_slate) progressBar = view.findViewById(R.id.progress) viewsAndReplies = view.findViewById(R.id.views_and_replies_bar) - storyCrossfader = view.findViewById(R.id.story_content_crossfader) storyFirstTimeNavigationViewStub = StoryFirstNavigationStub(view.findViewById(R.id.story_first_time_nav_stub)) storySlate.callback = this - storyCrossfader.callback = this storyFirstTimeNavigationViewStub.setCallback(this) chrome = listOf( @@ -286,7 +287,7 @@ class StoryViewerPageFragment : viewModel.setIsUserScrollingParent(isScrolling) } - lifecycleDisposable += activityViewModel.state.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread()).subscribe { volumeState -> + lifecycleDisposable += storyVolumeViewModel.state.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread()).subscribe { volumeState -> if (volumeState.isMuted) { videoControlsDelegate.mute() return@subscribe @@ -310,6 +311,9 @@ class StoryViewerPageFragment : } lifecycleDisposable += sharedViewModel.state.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread()).subscribe { parentState -> + viewModel.setIsRunningSharedElementAnimation(!parentState.loadState.isCrossfaderReady) + storyContentContainer.visible = parentState.loadState.isCrossfaderReady + if (parentState.pages.size <= parentState.page) { viewModel.setIsSelectedPage(false) } else if (storyRecipientId == parentState.pages[parentState.page]) { @@ -320,12 +324,6 @@ class StoryViewerPageFragment : } viewModel.setIsFirstPage(parentState.page == 0) viewModel.setIsSelectedPage(true) - when (parentState.crossfadeSource) { - is StoryViewerState.CrossfadeSource.TextModel -> storyCrossfader.setSourceView(parentState.crossfadeSource.storyTextPostModel) - is StoryViewerState.CrossfadeSource.ImageUri -> storyCrossfader.setSourceView(parentState.crossfadeSource.imageUri, parentState.crossfadeSource.imageBlur) - } - - onReadyToAnimate() } else { viewModel.setIsSelectedPage(false) } @@ -346,7 +344,7 @@ class StoryViewerPageFragment : presentDate(date, post) presentDistributionList(distributionList, post) presentCaption(caption, largeCaption, largeCaptionOverlay, post) - presentBlur(blurContainer, post) + presentBlur(post) val durations: Map = state.posts .mapIndexed { index, storyPost -> @@ -366,10 +364,6 @@ class StoryViewerPageFragment : presentStory(post, state.selectedPostIndex) presentSlate(post) - if (!storyCrossfader.setTargetView(post.conversationMessage.messageRecord as MmsMessageRecord)) { - onReadyToAnimate() - } - viewModel.setAreSegmentsInitialized(true) } else if (state.selectedPostIndex >= state.posts.size) { callback.onFinishedPosts(storyRecipientId) @@ -377,9 +371,11 @@ class StoryViewerPageFragment : callback.onGoToPreviousStory(storyRecipientId) } - if (state.isDisplayingInitialState && isFromNotification && !sharedViewModel.hasConsumedInitialState) { + if (state.isDisplayingInitialState && !sharedViewModel.hasConsumedInitialState) { sharedViewModel.consumeInitialState() - startReply(isFromNotification = true, groupReplyStartPosition = groupReplyStartPosition) + if (isFromNotification) { + startReply(isFromNotification = true, groupReplyStartPosition = groupReplyStartPosition) + } } } @@ -450,6 +446,7 @@ class StoryViewerPageFragment : super.onResume() viewModel.setIsFragmentResumed(true) viewModel.checkReadReceiptState() + markViewedIfAble() } override fun onPause() { @@ -624,6 +621,15 @@ class StoryViewerPageFragment : replyFragment.showNow(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) } + private fun markViewedIfAble() { + val post = if (viewModel.hasPost()) viewModel.getPost() else null + if (post?.content?.transferState == AttachmentDatabase.TRANSFER_PROGRESS_DONE) { + if (isResumed) { + viewModel.markViewed(post) + } + } + } + private fun onStartDirectReply(storyId: Long, recipientId: RecipientId) { viewModel.setIsDisplayingDirectReplyDialog(true) StoryDirectReplyDialogFragment.create( @@ -671,10 +677,7 @@ class StoryViewerPageFragment : AttachmentDatabase.TRANSFER_PROGRESS_DONE -> { storySlate.moveToState(StorySlateView.State.HIDDEN, post.id) viewModel.setIsDisplayingSlate(false) - - if (post.content.transferState == AttachmentDatabase.TRANSFER_PROGRESS_DONE) { - viewModel.markViewed(post) - } + markViewedIfAble() } AttachmentDatabase.TRANSFER_PROGRESS_PENDING -> { storySlate.moveToState(StorySlateView.State.LOADING, post.id) @@ -713,17 +716,11 @@ class StoryViewerPageFragment : distributionList.visible = storyPost.distributionList != null && !storyPost.distributionList.isMyStory } - private fun presentBlur(blur: ImageView, storyPost: StoryPost) { + private fun presentBlur(storyPost: StoryPost) { val record = storyPost.conversationMessage.messageRecord as? MediaMmsMessageRecord val blurHash = record?.slideDeck?.thumbnailSlide?.placeholderBlur storyFirstTimeNavigationViewStub.setBlurHash(blurHash) - - if (blurHash == null) { - GlideApp.with(blur).clear(blur) - } else { - GlideApp.with(blur).load(blurHash).into(blur) - } } @SuppressLint("SetTextI18n") @@ -875,11 +872,19 @@ class StoryViewerPageFragment : private fun createFragmentForPost(storyPost: StoryPost): Fragment { return when (storyPost.content) { - is StoryPost.Content.AttachmentContent -> MediaPreviewFragment.newInstance(storyPost.content.attachment, false) + is StoryPost.Content.AttachmentContent -> createFragmentForAttachmentContent(storyPost.content) is StoryPost.Content.TextContent -> StoryTextPostPreviewFragment.create(storyPost.content) } } + private fun createFragmentForAttachmentContent(attachmentContent: StoryPost.Content.AttachmentContent): Fragment { + return if (attachmentContent.isVideo()) { + MediaPreviewFragment.newInstance(attachmentContent.attachment, false) + } else { + StoryImageContentFragment.create(attachmentContent.attachment) + } + } + override fun setIsDisplayingLinkPreviewTooltip(isDisplayingLinkPreviewTooltip: Boolean) { viewModel.setIsDisplayingLinkPreviewTooltip(isDisplayingLinkPreviewTooltip) } @@ -1121,22 +1126,6 @@ class StoryViewerPageFragment : sharedViewModel.setContentIsReady() } - override fun onReadyToAnimate() { - sharedViewModel.setCrossfaderIsReady() - } - - override fun onAnimationStarted() { - storyContentContainer.alpha = 0f - blurContainer.alpha = 0f - viewModel.setIsRunningSharedElementAnimation(true) - } - - override fun onAnimationFinished() { - storyContentContainer.alpha = 1f - blurContainer.alpha = 1f - viewModel.setIsRunningSharedElementAnimation(false) - } - interface Callback { fun onGoToPreviousStory(recipientId: RecipientId) fun onFinishedPosts(recipientId: RecipientId) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt index e15f79aa47..282dfd696f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageRepository.kt @@ -45,11 +45,11 @@ open class StoryViewerPageRepository(context: Context) { fun refresh() { val stories = if (recipient.isMyStory) { - SignalDatabase.mms.getAllOutgoingStories(false) + SignalDatabase.mms.getAllOutgoingStories(false, 100) } else if (isUnviewedOnly) { SignalDatabase.mms.getUnreadStories(recipientId, 100) } else { - SignalDatabase.mms.getAllStoriesFor(recipientId) + SignalDatabase.mms.getAllStoriesFor(recipientId, 100) } val results = mutableListOf() @@ -190,7 +190,7 @@ open class StoryViewerPageRepository(context: Context) { val recipientId = storyPost.group?.id ?: storyPost.sender.id SignalDatabase.recipients.updateLastStoryViewTimestamp(recipientId) - Stories.enqueueNextStoriesForDownload(recipientId, true) + Stories.enqueueNextStoriesForDownload(recipientId, true, 5) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt index 244dfd91c1..841a0d8e43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt @@ -25,7 +25,8 @@ class StoryViewerPageViewModel( private val recipientId: RecipientId, private val initialStoryId: Long, private val isUnviewedOnly: Boolean, - private val repository: StoryViewerPageRepository + private val repository: StoryViewerPageRepository, + val storyCache: StoryCache ) : ViewModel() { private val store = RxStore(StoryViewerPageState(isReceiptsEnabled = repository.isReadReceiptsEnabled())) @@ -63,7 +64,7 @@ class StoryViewerPageViewModel( var isDisplayingInitialState = false val startIndex = if (state.posts.isEmpty() && initialStoryId > 0) { val initialIndex = posts.indexOfFirst { it.id == initialStoryId } - isDisplayingInitialState = initialIndex > -1 + isDisplayingInitialState = true initialIndex.takeIf { it > -1 } ?: state.selectedPostIndex } else if (state.posts.isEmpty()) { val initialPost = getNextUnreadPost(posts) @@ -81,11 +82,18 @@ class StoryViewerPageViewModel( isDisplayingInitialState = isDisplayingInitialState ) } + + storyCache.prefetch( + posts.map { it.content } + .filterIsInstance() + .map { it.attachment } + ) } } override fun onCleared() { disposables.clear() + storyCache.clear() } fun hideStory(): Completable { @@ -264,10 +272,11 @@ class StoryViewerPageViewModel( private val recipientId: RecipientId, private val initialStoryId: Long, private val isUnviewedOnly: Boolean, - private val repository: StoryViewerPageRepository + private val repository: StoryViewerPageRepository, + private val storyCache: StoryCache ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return modelClass.cast(StoryViewerPageViewModel(recipientId, initialStoryId, isUnviewedOnly, repository)) as T + return modelClass.cast(StoryViewerPageViewModel(recipientId, initialStoryId, isUnviewedOnly, repository, storyCache)) as T } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoriesSharedElementCrossFaderView.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoriesSharedElementCrossFaderView.kt index 7a432fe298..701df9a796 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoriesSharedElementCrossFaderView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/StoriesSharedElementCrossFaderView.kt @@ -231,8 +231,6 @@ class StoriesSharedElementCrossFaderView @JvmOverloads constructor( return } - animate().alpha(0f) - callback?.onAnimationFinished() } diff --git a/app/src/main/res/layout/stories_image_content_fragment.xml b/app/src/main/res/layout/stories_image_content_fragment.xml new file mode 100644 index 0000000000..01ee7f65de --- /dev/null +++ b/app/src/main/res/layout/stories_image_content_fragment.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/layout/stories_viewer_fragment.xml b/app/src/main/res/layout/stories_viewer_fragment.xml index 113563cd52..2441896423 100644 --- a/app/src/main/res/layout/stories_viewer_fragment.xml +++ b/app/src/main/res/layout/stories_viewer_fragment.xml @@ -1,6 +1,29 @@ - + + \ No newline at end of file + android:orientation="vertical" /> + + + diff --git a/app/src/main/res/layout/stories_viewer_fragment_page.xml b/app/src/main/res/layout/stories_viewer_fragment_page.xml index 0ffb8942f7..f8289f6840 100644 --- a/app/src/main/res/layout/stories_viewer_fragment_page.xml +++ b/app/src/main/res/layout/stories_viewer_fragment_page.xml @@ -25,30 +25,12 @@ app:cardCornerRadius="18dp" app:cardElevation="0dp"> - - - - @Before fun setUp() { RxJavaPlugins.setInitComputationSchedulerHandler { testScheduler } RxJavaPlugins.setComputationSchedulerHandler { testScheduler } + + whenever(repository.getFirstStory(any(), any(), any())).doReturn(Single.just(mock())) } @After @@ -34,9 +52,6 @@ class StoryViewerViewModelTest { @Test fun `Given a list of recipients, when I initialize, then I expect the list`() { // GIVEN - val repoStories: List = (1L..5L).map(RecipientId::from) - whenever(repository.getStories(any(), any())).doReturn(Single.just(repoStories)) - val injectedStories: List = (6L..10L).map(RecipientId::from) // WHEN diff --git a/app/src/test/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModelTest.kt index cfeff53b8f..60c25fb0d6 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModelTest.kt @@ -167,7 +167,8 @@ class StoryViewerPageViewModelTest { RecipientId.from(1), -1L, false, - repository + repository, + mock() ) }