mirror of
https://github.com/element-hq/element-android
synced 2024-11-28 05:31:21 +03:00
Adds proper handling of audio seek bar
This commit is contained in:
parent
34dcd70a64
commit
d0155c9890
9 changed files with 99 additions and 31 deletions
|
@ -105,7 +105,6 @@ import im.vector.app.core.utils.createJSonViewerStyleProvider
|
|||
import im.vector.app.core.utils.createUIHandler
|
||||
import im.vector.app.core.utils.isValidUrl
|
||||
import im.vector.app.core.utils.onPermissionDeniedDialog
|
||||
import im.vector.app.core.utils.onPermissionDeniedSnackbar
|
||||
import im.vector.app.core.utils.openLocation
|
||||
import im.vector.app.core.utils.openUrlInExternalBrowser
|
||||
import im.vector.app.core.utils.registerForPermissionsResult
|
||||
|
@ -2080,6 +2079,10 @@ class TimelineFragment @Inject constructor(
|
|||
messageComposerViewModel.handle(MessageComposerAction.VoiceWaveformMovedTo(eventId, duration, percentage))
|
||||
}
|
||||
|
||||
override fun onAudioSeekBarMovedTo(eventId: String, duration: Int, percentage: Float) {
|
||||
messageComposerViewModel.handle(MessageComposerAction.AudioSeekBarMovedTo(eventId, duration, percentage))
|
||||
}
|
||||
|
||||
private fun onShareActionClicked(action: EventSharedAction.Share) {
|
||||
when (action.messageContent) {
|
||||
is MessageTextContent -> shareText(requireContext(), action.messageContent.body)
|
||||
|
|
|
@ -40,12 +40,13 @@ import javax.inject.Inject
|
|||
/**
|
||||
* Helper class to record audio for voice messages.
|
||||
*/
|
||||
class VoiceMessageHelper @Inject constructor(
|
||||
class AudioMessageHelper @Inject constructor(
|
||||
private val context: Context,
|
||||
private val playbackTracker: AudioMessagePlaybackTracker,
|
||||
voiceRecorderProvider: VoiceRecorderProvider
|
||||
) {
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private var currentPlayingId: String? = null
|
||||
private var voiceRecorder: VoiceRecorder = voiceRecorderProvider.provideVoiceRecorder()
|
||||
|
||||
private val amplitudeList = mutableListOf<Int>()
|
||||
|
@ -136,6 +137,7 @@ class VoiceMessageHelper @Inject constructor(
|
|||
mediaPlayer?.stop()
|
||||
stopPlaybackTicker()
|
||||
stopRecordingAmplitudes()
|
||||
currentPlayingId = null
|
||||
if (playbackState is AudioMessagePlaybackTracker.Listener.State.Playing) {
|
||||
playbackTracker.pausePlayback(id)
|
||||
} else {
|
||||
|
@ -163,6 +165,7 @@ class VoiceMessageHelper @Inject constructor(
|
|||
seekTo(currentPlaybackTime)
|
||||
}
|
||||
}
|
||||
currentPlayingId = id
|
||||
} catch (failure: Throwable) {
|
||||
Timber.e(failure, "Unable to start playback")
|
||||
throw VoiceFailure.UnableToPlay(failure)
|
||||
|
@ -174,14 +177,21 @@ class VoiceMessageHelper @Inject constructor(
|
|||
playbackTracker.pausePlayback(AudioMessagePlaybackTracker.RECORDING_ID)
|
||||
mediaPlayer?.stop()
|
||||
stopPlaybackTicker()
|
||||
currentPlayingId = null
|
||||
}
|
||||
|
||||
fun movePlaybackTo(id: String, percentage: Float, totalDuration: Int) {
|
||||
val toMillisecond = (totalDuration * percentage).toInt()
|
||||
playbackTracker.updateCurrentPlaybackTime(id, toMillisecond, percentage)
|
||||
playbackTracker.pauseAllPlaybacks()
|
||||
|
||||
stopPlayback()
|
||||
playbackTracker.pausePlayback(id)
|
||||
if (currentPlayingId == id) {
|
||||
mediaPlayer?.seekTo(toMillisecond)
|
||||
playbackTracker.updatePlayingAtPlaybackTime(id, toMillisecond, percentage)
|
||||
} else {
|
||||
mediaPlayer?.pause()
|
||||
playbackTracker.updatePausedAtPlaybackTime(id, toMillisecond, percentage)
|
||||
stopPlaybackTicker()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRecordingAmplitudes() {
|
||||
|
@ -233,7 +243,7 @@ class VoiceMessageHelper @Inject constructor(
|
|||
val currentPosition = mediaPlayer?.currentPosition ?: 0
|
||||
val totalDuration = mediaPlayer?.duration ?: 0
|
||||
val percentage = currentPosition.toFloat() / totalDuration
|
||||
playbackTracker.updateCurrentPlaybackTime(id, currentPosition, percentage)
|
||||
playbackTracker.updatePlayingAtPlaybackTime(id, currentPosition, percentage)
|
||||
} else {
|
||||
playbackTracker.stopPlayback(id)
|
||||
stopPlaybackTicker()
|
|
@ -42,4 +42,5 @@ sealed class MessageComposerAction : VectorViewModelAction {
|
|||
data class EndAllVoiceActions(val deleteRecord: Boolean = true) : MessageComposerAction()
|
||||
data class VoiceWaveformTouchedUp(val eventId: String, val duration: Int, val percentage: Float) : MessageComposerAction()
|
||||
data class VoiceWaveformMovedTo(val eventId: String, val duration: Int, val percentage: Float) : MessageComposerAction()
|
||||
data class AudioSeekBarMovedTo(val eventId: String, val duration: Int, val percentage: Float) : MessageComposerAction()
|
||||
}
|
||||
|
|
|
@ -73,7 +73,7 @@ class MessageComposerViewModel @AssistedInject constructor(
|
|||
private val vectorPreferences: VectorPreferences,
|
||||
private val commandParser: CommandParser,
|
||||
private val rainbowGenerator: RainbowGenerator,
|
||||
private val voiceMessageHelper: VoiceMessageHelper,
|
||||
private val audioMessageHelper: AudioMessageHelper,
|
||||
private val analyticsTracker: AnalyticsTracker,
|
||||
private val voicePlayerHelper: VoicePlayerHelper
|
||||
) : VectorViewModel<MessageComposerViewState, MessageComposerAction, MessageComposerViewEvents>(initialState) {
|
||||
|
@ -90,7 +90,6 @@ class MessageComposerViewModel @AssistedInject constructor(
|
|||
}
|
||||
|
||||
override fun handle(action: MessageComposerAction) {
|
||||
Timber.v("Handle action: $action")
|
||||
when (action) {
|
||||
is MessageComposerAction.EnterEditMode -> handleEnterEditMode(action)
|
||||
is MessageComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action)
|
||||
|
@ -110,6 +109,7 @@ class MessageComposerViewModel @AssistedInject constructor(
|
|||
is MessageComposerAction.OnEntersBackground -> handleEntersBackground(action.composerText)
|
||||
is MessageComposerAction.VoiceWaveformTouchedUp -> handleVoiceWaveformTouchedUp(action)
|
||||
is MessageComposerAction.VoiceWaveformMovedTo -> handleVoiceWaveformMovedTo(action)
|
||||
is MessageComposerAction.AudioSeekBarMovedTo -> handleAudioSeekBarMovedTo(action)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -811,18 +811,18 @@ class MessageComposerViewModel @AssistedInject constructor(
|
|||
|
||||
private fun handleStartRecordingVoiceMessage() {
|
||||
try {
|
||||
voiceMessageHelper.startRecording(room.roomId)
|
||||
audioMessageHelper.startRecording(room.roomId)
|
||||
} catch (failure: Throwable) {
|
||||
_viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(failure))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEndRecordingVoiceMessage(isCancelled: Boolean, rootThreadEventId: String? = null) {
|
||||
voiceMessageHelper.stopPlayback()
|
||||
audioMessageHelper.stopPlayback()
|
||||
if (isCancelled) {
|
||||
voiceMessageHelper.deleteRecording()
|
||||
audioMessageHelper.deleteRecording()
|
||||
} else {
|
||||
voiceMessageHelper.stopRecording(convertForSending = true)?.let { audioType ->
|
||||
audioMessageHelper.stopRecording(convertForSending = true)?.let { audioType ->
|
||||
if (audioType.duration > 1000) {
|
||||
room.sendMedia(
|
||||
attachment = audioType.toContentAttachmentData(isVoiceMessage = true),
|
||||
|
@ -830,7 +830,7 @@ class MessageComposerViewModel @AssistedInject constructor(
|
|||
roomIds = emptySet(),
|
||||
rootThreadEventId = rootThreadEventId)
|
||||
} else {
|
||||
voiceMessageHelper.deleteRecording()
|
||||
audioMessageHelper.deleteRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -845,7 +845,7 @@ class MessageComposerViewModel @AssistedInject constructor(
|
|||
// Conversion can fail, fallback to the original file in this case and let the player fail for us
|
||||
val convertedFile = voicePlayerHelper.convertFile(audioFile) ?: audioFile
|
||||
// Play can fail
|
||||
voiceMessageHelper.startOrPausePlayback(action.eventId, convertedFile)
|
||||
audioMessageHelper.startOrPausePlayback(action.eventId, convertedFile)
|
||||
} catch (failure: Throwable) {
|
||||
_viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(failure))
|
||||
}
|
||||
|
@ -853,34 +853,38 @@ class MessageComposerViewModel @AssistedInject constructor(
|
|||
}
|
||||
|
||||
private fun handlePlayOrPauseRecordingPlayback() {
|
||||
voiceMessageHelper.startOrPauseRecordingPlayback()
|
||||
audioMessageHelper.startOrPauseRecordingPlayback()
|
||||
}
|
||||
|
||||
private fun handleEndAllVoiceActions(deleteRecord: Boolean) {
|
||||
voiceMessageHelper.clearTracker()
|
||||
voiceMessageHelper.stopAllVoiceActions(deleteRecord)
|
||||
audioMessageHelper.clearTracker()
|
||||
audioMessageHelper.stopAllVoiceActions(deleteRecord)
|
||||
}
|
||||
|
||||
private fun handleInitializeVoiceRecorder(attachmentData: ContentAttachmentData) {
|
||||
voiceMessageHelper.initializeRecorder(attachmentData)
|
||||
audioMessageHelper.initializeRecorder(attachmentData)
|
||||
setState { copy(voiceRecordingUiState = VoiceMessageRecorderView.RecordingUiState.Draft) }
|
||||
}
|
||||
|
||||
private fun handlePauseRecordingVoiceMessage() {
|
||||
voiceMessageHelper.pauseRecording()
|
||||
audioMessageHelper.pauseRecording()
|
||||
}
|
||||
|
||||
private fun handleVoiceWaveformTouchedUp(action: MessageComposerAction.VoiceWaveformTouchedUp) {
|
||||
voiceMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
|
||||
audioMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
|
||||
}
|
||||
|
||||
private fun handleVoiceWaveformMovedTo(action: MessageComposerAction.VoiceWaveformMovedTo) {
|
||||
voiceMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
|
||||
audioMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
|
||||
}
|
||||
|
||||
private fun handleAudioSeekBarMovedTo(action: MessageComposerAction.AudioSeekBarMovedTo) {
|
||||
audioMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
|
||||
}
|
||||
|
||||
private fun handleEntersBackground(composerText: String) {
|
||||
// Always stop all voice actions. It may be playing in timeline or active recording
|
||||
val playingAudioContent = voiceMessageHelper.stopAllVoiceActions(deleteRecord = false)
|
||||
val playingAudioContent = audioMessageHelper.stopAllVoiceActions(deleteRecord = false)
|
||||
|
||||
val isVoiceRecording = com.airbnb.mvrx.withState(this) { it.isVoiceRecording }
|
||||
if (isVoiceRecording) {
|
||||
|
|
|
@ -148,6 +148,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
|
|||
fun onVoiceWaveformTouchedUp(eventId: String, duration: Int, percentage: Float)
|
||||
fun onVoiceWaveformMovedTo(eventId: String, duration: Int, percentage: Float)
|
||||
|
||||
fun onAudioSeekBarMovedTo(eventId: String, duration: Int, percentage: Float)
|
||||
|
||||
fun onAddMoreReaction(event: TimelineEvent)
|
||||
}
|
||||
|
||||
|
|
|
@ -341,6 +341,7 @@ class MessageItemFactory @Inject constructor(
|
|||
): MessageAudioItem {
|
||||
val fileUrl = getAudioFileUrl(messageContent, informationData)
|
||||
val playbackControlButtonClickListener = createOnPlaybackButtonClickListener(messageContent, informationData, params)
|
||||
val duration = messageContent.audioInfo?.duration ?: 0
|
||||
|
||||
return MessageAudioItem_()
|
||||
.attributes(attributes)
|
||||
|
@ -349,6 +350,8 @@ class MessageItemFactory @Inject constructor(
|
|||
.playbackControlButtonClickListener(playbackControlButtonClickListener)
|
||||
.audioMessagePlaybackTracker(audioMessagePlaybackTracker)
|
||||
.isLocalFile(localFilesHelper.isLocalFile(fileUrl))
|
||||
.fileSize(messageContent.audioInfo?.size ?: 0L)
|
||||
.onSeek { params.callback?.onAudioSeekBarMovedTo(informationData.eventId, duration, it) }
|
||||
.mxcUrl(fileUrl)
|
||||
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
|
||||
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
|
||||
|
|
|
@ -104,10 +104,14 @@ class AudioMessagePlaybackTracker @Inject constructor() {
|
|||
setState(id, Listener.State.Idle)
|
||||
}
|
||||
|
||||
fun updateCurrentPlaybackTime(id: String, time: Int, percentage: Float) {
|
||||
fun updatePlayingAtPlaybackTime(id: String, time: Int, percentage: Float) {
|
||||
setState(id, Listener.State.Playing(time, percentage))
|
||||
}
|
||||
|
||||
fun updatePausedAtPlaybackTime(id: String, time: Int, percentage: Float) {
|
||||
setState(id, Listener.State.Paused(time, percentage))
|
||||
}
|
||||
|
||||
fun updateCurrentRecording(id: String, amplitudeList: List<Int>) {
|
||||
setState(id, Listener.State.Recording(amplitudeList))
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import android.graphics.Paint
|
|||
import android.text.format.DateUtils
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.SeekBar
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import com.airbnb.epoxy.EpoxyAttribute
|
||||
|
@ -29,6 +30,7 @@ import com.airbnb.epoxy.EpoxyModelClass
|
|||
import im.vector.app.R
|
||||
import im.vector.app.core.epoxy.ClickListener
|
||||
import im.vector.app.core.epoxy.onClick
|
||||
import im.vector.app.core.utils.TextUtils
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
|
||||
|
@ -47,10 +49,16 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
|
|||
@EpoxyAttribute
|
||||
var duration: Int = 0
|
||||
|
||||
@EpoxyAttribute
|
||||
var fileSize: Long = 0
|
||||
|
||||
@EpoxyAttribute
|
||||
@JvmField
|
||||
var isLocalFile = false
|
||||
|
||||
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
|
||||
var onSeek: ((percentage: Float) -> Unit)? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder
|
||||
|
||||
|
@ -63,12 +71,15 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
|
|||
@EpoxyAttribute
|
||||
lateinit var audioMessagePlaybackTracker: AudioMessagePlaybackTracker
|
||||
|
||||
private var isUserSeeking = false
|
||||
|
||||
override fun bind(holder: Holder) {
|
||||
super.bind(holder)
|
||||
renderSendState(holder.rootLayout, null)
|
||||
bindFilenameViewAttributes(holder)
|
||||
bindViewAttributes(holder)
|
||||
bindUploadState(holder)
|
||||
applyLayoutTint(holder)
|
||||
bindSeekBar(holder)
|
||||
holder.audioPlaybackControlButton.setOnClickListener { playbackControlButtonClickListener?.invoke(it) }
|
||||
renderStateBasedOnAudioPlayback(holder)
|
||||
}
|
||||
|
@ -93,10 +104,30 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
|
|||
holder.mainLayout.backgroundTintList = ColorStateList.valueOf(backgroundTint)
|
||||
}
|
||||
|
||||
private fun bindFilenameViewAttributes(holder: Holder) {
|
||||
private fun bindViewAttributes(holder: Holder) {
|
||||
holder.filenameView.text = filename
|
||||
holder.filenameView.onClick(attributes.itemClickListener)
|
||||
holder.filenameView.paintFlags = (holder.filenameView.paintFlags or Paint.UNDERLINE_TEXT_FLAG)
|
||||
holder.audioPlaybackDuration.text = formatPlaybackTime(duration)
|
||||
holder.fileSize.text = TextUtils.formatFileSize(holder.rootLayout.context, fileSize, true)
|
||||
}
|
||||
|
||||
private fun bindSeekBar(holder: Holder) {
|
||||
holder.audioSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
|
||||
holder.audioPlaybackTime.text = formatPlaybackTime(
|
||||
(duration * (progress.toFloat() / 100)).toInt()
|
||||
)
|
||||
}
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar) {
|
||||
isUserSeeking = true
|
||||
}
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
||||
isUserSeeking = false
|
||||
val percentage = seekBar.progress.toFloat() / 100
|
||||
onSeek?.invoke(percentage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun renderStateBasedOnAudioPlayback(holder: Holder) {
|
||||
|
@ -117,13 +148,18 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
|
|||
holder.audioPlaybackControlButton.contentDescription =
|
||||
holder.view.context.getString(R.string.a11y_play_audio_message, filename)
|
||||
holder.audioPlaybackTime.text = formatPlaybackTime(duration)
|
||||
holder.audioSeekBar.progress = 0
|
||||
}
|
||||
|
||||
private fun renderPlayingState(holder: Holder, state: AudioMessagePlaybackTracker.Listener.State.Playing) {
|
||||
holder.audioPlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
|
||||
holder.audioPlaybackControlButton.contentDescription =
|
||||
holder.view.context.getString(R.string.a11y_pause_audio_message, filename)
|
||||
holder.audioPlaybackTime.text = formatPlaybackTime(state.playbackTime)
|
||||
|
||||
if (!isUserSeeking) {
|
||||
holder.audioPlaybackTime.text = formatPlaybackTime(state.playbackTime)
|
||||
holder.audioSeekBar.progress = (state.percentage * 100).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderPausedState(holder: Holder, state: AudioMessagePlaybackTracker.Listener.State.Paused) {
|
||||
|
@ -131,6 +167,7 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
|
|||
holder.audioPlaybackControlButton.contentDescription =
|
||||
holder.view.context.getString(R.string.a11y_play_audio_message, filename)
|
||||
holder.audioPlaybackTime.text = formatPlaybackTime(state.playbackTime)
|
||||
holder.audioSeekBar.progress = (state.percentage * 100).toInt()
|
||||
}
|
||||
|
||||
private fun formatPlaybackTime(time: Int) = DateUtils.formatElapsedTime((time / 1000).toLong())
|
||||
|
@ -151,6 +188,9 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
|
|||
val audioPlaybackControlButton by bind<ImageButton>(R.id.audioPlaybackControlButton)
|
||||
val audioPlaybackTime by bind<TextView>(R.id.audioPlaybackTime)
|
||||
val progressLayout by bind<ViewGroup>(R.id.messageFileUploadProgressLayout)
|
||||
val fileSize by bind<TextView>(R.id.fileSize)
|
||||
val audioPlaybackDuration by bind<TextView>(R.id.audioPlaybackDuration)
|
||||
val audioSeekBar by bind<SeekBar>(R.id.audioSeekBar)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -45,10 +45,11 @@
|
|||
tools:text="Filename.mp3" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/audioPlaybackTime"
|
||||
android:id="@+id/audioPlaybackDuration"
|
||||
style="@style/Widget.Vector.TextView.Body"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textColor="?vctr_content_secondary"
|
||||
app:layout_constraintStart_toStartOf="@id/messageFilenameView"
|
||||
app:layout_constraintTop_toBottomOf="@id/messageFilenameView"
|
||||
|
@ -61,8 +62,8 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:textColor="?vctr_content_secondary"
|
||||
android:layout_marginStart="4dp"
|
||||
app:layout_constraintStart_toEndOf="@id/audioPlaybackTime"
|
||||
app:layout_constraintBottom_toBottomOf="@id/audioPlaybackTime"
|
||||
app:layout_constraintStart_toEndOf="@id/audioPlaybackDuration"
|
||||
app:layout_constraintBottom_toBottomOf="@id/audioPlaybackDuration"
|
||||
tools:text="(2MB)" />
|
||||
|
||||
<SeekBar
|
||||
|
@ -74,13 +75,13 @@
|
|||
android:progressDrawable="@drawable/bg_seek_bar"
|
||||
android:thumbTint="?vctr_content_tertiary"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/audioPlaybackDuration"
|
||||
app:layout_constraintEnd_toStartOf="@id/audioPlaybackTime"
|
||||
app:layout_constraintTop_toBottomOf="@id/audioPlaybackControlButton"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
tools:progress="40" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/audioPlaybackDuration"
|
||||
android:id="@+id/audioPlaybackTime"
|
||||
style="@style/Widget.Vector.TextView.Body"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
Loading…
Reference in a new issue