mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-21 02:08:40 +00:00
Implement several caching improvements for the Story Viewer.
This commit is contained in:
@@ -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<StoryResult> 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<RecipientId> getUnreadStoryThreadRecipientIds();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class MyStoriesRepository(context: Context) {
|
||||
return Observable.create { emitter ->
|
||||
fun refresh() {
|
||||
val storiesMap = mutableMapOf<Recipient, List<MessageRecord>>()
|
||||
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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<MmsMessageRecord> {
|
||||
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<List<RecipientId>> {
|
||||
return Single.create<List<RecipientId>> { emitter ->
|
||||
val myStoriesId = SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Boolean> = MutableLiveData(false)
|
||||
val isScrolling: LiveData<Boolean> = scrollStatePublisher
|
||||
|
||||
private val childScrollStatePublisher: MutableLiveData<Boolean> = MutableLiveData(false)
|
||||
val isChildScrolling: LiveData<Boolean> = childScrollStatePublisher
|
||||
private val childScrollStatePublisher: BehaviorSubject<Boolean> = BehaviorSubject.createDefault(false)
|
||||
val allowParentScrolling: Observable<Boolean> = 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(
|
||||
|
||||
@@ -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<Uri, StoryCacheValue>()
|
||||
|
||||
/**
|
||||
* 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<Attachment>) {
|
||||
val prefetchableAttachments: List<Attachment> = 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<Uri, StoryCacheValue> = 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<Drawable>(size.width, size.height) {
|
||||
|
||||
private var resource: Drawable? = null
|
||||
private var isFailed: Boolean = false
|
||||
|
||||
private val listeners = mutableSetOf<Listener>()
|
||||
|
||||
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<in Drawable>?) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Drawable> {
|
||||
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
|
||||
blurState = LoadState.FAILED
|
||||
notifyListeners()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, 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<Drawable> {
|
||||
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
|
||||
imageState = LoadState.FAILED
|
||||
notifyListeners()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, 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<MediaPreviewFragment.Events>().mediaNotAvailable()
|
||||
} else {
|
||||
requireListener<MediaPreviewFragment.Events>().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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Int, Long> = 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)
|
||||
|
||||
@@ -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<MessageRecord>()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<StoryPost.Content.AttachmentContent>()
|
||||
.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 <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(StoryViewerPageViewModel(recipientId, initialStoryId, isUnviewedOnly, repository)) as T
|
||||
return modelClass.cast(StoryViewerPageViewModel(recipientId, initialStoryId, isUnviewedOnly, repository, storyCache)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,8 +231,6 @@ class StoriesSharedElementCrossFaderView @JvmOverloads constructor(
|
||||
return
|
||||
}
|
||||
|
||||
animate().alpha(0f)
|
||||
|
||||
callback?.onAnimationFinished()
|
||||
}
|
||||
|
||||
|
||||
18
app/src/main/res/layout/stories_image_content_fragment.xml
Normal file
18
app/src/main/res/layout/stories_image_content_fragment.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<ImageView
|
||||
android:id="@+id/blur"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitCenter" />
|
||||
</FrameLayout>
|
||||
@@ -1,6 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.viewpager2.widget.ViewPager2 xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/story_item_pager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical" />
|
||||
android:orientation="vertical" />
|
||||
|
||||
<org.thoughtcrime.securesms.stories.viewer.reply.StoriesSharedElementCrossFaderView
|
||||
android:id="@+id/story_content_crossfader"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:alpha="0"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:transitionName="story"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintDimensionRatio="9:16"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -25,30 +25,12 @@
|
||||
app:cardCornerRadius="18dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/story_blur_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/story_content_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:background="@drawable/test_gradient" />
|
||||
|
||||
<org.thoughtcrime.securesms.stories.viewer.reply.StoriesSharedElementCrossFaderView
|
||||
android:id="@+id/story_content_crossfader"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:alpha="0"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:transitionName="story"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp" />
|
||||
|
||||
<View
|
||||
android:id="@+id/story_gradient_top"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -6,7 +6,12 @@ import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.mockito.Mock
|
||||
import org.mockito.MockedStatic
|
||||
import org.mockito.junit.MockitoJUnit
|
||||
import org.mockito.junit.MockitoRule
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
@@ -14,16 +19,29 @@ import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.stories.StoryViewerArgs
|
||||
|
||||
class StoryViewerViewModelTest {
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val mockitoRule: MockitoRule = MockitoJUnit.rule()
|
||||
|
||||
private val testScheduler = TestScheduler()
|
||||
private val repository: StoryViewerRepository = mock()
|
||||
|
||||
@Mock
|
||||
private lateinit var repository: StoryViewerRepository
|
||||
|
||||
@Mock
|
||||
private lateinit var mockStoriesStatic: MockedStatic<Stories>
|
||||
|
||||
@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<RecipientId> = (1L..5L).map(RecipientId::from)
|
||||
whenever(repository.getStories(any(), any())).doReturn(Single.just(repoStories))
|
||||
|
||||
val injectedStories: List<RecipientId> = (6L..10L).map(RecipientId::from)
|
||||
|
||||
// WHEN
|
||||
|
||||
@@ -167,7 +167,8 @@ class StoryViewerPageViewModelTest {
|
||||
RecipientId.from(1),
|
||||
-1L,
|
||||
false,
|
||||
repository
|
||||
repository,
|
||||
mock()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user