Merge branch 'develop' into feature/bca/rust_flavor

This commit is contained in:
valere 2023-01-23 09:17:26 +01:00
commit 4ee53ad9d0
18 changed files with 80 additions and 55 deletions

View File

@ -25,14 +25,10 @@ jobs:
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/cache@v3 - name: Configure gradle
uses: gradle/gradle-build-action@v2
with: with:
path: | cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Assemble ${{ matrix.target }} debug apk - name: Assemble ${{ matrix.target }} debug apk
run: ./gradlew assemble${{ matrix.target }}KotlinCryptoDebug $CI_GRADLE_ARG_PROPERTIES run: ./gradlew assemble${{ matrix.target }}KotlinCryptoDebug $CI_GRADLE_ARG_PROPERTIES
- name: Upload ${{ matrix.target }} debug APKs - name: Upload ${{ matrix.target }} debug APKs
@ -50,14 +46,10 @@ jobs:
cancel-in-progress: ${{ github.ref != 'refs/head/main' }} cancel-in-progress: ${{ github.ref != 'refs/head/main' }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/cache@v3 - name: Configure gradle
uses: gradle/gradle-build-action@v2
with: with:
path: | cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Assemble GPlay unsigned apk - name: Assemble GPlay unsigned apk
run: ./gradlew clean assembleGplayKotlinCryptoRelease $CI_GRADLE_ARG_PROPERTIES run: ./gradlew clean assembleGplayKotlinCryptoRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload Gplay unsigned APKs - name: Upload Gplay unsigned APKs

View File

@ -19,14 +19,10 @@ jobs:
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: 3.8 python-version: 3.8
- uses: actions/cache@v3 - name: Configure gradle
uses: gradle/gradle-build-action@v2
with: with:
path: | cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install towncrier - name: Install towncrier
run: | run: |
python3 -m pip install towncrier python3 -m pip install towncrier

View File

@ -44,14 +44,14 @@ jobs:
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: 3.8 python-version: 3.8
- uses: actions/cache@v3 - uses: actions/setup-java@v3
with: with:
path: | distribution: 'adopt'
~/.gradle/caches java-version: '11'
~/.gradle/wrapper - name: Configure gradle
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} uses: gradle/gradle-build-action@v2
restore-keys: | with:
${{ runner.os }}-gradle- cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Start synapse server - name: Start synapse server
uses: michaelkaye/setup-matrix-synapse@v1.0.4 uses: michaelkaye/setup-matrix-synapse@v1.0.4
with: with:
@ -59,10 +59,6 @@ jobs:
httpPort: 8080 httpPort: 8080
disableRateLimiting: true disableRateLimiting: true
public_baseurl: "http://10.0.2.2:8080/" public_baseurl: "http://10.0.2.2:8080/"
- uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: '11'
- name: Run sanity tests on API ${{ matrix.api-level }} - name: Run sanity tests on API ${{ matrix.api-level }}
uses: reactivecircus/android-emulator-runner@v2 uses: reactivecircus/android-emulator-runner@v2
with: with:

View File

@ -139,14 +139,10 @@ jobs:
# with: # with:
# distribution: 'adopt' # distribution: 'adopt'
# java-version: 11 # java-version: 11
# - uses: actions/cache@v3 # - name: Configure gradle
# uses: gradle/gradle-build-action@v2
# with: # with:
# path: | # cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
# ~/.gradle/caches
# ~/.gradle/wrapper
# key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
# restore-keys: |
# ${{ runner.os }}-gradle-
# - name: Build Android Tests # - name: Build Android Tests
# run: ./gradlew clean assembleAndroidTest $CI_GRADLE_ARG_PROPERTIES # run: ./gradlew clean assembleAndroidTest $CI_GRADLE_ARG_PROPERTIES

View File

