mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 21:15:48 +00:00
Move story post display logic into a single fragment.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user