Move story post display logic into a single fragment.

This commit is contained in:
Alex Hart
2022-10-12 10:02:27 -03:00
committed by GitHub
parent 96d60e11b0
commit e1c6dfb73b
18 changed files with 488 additions and 312 deletions

View File

@@ -28,15 +28,21 @@ data class StoryPost(
override val transferState: Int = attachment.transferState
override fun isVideo(): Boolean = MediaUtil.isVideo(attachment)
override fun isText(): Boolean = false
}
class TextContent(uri: Uri, val recordId: Long, hasBody: Boolean, val length: Int) : Content(uri) {
override val transferState: Int = if (hasBody) AttachmentDatabase.TRANSFER_PROGRESS_DONE else AttachmentDatabase.TRANSFER_PROGRESS_FAILED
override fun isVideo(): Boolean = false
override fun isText(): Boolean = true
}
abstract val transferState: Int
abstract fun isVideo(): Boolean
abstract fun isText(): Boolean
}
}

View File

@@ -10,7 +10,6 @@ import android.graphics.RenderEffect
import android.graphics.Shader
import android.graphics.drawable.Drawable
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.method.ScrollingMovementMethod
@@ -71,11 +70,11 @@ import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
import org.thoughtcrime.securesms.stories.viewer.StoryViewerViewModel
import org.thoughtcrime.securesms.stories.viewer.StoryVolumeViewModel
import org.thoughtcrime.securesms.stories.viewer.info.StoryInfoBottomSheetDialogFragment
import org.thoughtcrime.securesms.stories.viewer.post.StoryPostFragment
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
import org.thoughtcrime.securesms.stories.viewer.reply.tabs.StoryViewsAndRepliesDialogFragment
import org.thoughtcrime.securesms.stories.viewer.text.StoryTextPostPreviewFragment
import org.thoughtcrime.securesms.stories.viewer.views.StoryViewsBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.AvatarUtil
import org.thoughtcrime.securesms.util.BottomSheetUtil
@@ -95,10 +94,9 @@ import kotlin.math.min
class StoryViewerPageFragment :
Fragment(R.layout.stories_viewer_fragment_page),
MediaPreviewFragment.Events,
StoryPostFragment.Callback,
MultiselectForwardBottomSheet.Callback,
StorySlateView.Callback,
StoryTextPostPreviewFragment.Callback,
StoryFirstTimeNavigationView.Callback,
StoryInfoBottomSheetDialogFragment.OnInfoSheetDismissedListener,
SafetyNumberBottomSheet.Callbacks {
@@ -232,7 +230,7 @@ class StoryViewerPageFragment :
scaleListener
)
cardWrapper.setOnInterceptTouchEventListener { !storySlate.state.hasClickableContent && childFragmentManager.findFragmentById(R.id.story_content_container) !is StoryTextPostPreviewFragment }
cardWrapper.setOnInterceptTouchEventListener { !storySlate.state.hasClickableContent && viewModel.getPost()?.content?.isText() != true }
cardWrapper.setOnTouchListener { _, event ->
scaleDetector.onTouchEvent(event)
val result = if (scaleDetector.isInProgress || scaleListener.isPerformingEndAnimation) {
@@ -711,16 +709,6 @@ class StoryViewerPageFragment :
}
private fun presentStory(post: StoryPost, index: Int) {
val fragment = childFragmentManager.findFragmentById(R.id.story_content_container)
if (fragment != null && fragment.requireArguments().getParcelable<Uri>(MediaPreviewFragment.DATA_URI) == post.content.uri) {
progressBar.setPosition(index)
return
}
if (fragment is MediaPreviewFragment) {
fragment.cleanUp()
}
if (post.content.uri == null) {
progressBar.setPosition(index)
progressBar.invalidate()
@@ -728,9 +716,6 @@ class StoryViewerPageFragment :
progressBar.setPosition(index)
storySlate.moveToState(StorySlateView.State.HIDDEN, post.id)
viewModel.setIsDisplayingSlate(false)
childFragmentManager.beginTransaction()
.replace(R.id.story_content_container, createFragmentForPost(post))
.commitNow()
}
}
@@ -1012,21 +997,6 @@ class StoryViewerPageFragment :
}
}
private fun createFragmentForPost(storyPost: StoryPost): Fragment {
return when (storyPost.content) {
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)
}
@@ -1306,15 +1276,11 @@ class StoryViewerPageFragment :
}
}
override fun singleTapOnMedia(): Boolean {
return false
}
override fun onMediaReady() {
override fun onContentReady() {
sharedViewModel.setContentIsReady()
}
override fun mediaNotAvailable() {
override fun onContentNotAvailable() {
sharedViewModel.setContentIsReady()
}

View File

@@ -42,6 +42,9 @@ class StoryViewerPageViewModel(
val groupDirectReplyObservable: Observable<Optional<StoryViewerDialog>> = storyViewerDialogSubject
val state: Flowable<StoryViewerPageState> = store.stateFlowable
val postContent: Flowable<Optional<StoryPost.Content>> = store.stateFlowable.map {
Optional.ofNullable(it.posts.getOrNull(it.selectedPostIndex)?.content)
}
fun getStateSnapshot(): StoryViewerPageState = store.state

View File

@@ -1,12 +1,7 @@
package org.thoughtcrime.securesms.stories.viewer.page
package org.thoughtcrime.securesms.stories.viewer.post
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
@@ -14,29 +9,35 @@ 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
import org.thoughtcrime.securesms.stories.viewer.page.StoryCache
import org.thoughtcrime.securesms.stories.viewer.page.StoryDisplay
class StoryImageContentFragment : Fragment(R.layout.stories_image_content_fragment) {
/**
* Render logic for story image posts
*/
class StoryImageLoader(
private val fragment: StoryPostFragment,
private val imagePost: StoryPostState.ImagePost,
private val storyCache: StoryCache,
private val storySize: StoryDisplay.Size,
private val postImage: ImageView,
private val blurImage: ImageView,
private val callback: StoryPostFragment.Callback
) {
companion object {
private val TAG = Log.tag(StoryImageLoader::class.java)
}
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)
postImage.setImageDrawable(resource)
imageState = LoadState.READY
notifyListeners()
}
@@ -49,7 +50,7 @@ class StoryImageContentFragment : Fragment(R.layout.stories_image_content_fragme
private val blurListener = object : StoryCache.Listener {
override fun onResourceReady(resource: Drawable) {
blur.setImageDrawable(resource)
blurImage.setImageDrawable(resource)
blurState = LoadState.READY
notifyListeners()
}
@@ -60,28 +61,26 @@ class StoryImageContentFragment : Fragment(R.layout.stories_image_content_fragme
}
}
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)
fun load() {
val cacheValue = storyCache.getFromCache(imagePost.imageUri)
if (cacheValue != null) {
loadViaCache(cacheValue)
} else {
loadViaGlide(blurHash, storySize)
loadViaGlide(imagePost.blurHash, storySize)
}
}
fun clear() {
GlideApp.with(postImage).clear(postImage)
GlideApp.with(blurImage).clear(blurImage)
}
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) })
fragment.viewLifecycleOwner.lifecycle.addObserver(OnDestroy { blurTarget.removeListener(blurListener) })
} else {
blurState = LoadState.FAILED
notifyListeners()
@@ -89,13 +88,13 @@ class StoryImageContentFragment : Fragment(R.layout.stories_image_content_fragme
val imageTarget = cacheValue.imageTarget
imageTarget.addListener(imageListener)
viewLifecycleOwner.lifecycle.addObserver(OnDestroy { imageTarget.removeListener(blurListener) })
fragment.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)
GlideApp.with(blurImage)
.load(blurHash)
.override(storySize.width, storySize.height)
.addListener(object : RequestListener<Drawable> {
@@ -111,14 +110,14 @@ class StoryImageContentFragment : Fragment(R.layout.stories_image_content_fragme
return false
}
})
.into(blur)
.into(blurImage)
} else {
blurState = LoadState.FAILED
notifyListeners()
}
GlideApp.with(imageView)
.load(DecryptableStreamUriLoader.DecryptableUri(requireArguments().getParcelable(URI)!!))
GlideApp.with(postImage)
.load(DecryptableStreamUriLoader.DecryptableUri(imagePost.imageUri))
.override(storySize.width, storySize.height)
.addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
@@ -133,20 +132,20 @@ class StoryImageContentFragment : Fragment(R.layout.stories_image_content_fragme
return false
}
})
.into(imageView)
.into(postImage)
}
private fun notifyListeners() {
if (isDetached) {
if (fragment.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()
callback.onContentNotAvailable()
} else {
requireListener<MediaPreviewFragment.Events>().onMediaReady()
callback.onContentReady()
}
}
}
@@ -157,26 +156,9 @@ class StoryImageContentFragment : Fragment(R.layout.stories_image_content_fragme
}
}
enum class LoadState {
private 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

@@ -0,0 +1,176 @@
package org.thoughtcrime.securesms.stories.viewer.post
import android.app.Activity
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.databinding.StoriesPostFragmentBinding
import org.thoughtcrime.securesms.mediapreview.VideoControlsDelegate
import org.thoughtcrime.securesms.stories.viewer.page.StoryDisplay
import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageViewModel
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.video.VideoPlayer.PlayerCallback
/**
* Renders a given StoryPost object as a viewable story.
*/
class StoryPostFragment : Fragment(R.layout.stories_post_fragment) {
private val postViewModel: StoryPostViewModel by viewModels(factoryProducer = {
StoryPostViewModel.Factory(StoryTextPostRepository())
})
private val pageViewModel: StoryViewerPageViewModel by viewModels(ownerProducer = {
requireParentFragment()
})
private val binding by ViewBinderDelegate(StoriesPostFragmentBinding::bind) {
presentNone()
}
private val disposables = LifecycleDisposable()
private var storyImageLoader: StoryImageLoader? = null
private var storyTextLoader: StoryTextLoader? = null
private var storyVideoLoader: StoryVideoLoader? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initializeVideoPlayer()
disposables.bindTo(viewLifecycleOwner)
disposables += pageViewModel.postContent
.filter { it.isPresent }
.map { it.get() }
.distinctUntilChanged()
.subscribe {
postViewModel.onPostContentChanged(it)
}
disposables += postViewModel.state.subscribe { state ->
when (state) {
is StoryPostState.None -> presentNone()
is StoryPostState.TextPost -> presentTextPost(state)
is StoryPostState.VideoPost -> presentVideoPost(state)
is StoryPostState.ImagePost -> presentImagePost(state)
}
}
}
private fun initializeVideoPlayer() {
binding.video.setWindow(requireActivity().window)
binding.video.setPlayerPositionDiscontinuityCallback { _, r: Int ->
requireCallback().getVideoControlsDelegate()?.onPlayerPositionDiscontinuity(r)
}
binding.video.setPlayerCallback(object : PlayerCallback {
override fun onReady() {
requireCallback().onContentReady()
}
override fun onPlaying() {
val activity: Activity? = activity
if (activity is VoiceNoteMediaControllerOwner) {
(activity as VoiceNoteMediaControllerOwner).voiceNoteMediaController.pausePlayback()
}
}
override fun onStopped() {}
override fun onError() {
requireCallback().onContentNotAvailable()
}
})
}
private fun presentNone() {
storyImageLoader?.clear()
storyImageLoader = null
storyVideoLoader?.clear()
storyVideoLoader = null
storyTextLoader = null
binding.text.visible = false
binding.blur.visible = false
binding.image.visible = false
binding.video.visible = false
}
private fun presentVideoPost(state: StoryPostState.VideoPost) {
presentNone()
binding.video.visible = true
storyVideoLoader = StoryVideoLoader(
this,
state,
binding.video,
requireCallback()
)
storyVideoLoader?.load()
}
private fun presentImagePost(state: StoryPostState.ImagePost) {
presentNone()
binding.image.visible = true
binding.blur.visible = true
storyImageLoader = StoryImageLoader(
this,
state,
pageViewModel.storyCache,
StoryDisplay.getStorySize(resources),
binding.image,
binding.blur,
requireCallback()
)
storyImageLoader?.load()
}
private fun presentTextPost(state: StoryPostState.TextPost) {
presentNone()
if (state.loadState == StoryPostState.LoadState.FAILED) {
requireCallback().onContentNotAvailable()
return
}
if (state.loadState == StoryPostState.LoadState.INIT) {
return
}
binding.text.visible = true
storyTextLoader = StoryTextLoader(
this,
binding.text,
state,
requireCallback()
)
storyTextLoader?.load()
}
fun requireCallback(): Callback {
return requireListener()
}
interface Callback {
fun onContentReady()
fun onContentNotAvailable()
fun setIsDisplayingLinkPreviewTooltip(isDisplayingLinkPreviewTooltip: Boolean)
fun getVideoControlsDelegate(): VideoControlsDelegate?
}
}

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.stories.viewer.post
import android.graphics.Typeface
import android.net.Uri
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.linkpreview.LinkPreview
sealed class StoryPostState {
data class TextPost(
val storyTextPost: StoryTextPost? = null,
val linkPreview: LinkPreview? = null,
val typeface: Typeface? = null,
val loadState: LoadState = LoadState.INIT
) : StoryPostState()
data class ImagePost(
val imageUri: Uri,
val blurHash: BlurHash?
) : StoryPostState()
data class VideoPost(
val videoUri: Uri,
val size: Long
) : StoryPostState()
data class None(private val ts: Long = System.currentTimeMillis()) : StoryPostState()
enum class LoadState {
INIT,
LOADED,
FAILED
}
}