@ -25,7 +25,7 @@ buildscript {
classpath libs.gradle.kotlinPlugin classpath libs.gradle.kotlinPlugin
classpath libs.gradle.hiltPlugin classpath libs.gradle.hiltPlugin
classpath 'com.google.firebase:firebase-appdistribution-gradle:3.1.1' classpath 'com.google.firebase:firebase-appdistribution-gradle:3.1.1'
classpath 'com.google.gms:google-services:4.3.14' classpath 'com.google.gms:google-services:4.3.15'
classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.5.0.2730' classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.5.0.2730'
classpath 'com.google.android.gms:oss-licenses-plugin:0.10.6' classpath 'com.google.android.gms:oss-licenses-plugin:0.10.6'
classpath "com.likethesalad.android:stem-plugin:2.3.0" classpath "com.likethesalad.android:stem-plugin:2.3.0"

1
changelog.d/7807.misc Normal file
View File

@ -0,0 +1 @@
Support reactions on Voice Broadcast

1
changelog.d/7830.misc Normal file
View File

@ -0,0 +1 @@
Pause voice broadcast listening on new VB recording

1
changelog.d/7895.bugfix Normal file
View File

@ -0,0 +1 @@
Send voice message should not be allowed during a voice broadcast recording

1
changelog.d/7929.misc Normal file
View File

@ -0,0 +1 @@
Tapping slightly left or right of the 30s buttons highlights the whole cell instead of registering as button presses

View File

@ -3108,6 +3108,8 @@
<string name="error_voice_message_unable_to_play">Cannot play this voice message</string> <string name="error_voice_message_unable_to_play">Cannot play this voice message</string>
<string name="error_voice_message_unable_to_record">Cannot record a voice message</string> <string name="error_voice_message_unable_to_record">Cannot record a voice message</string>
<string name="error_voice_message_cannot_reply_or_edit">Cannot reply or edit while voice message is active</string> <string name="error_voice_message_cannot_reply_or_edit">Cannot reply or edit while voice message is active</string>
<string name="error_voice_message_broadcast_in_progress">Cannot start voice message</string>
<string name="error_voice_message_broadcast_in_progress_message">You cant start a voice message as you are currently recording a live broadcast. Please end your live broadcast in order to start recording a voice message</string>
<string name="voice_message_reply_content">Voice Message (%1$s)</string> <string name="voice_message_reply_content">Voice Message (%1$s)</string>
<string name="a11y_audio_message_item">%1$s, %2$s, %3$s</string> <!-- filename, duration, file size --> <string name="a11y_audio_message_item">%1$s, %2$s, %3$s</string> <!-- filename, duration, file size -->

View File

@ -151,6 +151,7 @@ class DefaultErrorFormatter @Inject constructor(
return when (throwable) { return when (throwable) {
is VoiceFailure.UnableToPlay -> stringProvider.getString(R.string.error_voice_message_unable_to_play) is VoiceFailure.UnableToPlay -> stringProvider.getString(R.string.error_voice_message_unable_to_play)
is VoiceFailure.UnableToRecord -> stringProvider.getString(R.string.error_voice_message_unable_to_record) is VoiceFailure.UnableToRecord -> stringProvider.getString(R.string.error_voice_message_unable_to_record)
is VoiceFailure.VoiceBroadcastInProgress -> stringProvider.getString(R.string.error_voice_message_broadcast_in_progress)
} }
} }

View File

@ -18,6 +18,8 @@ package im.vector.app.core.extensions
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent
@ -26,8 +28,9 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
fun TimelineEvent.canReact(): Boolean { fun TimelineEvent.canReact(): Boolean {
// Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment // Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START, and started voice broadcast are supported for the moment
return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START.values + EventType.POLL_END.values && return (root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START.values + EventType.POLL_END.values ||
root.asVoiceBroadcastEvent()?.content?.voiceBroadcastState == VoiceBroadcastState.STARTED) &&
root.sendState == SendState.SYNCED && root.sendState == SendState.SYNCED &&
!root.isRedacted() !root.isRedacted()
} }

View File

@ -627,13 +627,17 @@ class TimelineViewModel @AssistedInject constructor(
viewModelScope.launch { viewModelScope.launch {
when (action) { when (action) {
VoiceBroadcastAction.Recording.Start -> { VoiceBroadcastAction.Recording.Start -> {
voiceBroadcastHelper.pausePlayback()
voiceBroadcastHelper.startVoiceBroadcast(room.roomId).fold( voiceBroadcastHelper.startVoiceBroadcast(room.roomId).fold(
{ _viewEvents.post(RoomDetailViewEvents.ActionSuccess(action)) }, { _viewEvents.post(RoomDetailViewEvents.ActionSuccess(action)) },
{ _viewEvents.post(RoomDetailViewEvents.ActionFailure(action, it)) }, { _viewEvents.post(RoomDetailViewEvents.ActionFailure(action, it)) },
) )
} }
VoiceBroadcastAction.Recording.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId) VoiceBroadcastAction.Recording.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId)
VoiceBroadcastAction.Recording.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId) VoiceBroadcastAction.Recording.Resume -> {
voiceBroadcastHelper.pausePlayback()
voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId)
}
VoiceBroadcastAction.Recording.Stop -> _viewEvents.post(RoomDetailViewEvents.DisplayPromptToStopVoiceBroadcast) VoiceBroadcastAction.Recording.Stop -> _viewEvents.post(RoomDetailViewEvents.DisplayPromptToStopVoiceBroadcast)
VoiceBroadcastAction.Recording.StopConfirmed -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) VoiceBroadcastAction.Recording.StopConfirmed -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId)
is VoiceBroadcastAction.Listening.PlayOrResume -> voiceBroadcastHelper.playOrResumePlayback(action.voiceBroadcast) is VoiceBroadcastAction.Listening.PlayOrResume -> voiceBroadcastHelper.playOrResumePlayback(action.voiceBroadcast)

