diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt index a6cba12c07..dfec8f041d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryPost.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index 8993d77d68..fa33d42fbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -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(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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt index 0601202476..27790553af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt @@ -42,6 +42,9 @@ class StoryViewerPageViewModel( val groupDirectReplyObservable: Observable> = storyViewerDialogSubject val state: Flowable = store.stateFlowable + val postContent: Flowable> = store.stateFlowable.map { + Optional.ofNullable(it.posts.getOrNull(it.selectedPostIndex)?.content) + } fun getStateSnapshot(): StoryViewerPageState = store.state diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryImageContentFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryImageLoader.kt similarity index 61% rename from app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryImageContentFragment.kt rename to app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryImageLoader.kt index 5f3ed7d642..3d0a7d7441 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryImageContentFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryImageLoader.kt @@ -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 { @@ -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 { override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, 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().mediaNotAvailable() + callback.onContentNotAvailable() } else { - requireListener().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) - } - } - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostFragment.kt new file mode 100644 index 0000000000..e77aa97287 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostFragment.kt @@ -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? + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostState.kt new file mode 100644 index 0000000000..86f6bc822b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostState.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostViewModel.kt new file mode 100644 index 0000000000..6d06eb6d4f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostViewModel.kt @@ -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 = 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 create(modelClass: Class): T { + return modelClass.cast(StoryPostViewModel(repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryTextLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryTextLoader.kt new file mode 100644 index 0000000000..4b703aa08e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryTextLoader.kt @@ -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(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) + }) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryTextPostRepository.kt similarity index 95% rename from app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostRepository.kt rename to app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryTextPostRepository.kt index d2774f629f..5149610b04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryTextPostRepository.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryVideoLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryVideoLoader.kt new file mode 100644 index 0000000000..6b7292b2bc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryVideoLoader.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostPreviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostPreviewFragment.kt deleted file mode 100644 index c9e2787bc4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostPreviewFragment.kt +++ /dev/null @@ -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().mediaNotAvailable() - } - } - - if (state.typeface != null) { - storyTextPostView.setTypeface(state.typeface) - } - - if (state.typeface != null && state.loadState == StoryTextPostState.LoadState.LOADED) { - requireListener().onMediaReady() - } - } - } - - @SuppressLint("AlertDialogBuilderUsage") - private fun showLinkPreviewTooltip(view: View, linkPreview: LinkPreview) { - requireListener().setIsDisplayingLinkPreviewTooltip(true) - - val contentView = LayoutInflater.from(requireContext()).inflate(R.layout.stories_link_popup, null, false) - - contentView.findViewById(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) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostState.kt deleted file mode 100644 index 0bf2e52f81..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostState.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostViewModel.kt deleted file mode 100644 index 3fdc5c9524..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/text/StoryTextPostViewModel.kt +++ /dev/null @@ -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 = 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 create(modelClass: Class): T { - return modelClass.cast(StoryTextPostViewModel(recordId, repository)) as T - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FragmentDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/util/FragmentDialogs.kt index 5ebd0c47a8..bccdd277b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FragmentDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FragmentDialogs.kt @@ -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().setIsDisplayingLinkPreviewTooltip(false) - } + alertDialog.setOnDismissListener(onDismiss) alertDialog.setOnShowListener { onShow(alertDialog, contentView) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java index 00892a7ba8..b8e29b58e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java @@ -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); } diff --git a/app/src/main/res/layout/stories_image_content_fragment.xml b/app/src/main/res/layout/stories_post_fragment.xml similarity index 63% rename from app/src/main/res/layout/stories_image_content_fragment.xml rename to app/src/main/res/layout/stories_post_fragment.xml index 81397ad49c..794ae043ab 100644 --- a/app/src/main/res/layout/stories_image_content_fragment.xml +++ b/app/src/main/res/layout/stories_post_fragment.xml @@ -1,12 +1,11 @@ + @@ -17,4 +16,15 @@ android:layout_height="match_parent" android:importantForAccessibility="no" android:scaleType="fitCenter" /> - + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_text_post_preview_fragment.xml b/app/src/main/res/layout/stories_text_post_preview_fragment.xml deleted file mode 100644 index b128639e9b..0000000000 --- a/app/src/main/res/layout/stories_text_post_preview_fragment.xml +++ /dev/null @@ -1,8 +0,0 @@ - - diff --git a/app/src/main/res/layout/stories_viewer_fragment_page.xml b/app/src/main/res/layout/stories_viewer_fragment_page.xml index 1cee95909c..7329faf53e 100644 --- a/app/src/main/res/layout/stories_viewer_fragment_page.xml +++ b/app/src/main/res/layout/stories_viewer_fragment_page.xml @@ -32,6 +32,7 @@