View File

@@ -0,0 +1,93 @@
package org.thoughtcrime.securesms.stories.viewer.post
import android.graphics.Typeface
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.stories.viewer.page.StoryPost
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.rx.RxStore
class StoryPostViewModel(private val repository: StoryTextPostRepository) : ViewModel() {
companion object {
val TAG = Log.tag(StoryPostViewModel::class.java)
}
private val store: RxStore<StoryPostState> = RxStore(StoryPostState.None())
private val disposables = CompositeDisposable()
val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
override fun onCleared() {
store.dispose()
disposables.clear()
}
fun onPostContentChanged(storyPostContent: StoryPost.Content) {
disposables.clear()
when (storyPostContent) {
is StoryPost.Content.AttachmentContent -> {
if (storyPostContent.uri == null) {
store.update { StoryPostState.None() }
} else if (storyPostContent.isVideo()) {
store.update { StoryPostState.VideoPost(videoUri = storyPostContent.uri, storyPostContent.attachment.size) }
} else {
store.update { StoryPostState.ImagePost(storyPostContent.uri, storyPostContent.attachment.blurHash) }
}
}
is StoryPost.Content.TextContent -> {
loadTextContent(storyPostContent.recordId)
}
}
}
private fun loadTextContent(recordId: Long) {
val typeface = repository.getTypeface(recordId)
.doOnError { Log.w(TAG, "Failed to get typeface. Rendering with default.", it) }
.onErrorReturn { Typeface.DEFAULT }
val postAndPreviews = repository.getRecord(recordId)
.map {
if (it.body.isNotEmpty()) {
StoryTextPost.parseFrom(Base64.decode(it.body)) to it.linkPreviews.firstOrNull()
} else {
throw Exception("Text post message body is empty.")
}
}
disposables += Single.zip(typeface, postAndPreviews, ::Pair).subscribeBy(
onSuccess = { (t, p) ->
store.update {
StoryPostState.TextPost(
storyTextPost = p.first,
linkPreview = p.second,
typeface = t,
loadState = StoryPostState.LoadState.LOADED
)
}
},
onError = {
Log.d(TAG, "Couldn't load text post", it)
store.update {
StoryPostState.TextPost(
loadState = StoryPostState.LoadState.FAILED
)
}
}
)
}
class Factory(private val repository: StoryTextPostRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(StoryPostViewModel(repository)) as T
}
}
}

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.stories.viewer.post
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.stories.StoryTextPostView
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.FragmentDialogs.displayInDialogAboveAnchor
/**
* Render logic for story text posts
*/
class StoryTextLoader(
private val fragment: StoryPostFragment,
private val text: StoryTextPostView,
private val state: StoryPostState.TextPost,
private val callback: StoryPostFragment.Callback
) {
fun load() {
text.bindFromStoryTextPost(state.storyTextPost!!)
text.bindLinkPreview(state.linkPreview, state.storyTextPost.body.isBlank())
text.postAdjustLinkPreviewTranslationY()
if (state.linkPreview != null) {
text.setLinkPreviewClickListener {
showLinkPreviewTooltip(it, state.linkPreview)
}
} else {
text.setLinkPreviewClickListener(null)
}
if (state.typeface != null) {
text.setTypeface(state.typeface)
}
if (state.typeface != null && state.loadState == StoryPostState.LoadState.LOADED) {
callback.onContentReady()
}
}
private fun showLinkPreviewTooltip(view: View, linkPreview: LinkPreview) {
callback.setIsDisplayingLinkPreviewTooltip(true)
val contentView = LayoutInflater.from(fragment.requireContext()).inflate(R.layout.stories_link_popup, null, false)
contentView.findViewById<TextView>(R.id.url).text = linkPreview.url
contentView.setOnClickListener {
CommunicationActions.openBrowserLink(fragment.requireContext(), linkPreview.url)
}
contentView.measure(
View.MeasureSpec.makeMeasureSpec(DimensionUnit.DP.toPixels(275f).toInt(), View.MeasureSpec.EXACTLY),
0
)
contentView.layout(0, 0, contentView.measuredWidth, contentView.measuredHeight)
fragment.displayInDialogAboveAnchor(view, contentView, windowDim = 0f, onDismiss = {
callback.setIsDisplayingLinkPreviewTooltip(false)
})
}
}