View File

@ -191,6 +191,8 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
is MessageComposerViewEvents.VoicePlaybackOrRecordingFailure -> { is MessageComposerViewEvents.VoicePlaybackOrRecordingFailure -> {
if (it.throwable is VoiceFailure.UnableToRecord) { if (it.throwable is VoiceFailure.UnableToRecord) {
onCannotRecord() onCannotRecord()
} else if (it.throwable is VoiceFailure.VoiceBroadcastInProgress) {
displayErrorVoiceBroadcastInProgress()
} }
showErrorInSnackbar(it.throwable) showErrorInSnackbar(it.throwable)
} }
@ -526,6 +528,14 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(VoiceMessageRecorderView.RecordingUiState.Idle)) messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(VoiceMessageRecorderView.RecordingUiState.Idle))
} }
private fun displayErrorVoiceBroadcastInProgress() {
MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.error_voice_message_broadcast_in_progress)
.setMessage(getString(R.string.error_voice_message_broadcast_in_progress_message))
.setPositiveButton(android.R.string.ok, null)
.show()
}
private fun handleJoinedToAnotherRoom(action: MessageComposerViewEvents.JoinRoomCommandSuccess) { private fun handleJoinedToAnotherRoom(action: MessageComposerViewEvents.JoinRoomCommandSuccess) {
composer.setTextIfDifferent("") composer.setTextIfDifferent("")
lockSendButton = false lockSendButton = false

View File

@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail.composer
import android.text.SpannableString import android.text.SpannableString
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.withState
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -28,6 +29,7 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.getVectorLastMessageContent
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.core.time.Clock
import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.AnalyticsTracker
import im.vector.app.features.analytics.extensions.toAnalyticsComposer import im.vector.app.features.analytics.extensions.toAnalyticsComposer
import im.vector.app.features.analytics.extensions.toAnalyticsJoinedRoom import im.vector.app.features.analytics.extensions.toAnalyticsJoinedRoom
@ -42,12 +44,19 @@ import im.vector.app.features.home.room.detail.toMessageType
import im.vector.app.features.powerlevel.PowerLevelsFlowFactory import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
import im.vector.app.features.session.coroutineScope import im.vector.app.features.session.coroutineScope
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.voice.VoiceFailure
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastStateEventLiveUseCase
import im.vector.app.features.voicebroadcast.voiceBroadcastId
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
@ -74,6 +83,7 @@ import org.matrix.android.sdk.api.session.room.send.UserDraft
import org.matrix.android.sdk.api.session.room.timeline.getRelationContent import org.matrix.android.sdk.api.session.room.timeline.getRelationContent
import org.matrix.android.sdk.api.session.room.timeline.getTextEditableContent import org.matrix.android.sdk.api.session.room.timeline.getTextEditableContent
import org.matrix.android.sdk.api.session.space.CreateSpaceParams import org.matrix.android.sdk.api.session.space.CreateSpaceParams
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.flow.flow import org.matrix.android.sdk.flow.flow
import org.matrix.android.sdk.flow.unwrap import org.matrix.android.sdk.flow.unwrap
import timber.log.Timber import timber.log.Timber
@ -88,6 +98,8 @@ class MessageComposerViewModel @AssistedInject constructor(
private val audioMessageHelper: AudioMessageHelper, private val audioMessageHelper: AudioMessageHelper,
private val analyticsTracker: AnalyticsTracker, private val analyticsTracker: AnalyticsTracker,
private val voiceBroadcastHelper: VoiceBroadcastHelper, private val voiceBroadcastHelper: VoiceBroadcastHelper,
private val clock: Clock,
private val getVoiceBroadcastStateEventLiveUseCase: GetVoiceBroadcastStateEventLiveUseCase,
) : VectorViewModel<MessageComposerViewState, MessageComposerAction, MessageComposerViewEvents>(initialState) { ) : VectorViewModel<MessageComposerViewState, MessageComposerAction, MessageComposerViewEvents>(initialState) {
private val room = session.getRoom(initialState.roomId) private val room = session.getRoom(initialState.roomId)
@ -203,8 +215,11 @@ class MessageComposerViewModel @AssistedInject constructor(
private fun observeVoiceBroadcast(room: Room) { private fun observeVoiceBroadcast(room: Room) {
room.stateService().getStateEventLive(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, QueryStringValue.Equals(session.myUserId)) room.stateService().getStateEventLive(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, QueryStringValue.Equals(session.myUserId))
.asFlow() .asFlow()
.unwrap() .map { it.getOrNull()?.asVoiceBroadcastEvent()?.voiceBroadcastId }
.mapNotNull { it.asVoiceBroadcastEvent()?.content?.voiceBroadcastState } .flatMapLatest { voiceBroadcastId ->
voiceBroadcastId?.let { getVoiceBroadcastStateEventLiveUseCase.execute(VoiceBroadcast(it, room.roomId)) } ?: flowOf(Optional.empty())
}
.map { it.getOrNull()?.content?.voiceBroadcastState }
.setOnEach { .setOnEach {
copy(voiceBroadcastState = it) copy(voiceBroadcastState = it)
} }
@ -916,10 +931,16 @@ class MessageComposerViewModel @AssistedInject constructor(
} }
private fun handleStartRecordingVoiceMessage(room: Room) { private fun handleStartRecordingVoiceMessage(room: Room) {
try { val voiceBroadcastState = withState(this) { it.voiceBroadcastState }
audioMessageHelper.startRecording(room.roomId) if (voiceBroadcastState != null && voiceBroadcastState != VoiceBroadcastState.STOPPED) {
} catch (failure: Throwable) { _viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(VoiceFailure.VoiceBroadcastInProgress))
_viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(failure)) } else {
try {
audioMessageHelper.startRecording(room.roomId)
setState { copy(voiceRecordingUiState = VoiceMessageRecorderView.RecordingUiState.Recording(clock.epochMillis())) }
} catch (failure: Throwable) {
_viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(failure))
}
} }
} }

View File

@ -125,7 +125,6 @@ class VoiceRecorderFragment : VectorBaseFragment<FragmentVoiceRecorderBinding>()
if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) { if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
messageComposerViewModel.handle(MessageComposerAction.StartRecordingVoiceMessage) messageComposerViewModel.handle(MessageComposerAction.StartRecordingVoiceMessage)
vibrate(requireContext()) vibrate(requireContext())
updateRecordingUiState(VoiceMessageRecorderView.RecordingUiState.Recording(clock.epochMillis()))
} }
} }

