Implement several caching improvements for the Story Viewer.

This commit is contained in:
Alex Hart
2022-07-05 12:38:35 -03:00
parent 8f85b58612
commit 32312da384
25 changed files with 603 additions and 116 deletions

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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)
}

View File

@@ -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();
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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

View File

@@ -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(

View File

@@ -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()
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -231,8 +231,6 @@ class StoriesSharedElementCrossFaderView @JvmOverloads constructor(
return
}
animate().alpha(0f)
callback?.onAnimationFinished()
}