View File

@@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.stories.viewer.text
package org.thoughtcrime.securesms.stories.viewer.post
import android.graphics.Typeface
import io.reactivex.rxjava3.core.Single

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.stories.viewer.post
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.mms.VideoSlide
import org.thoughtcrime.securesms.video.VideoPlayer
/**
* Render logic for story video posts
*/
class StoryVideoLoader(
private val fragment: StoryPostFragment,
private val videoPost: StoryPostState.VideoPost,
private val videoPlayer: VideoPlayer,
private val callback: StoryPostFragment.Callback
) : DefaultLifecycleObserver {
companion object {
private val TAG = Log.tag(StoryVideoLoader::class.java)
}
fun load() {
fragment.viewLifecycleOwner.lifecycle.addObserver(this)
videoPlayer.setVideoSource(VideoSlide(fragment.requireContext(), videoPost.videoUri, videoPost.size, false), true, TAG)
}
fun clear() {
fragment.viewLifecycleOwner.lifecycle.removeObserver(this)
videoPlayer.cleanup()
}
override fun onResume(lifecycleOwner: LifecycleOwner) {
callback.getVideoControlsDelegate()?.attachPlayer(videoPost.videoUri, videoPlayer, false)
}
override fun onPause(lifecycleOwner: LifecycleOwner) {
callback.getVideoControlsDelegate()?.detachPlayer()
}
}