View File

@ -19,4 +19,5 @@ package im.vector.app.features.voice
sealed class VoiceFailure(cause: Throwable? = null) : Throwable(cause = cause) { sealed class VoiceFailure(cause: Throwable? = null) : Throwable(cause = cause) {
data class UnableToPlay(val throwable: Throwable) : VoiceFailure(throwable) data class UnableToPlay(val throwable: Throwable) : VoiceFailure(throwable)
data class UnableToRecord(val throwable: Throwable) : VoiceFailure(throwable) data class UnableToRecord(val throwable: Throwable) : VoiceFailure(throwable)
object VoiceBroadcastInProgress : VoiceFailure()
} }

View File

@ -105,8 +105,8 @@
<ImageButton <ImageButton
android:id="@+id/fastBackwardButton" android:id="@+id/fastBackwardButton"
android:layout_width="24dp" android:layout_width="@dimen/voice_broadcast_player_button_size"
android:layout_height="24dp" android:layout_height="@dimen/voice_broadcast_player_button_size"
android:background="@drawable/bg_rounded_button" android:background="@drawable/bg_rounded_button"
android:contentDescription="@string/a11y_voice_broadcast_fast_backward" android:contentDescription="@string/a11y_voice_broadcast_fast_backward"
android:src="@drawable/ic_player_backward_30" android:src="@drawable/ic_player_backward_30"
@ -126,8 +126,8 @@
<ImageButton <ImageButton
android:id="@+id/fastForwardButton" android:id="@+id/fastForwardButton"
android:layout_width="24dp" android:layout_width="@dimen/voice_broadcast_player_button_size"
android:layout_height="24dp" android:layout_height="@dimen/voice_broadcast_player_button_size"
android:background="@drawable/bg_rounded_button" android:background="@drawable/bg_rounded_button"
android:contentDescription="@string/a11y_voice_broadcast_fast_forward" android:contentDescription="@string/a11y_voice_broadcast_fast_forward"
android:src="@drawable/ic_player_forward_30" android:src="@drawable/ic_player_forward_30"