View File

@@ -1,99 +0,0 @@
package org.thoughtcrime.securesms.stories.viewer.text
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment
import org.thoughtcrime.securesms.stories.StoryTextPostView
import org.thoughtcrime.securesms.stories.viewer.page.StoryPost
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.FragmentDialogs.displayInDialogAboveAnchor
import org.thoughtcrime.securesms.util.fragments.requireListener
class StoryTextPostPreviewFragment : Fragment(R.layout.stories_text_post_preview_fragment) {
companion object {
private const val STORY_ID = "STORY_ID"
fun create(content: StoryPost.Content.TextContent): Fragment {
return StoryTextPostPreviewFragment().apply {
arguments = Bundle().apply {
putParcelable(MediaPreviewFragment.DATA_URI, content.uri)
putLong(STORY_ID, content.recordId)
}
}
}
}
private val viewModel: StoryTextPostViewModel by viewModels(
factoryProducer = {
StoryTextPostViewModel.Factory(requireArguments().getLong(STORY_ID), StoryTextPostRepository())
}
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val storyTextPostView: StoryTextPostView = view.findViewById(R.id.story_text_post)
viewModel.state.observe(viewLifecycleOwner) { state ->
when (state.loadState) {
StoryTextPostState.LoadState.INIT -> Unit
StoryTextPostState.LoadState.LOADED -> {
storyTextPostView.bindFromStoryTextPost(state.storyTextPost!!)
storyTextPostView.bindLinkPreview(state.linkPreview, state.storyTextPost.body.isBlank())
storyTextPostView.postAdjustLinkPreviewTranslationY()
if (state.linkPreview != null) {
storyTextPostView.setLinkPreviewClickListener {
showLinkPreviewTooltip(it, state.linkPreview)
}
} else {
storyTextPostView.setLinkPreviewClickListener(null)
}
}
StoryTextPostState.LoadState.FAILED -> {
requireListener<MediaPreviewFragment.Events>().mediaNotAvailable()
}
}
if (state.typeface != null) {
storyTextPostView.setTypeface(state.typeface)
}
if (state.typeface != null && state.loadState == StoryTextPostState.LoadState.LOADED) {
requireListener<MediaPreviewFragment.Events>().onMediaReady()
}
}
}
@SuppressLint("AlertDialogBuilderUsage")
private fun showLinkPreviewTooltip(view: View, linkPreview: LinkPreview) {
requireListener<Callback>().setIsDisplayingLinkPreviewTooltip(true)
val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.stories_link_popup, null, false)
contentView.findViewById<TextView>(R.id.url).text = linkPreview.url
contentView.setOnClickListener {
CommunicationActions.openBrowserLink(requireContext(), linkPreview.url)
}
contentView.measure(
View.MeasureSpec.makeMeasureSpec(DimensionUnit.DP.toPixels(275f).toInt(), View.MeasureSpec.EXACTLY),
0
)
contentView.layout(0, 0, contentView.measuredWidth, contentView.measuredHeight)
displayInDialogAboveAnchor(view, contentView, windowDim = 0f)
}
interface Callback {
fun setIsDisplayingLinkPreviewTooltip(isDisplayingLinkPreviewTooltip: Boolean)
}
}

View File

@@ -1,18 +0,0 @@
package org.thoughtcrime.securesms.stories.viewer.text
import android.graphics.Typeface
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.linkpreview.LinkPreview
data class StoryTextPostState(
val storyTextPost: StoryTextPost? = null,
val linkPreview: LinkPreview? = null,
val loadState: LoadState = LoadState.INIT,
val typeface: Typeface? = null
) {
enum class LoadState {
INIT,
LOADED,
FAILED
}
}

View File

@@ -1,79 +0,0 @@
package org.thoughtcrime.securesms.stories.viewer.text
import android.graphics.Typeface
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.livedata.Store
class StoryTextPostViewModel(recordId: Long, repository: StoryTextPostRepository) : ViewModel() {
companion object {
private val TAG = Log.tag(StoryTextPostViewModel::class.java)
}
private val store = Store(StoryTextPostState())
private val disposables = CompositeDisposable()
val state: LiveData<StoryTextPostState> = store.stateLiveData
init {
disposables += repository.getTypeface(recordId)
.subscribeBy(
onSuccess = { typeface ->
store.update {
it.copy(typeface = typeface)
}
},
onError = { error ->
Log.w(TAG, "Failed to get typeface. Rendering with default.", error)
store.update {
it.copy(typeface = Typeface.DEFAULT)
}
}
)
disposables += repository.getRecord(recordId)
.map {
if (it.body.isNotEmpty()) {
StoryTextPost.parseFrom(Base64.decode(it.body)) to it.linkPreviews.firstOrNull()
} else {
throw Exception("Text post message body is empty.")
}
}
.subscribeBy(
onSuccess = { (post, previews) ->
store.update { state ->
state.copy(
storyTextPost = post,
linkPreview = previews,
loadState = StoryTextPostState.LoadState.LOADED
)
}
},
onError = {
store.update { state ->
state.copy(
loadState = StoryTextPostState.LoadState.FAILED
)
}
}
)
}
override fun onCleared() {
disposables.clear()
}
class Factory(private val recordId: Long, private val repository: StoryTextPostRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(StoryTextPostViewModel(recordId, repository)) as T
}
}
}

View File

@@ -10,8 +10,6 @@ import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.stories.viewer.text.StoryTextPostPreviewFragment
import org.thoughtcrime.securesms.util.fragments.requireListener
/**
* Helper functions to display custom views in AlertDialogs anchored to the top of the specified view.
@@ -40,7 +38,8 @@ object FragmentDialogs {
anchorView: View,
contentView: View,
windowDim: Float = -1f,
onShow: (DialogInterface, View) -> Unit = { _, _ -> }
onShow: (DialogInterface, View) -> Unit = { _, _ -> },
onDismiss: (DialogInterface) -> Unit = { }
): DialogInterface {
val alertDialog = AlertDialog.Builder(requireContext())
.setView(contentView)
@@ -59,9 +58,7 @@ object FragmentDialogs {
}
alertDialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
alertDialog.setOnDismissListener {
requireListener<StoryTextPostPreviewFragment.Callback>().setIsDisplayingLinkPreviewTooltip(false)
}
alertDialog.setOnDismissListener(onDismiss)
alertDialog.setOnShowListener { onShow(alertDialog, contentView) }

View File

@@ -65,6 +65,7 @@ public class VideoPlayer extends FrameLayout {
private long clippedStartUs;
private ExoPlayerListener exoPlayerListener;
private Player.Listener playerListener;
private boolean muted;
public VideoPlayer(Context context) {
this(context, null);
@@ -130,6 +131,9 @@ public class VideoPlayer extends FrameLayout {
exoPlayer.addListener(playerListener);
exoView.setPlayer(exoPlayer);
exoControls.setPlayer(exoPlayer);
if (muted) {
mute();
}
}
mediaItem = MediaItem.fromUri(Objects.requireNonNull(videoSource.getUri()));
@@ -139,12 +143,14 @@ public class VideoPlayer extends FrameLayout {
}
public void mute() {
this.muted = true;
if (exoPlayer != null && exoPlayer.getAudioComponent() != null) {
exoPlayer.getAudioComponent().setVolume(0f);
}
}
public void unmute() {
this.muted = false;
if (exoPlayer != null && exoPlayer.getAudioComponent() != null) {
exoPlayer.getAudioComponent().setVolume(1f);
}

View File

@@ -1,12 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/blur"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="centerCrop" />
@@ -17,4 +16,15 @@
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitCenter" />
</FrameLayout>
<org.thoughtcrime.securesms.stories.StoryTextPostView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<org.thoughtcrime.securesms.video.VideoPlayer
android:id="@+id/video"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.stories.StoryTextPostView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
android:id="@+id/story_text_post"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />

View File

@@ -32,6 +32,7 @@
<androidx.fragment.app.FragmentContainerView
android:id="@+id/story_content_container"
android:name="org.thoughtcrime.securesms.stories.viewer.post.StoryPostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:background="@drawable/test_gradient" />