Merge pull request #7993 from vector-im/feature/fre/vb_handle_connection_error

Pause voice broadcast if there is no network
This commit is contained in:
Florian Renaud 2023-01-24 14:43:04 +01:00 committed by GitHub
commit 71b7fbdf15
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 208 additions and 81 deletions

1
changelog.d/7890.feature Normal file
View file

@ -0,0 +1 @@
[Voice Broadcast] Handle connection errors while recording

View file

@ -3125,6 +3125,7 @@
<string name="error_voice_broadcast_blocked_by_someone_else_message">Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.</string>
<string name="error_voice_broadcast_already_in_progress_message">You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.</string>
<string name="error_voice_broadcast_unable_to_play">Unable to play this voice broadcast.</string>
<string name="error_voice_broadcast_no_connection_recording">Connection error - Recording paused</string>
<!-- Examples of usage: 6h 15min 30sec left / 15min 30sec left / 30sec left -->
<string name="voice_broadcast_recording_time_left">%1$s left</string>
<string name="stop_voice_broadcast_dialog_title">Stop live broadcasting?</string>

View file

@ -17,6 +17,8 @@
package im.vector.app.features.home.room.detail.timeline.item
import android.widget.ImageButton
import android.widget.TextView
import androidx.constraintlayout.widget.Group
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
@ -55,11 +57,11 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem
}
override fun renderLiveIndicator(holder: Holder) {
when (voiceBroadcastState) {
VoiceBroadcastState.STARTED,
VoiceBroadcastState.RESUMED -> renderPlayingLiveIndicator(holder)
VoiceBroadcastState.PAUSED -> renderPausedLiveIndicator(holder)
VoiceBroadcastState.STOPPED, null -> renderNoLiveIndicator(holder)
when (recorder?.recordingState) {
VoiceBroadcastRecorder.State.Recording -> renderPlayingLiveIndicator(holder)
VoiceBroadcastRecorder.State.Error,
VoiceBroadcastRecorder.State.Paused -> renderPausedLiveIndicator(holder)
VoiceBroadcastRecorder.State.Idle, null -> renderNoLiveIndicator(holder)
}
}
@ -85,7 +87,9 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem
VoiceBroadcastRecorder.State.Recording -> renderRecordingState(holder)
VoiceBroadcastRecorder.State.Paused -> renderPausedState(holder)
VoiceBroadcastRecorder.State.Idle -> renderStoppedState(holder)
VoiceBroadcastRecorder.State.Error -> renderErrorState(holder, true)
}
renderLiveIndicator(holder)
}
private fun renderVoiceBroadcastState(holder: Holder) {
@ -101,6 +105,7 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem
private fun renderRecordingState(holder: Holder) = with(holder) {
stopRecordButton.isEnabled = true
recordButton.isEnabled = true
renderErrorState(holder, false)
val drawableColor = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary)
val drawable = drawableProvider.getDrawable(R.drawable.ic_play_pause_pause, drawableColor)
@ -113,6 +118,7 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem
private fun renderPausedState(holder: Holder) = with(holder) {
stopRecordButton.isEnabled = true
recordButton.isEnabled = true
renderErrorState(holder, false)
recordButton.setImageResource(R.drawable.ic_recording_dot)
recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_resume_voice_broadcast_record)
@ -123,6 +129,12 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem
private fun renderStoppedState(holder: Holder) = with(holder) {
recordButton.isEnabled = false
stopRecordButton.isEnabled = false
renderErrorState(holder, false)
}
private fun renderErrorState(holder: Holder, isOnError: Boolean) = with(holder) {
controlsGroup.isVisible = !isOnError
errorView.isVisible = isOnError
}
override fun unbind(holder: Holder) {
@ -142,6 +154,8 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem
val remainingTimeMetadata by bind<VoiceBroadcastMetadataView>(R.id.remainingTimeMetadata)
val recordButton by bind<ImageButton>(R.id.recordButton)
val stopRecordButton by bind<ImageButton>(R.id.stopRecordButton)
val errorView by bind<TextView>(R.id.errorView)
val controlsGroup by bind<Group>(R.id.controlsGroup)
}
companion object {

View file

@ -33,6 +33,8 @@ interface VoiceBroadcastRecorder : VoiceRecorder {
val currentRemainingTime: Long?
fun startRecordVoiceBroadcast(voiceBroadcast: VoiceBroadcast, chunkLength: Int, maxLength: Int)
fun pauseOnError()
fun addListener(listener: Listener)
fun removeListener(listener: Listener)
@ -46,5 +48,6 @@ interface VoiceBroadcastRecorder : VoiceRecorder {
Recording,
Paused,
Idle,
Error,
}
}

View file

@ -29,10 +29,14 @@ import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastStateEventLiveUseCase
import im.vector.lib.core.utils.timer.CountUpTimer
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.sync.SyncState
import org.matrix.android.sdk.flow.flow
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
@ -47,6 +51,7 @@ class VoiceBroadcastRecorderQ(
private val sessionScope get() = session.coroutineScope
private var voiceBroadcastStateObserver: Job? = null
private var syncStateObserver: Job? = null
private var maxFileSize = 0L // zero or negative for no limit
private var currentVoiceBroadcast: VoiceBroadcast? = null
@ -96,21 +101,36 @@ class VoiceBroadcastRecorderQ(
observeVoiceBroadcastStateEvent(voiceBroadcast)
}
override fun pauseRecord() {
override fun startRecord(roomId: String) {
super.startRecord(roomId)
observeConnectionState()
}
override fun pauseOnError() {
if (recordingState != VoiceBroadcastRecorder.State.Recording) return
tryOrNull { mediaRecorder?.stop() }
mediaRecorder?.reset()
pauseRecorder()
stopObservingConnectionState()
recordingState = VoiceBroadcastRecorder.State.Error
}
override fun pauseRecord() {
if (recordingState !in arrayOf(VoiceBroadcastRecorder.State.Recording, VoiceBroadcastRecorder.State.Error)) return
pauseRecorder()
stopObservingConnectionState()
recordingState = VoiceBroadcastRecorder.State.Paused
recordingTicker.pause()
notifyOutputFileCreated()
}
override fun resumeRecord() {
if (recordingState != VoiceBroadcastRecorder.State.Paused) return
currentSequence++
currentVoiceBroadcast?.let { startRecord(it.roomId) }
recordingState = VoiceBroadcastRecorder.State.Recording
recordingTicker.resume()
observeConnectionState()
}
override fun stopRecord() {
@ -128,6 +148,8 @@ class VoiceBroadcastRecorderQ(
voiceBroadcastStateObserver?.cancel()
voiceBroadcastStateObserver = null
stopObservingConnectionState()
// Reset data
currentSequence = 0
currentMaxLength = 0
@ -197,6 +219,27 @@ class VoiceBroadcastRecorderQ(
}
}
private fun pauseRecorder() {
if (recordingState != VoiceBroadcastRecorder.State.Recording) return
tryOrNull { mediaRecorder?.stop() }
mediaRecorder?.reset()
recordingTicker.pause()
}
private fun observeConnectionState() {
syncStateObserver = session.flow().liveSyncState()
.distinctUntilChanged()
.filter { it is SyncState.NoNetwork }
.onEach { pauseOnError() }
.launchIn(sessionScope)
}
private fun stopObservingConnectionState() {
syncStateObserver?.cancel()
syncStateObserver = null
}
private inner class RecordingTicker(
private var recordingTicker: CountUpTimer? = null,
) {

View file

@ -16,17 +16,25 @@
package im.vector.app.features.voicebroadcast.recording.usecase
import im.vector.app.features.session.coroutineScope
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
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 im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.Room
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.sync.SyncState
import org.matrix.android.sdk.flow.flow
import timber.log.Timber
import javax.inject.Inject
@ -51,25 +59,35 @@ class PauseVoiceBroadcastUseCase @Inject constructor(
}
}
private suspend fun pauseVoiceBroadcast(room: Room, reference: RelationDefaultContent?) {
private suspend fun pauseVoiceBroadcast(room: Room, reference: RelationDefaultContent?, remainingRetry: Int = 3) {
Timber.d("## PauseVoiceBroadcastUseCase: Send new voice broadcast info state event")
// save the last sequence number and immediately pause the recording
val lastSequence = voiceBroadcastRecorder?.currentSequence
pauseRecording()
try {
// save the last sequence number and immediately pause the recording
val lastSequence = voiceBroadcastRecorder?.currentSequence
room.stateService().sendStateEvent(
eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = session.myUserId,
body = MessageVoiceBroadcastInfoContent(
relatesTo = reference,
voiceBroadcastStateStr = VoiceBroadcastState.PAUSED.value,
lastChunkSequence = lastSequence,
).toContent(),
)
}
room.stateService().sendStateEvent(
eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = session.myUserId,
body = MessageVoiceBroadcastInfoContent(
relatesTo = reference,
voiceBroadcastStateStr = VoiceBroadcastState.PAUSED.value,
lastChunkSequence = lastSequence,
).toContent(),
)
private fun pauseRecording() {
voiceBroadcastRecorder?.pauseRecord()
voiceBroadcastRecorder?.pauseRecord()
} catch (e: Failure) {
if (remainingRetry > 0) {
voiceBroadcastRecorder?.pauseOnError()
// Retry if there is no network issue (sync is running well)
session.flow().liveSyncState()
.filter { it is SyncState.Running }
.take(1)
.onEach { pauseVoiceBroadcast(room, reference, remainingRetry - 1) }
.launchIn(session.coroutineScope)
}
throw e
}
}
}

View file

@ -58,6 +58,7 @@ class StartVoiceBroadcastUseCase @Inject constructor(
private val buildMeta: BuildMeta,
private val getRoomLiveVoiceBroadcastsUseCase: GetRoomLiveVoiceBroadcastsUseCase,
private val stopVoiceBroadcastUseCase: StopVoiceBroadcastUseCase,
private val pauseVoiceBroadcastUseCase: PauseVoiceBroadcastUseCase,
) {
suspend fun execute(roomId: String): Result<Unit> = runCatching {
@ -103,6 +104,14 @@ class StartVoiceBroadcastUseCase @Inject constructor(
session.coroutineScope.launch { stopVoiceBroadcastUseCase.execute(room.roomId) }
}
}
override fun onStateUpdated(state: VoiceBroadcastRecorder.State) {
if (state == VoiceBroadcastRecorder.State.Error) {
session.coroutineScope.launch {
pauseVoiceBroadcastUseCase.execute(room.roomId)
}
}
}
})
voiceBroadcastRecorder?.startRecordVoiceBroadcast(voiceBroadcast, chunkLength, maxLength)
}

View file

@ -40,51 +40,59 @@
<TextView
android:id="@+id/titleText"
style="@style/Widget.Vector.TextView.Body.Medium"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:contentDescription="@string/avatar"
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintEnd_toStartOf="@id/liveIndicator"
app:layout_constraintStart_toEndOf="@id/avatarRightBarrier"
app:layout_constraintTop_toTopOf="parent"
tools:text="@sample/rooms.json/data/name" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/metadataFlow"
android:layout_width="wrap_content"
<LinearLayout
android:id="@+id/metadataGroup"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginEnd="4dp"
android:orientation="vertical"
app:constraint_referenced_ids="broadcasterNameMetadata,bufferingMetadata,voiceBroadcastMetadata,listenersCountMetadata"
app:flow_horizontalAlign="start"
app:flow_verticalGap="4dp"
app:layout_constraintEnd_toStartOf="@id/liveIndicator"
app:layout_constraintStart_toEndOf="@id/avatarRightBarrier"
app:layout_constraintTop_toBottomOf="@id/titleText" />
app:layout_constraintTop_toBottomOf="@id/titleText">
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/broadcasterNameMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:metadataIcon="@drawable/ic_voice_broadcast_mic"
tools:metadataValue="@sample/users.json/data/displayName" />
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/broadcasterNameMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
app:metadataIcon="@drawable/ic_voice_broadcast_mic"
tools:metadataValue="@sample/users.json/data/displayName" />
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastBufferingView
android:id="@+id/bufferingMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastBufferingView
android:id="@+id/bufferingMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp" />
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/voiceBroadcastMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:metadataIcon="@drawable/ic_voice_broadcast"
app:metadataValue="@string/attachment_type_voice_broadcast" />
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/voiceBroadcastMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
app:metadataIcon="@drawable/ic_voice_broadcast"
app:metadataValue="@string/attachment_type_voice_broadcast" />
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/listenersCountMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:metadataIcon="@drawable/ic_member_small"
app:metadataValue="@string/no_value_placeholder"
tools:metadataValue="5 listeners" />
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/listenersCountMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
app:metadataIcon="@drawable/ic_member_small"
app:metadataValue="@string/no_value_placeholder"
tools:metadataValue="5 listeners" />
</LinearLayout>
<androidx.constraintlayout.widget.Barrier
android:id="@+id/headerBottomBarrier"
@ -92,7 +100,7 @@
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:barrierMargin="10dp"
app:constraint_referenced_ids="roomAvatarImageView,titleText,metadataFlow" />
app:constraint_referenced_ids="roomAvatarImageView,titleText,metadataGroup" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/controllerButtonsFlow"

View file

@ -38,39 +38,45 @@
<TextView
android:id="@+id/titleText"
style="@style/Widget.Vector.TextView.Body.Medium"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:contentDescription="@string/avatar"
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintEnd_toStartOf="@id/liveIndicator"
app:layout_constraintStart_toEndOf="@id/avatarRightBarrier"
app:layout_constraintTop_toTopOf="parent"
tools:text="@sample/users.json/data/displayName" />
tools:text="@sample/rooms.json/data/name" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/metadataFlow"
android:layout_width="wrap_content"
<LinearLayout
android:id="@+id/metadataGroup"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginEnd="4dp"
android:orientation="vertical"
app:constraint_referenced_ids="listenersCountMetadata,remainingTimeMetadata"
app:flow_horizontalAlign="start"
app:flow_verticalGap="4dp"
app:layout_constraintEnd_toStartOf="@id/liveIndicator"
app:layout_constraintStart_toEndOf="@id/avatarRightBarrier"
app:layout_constraintTop_toBottomOf="@id/titleText" />
app:layout_constraintTop_toBottomOf="@id/titleText">
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/listenersCountMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:metadataIcon="@drawable/ic_member_small"
app:metadataValue="@string/no_value_placeholder"
tools:metadataValue="5 listening" />
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/listenersCountMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
app:metadataIcon="@drawable/ic_member_small"
app:metadataValue="@string/no_value_placeholder"
tools:metadataValue="5 listening" />
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/remainingTimeMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:metadataIcon="@drawable/ic_timer"
tools:metadataValue="3h 2m 50s left" />
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/remainingTimeMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
app:metadataIcon="@drawable/ic_timer"
tools:metadataValue="3h 2m 50s left" />
</LinearLayout>
<androidx.constraintlayout.widget.Barrier
android:id="@+id/headerBottomBarrier"
@ -78,7 +84,7 @@
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:barrierMargin="12dp"
app:constraint_referenced_ids="roomAvatarImageView,titleText,metadataFlow" />
app:constraint_referenced_ids="roomAvatarImageView,titleText,metadataGroup" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/controllerButtonsFlow"
@ -107,4 +113,27 @@
android:contentDescription="@string/a11y_stop_voice_broadcast_record"
android:src="@drawable/ic_stop" />
<androidx.constraintlayout.widget.Group
android:id="@+id/controlsGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="controllerButtonsFlow,recordButton,stopRecordButton" />
<TextView
android:id="@+id/errorView"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="4dp"
android:gravity="center"
android:text="@string/error_voice_broadcast_no_connection_recording"
android:textColor="?colorError"
android:visibility="gone"
app:drawableStartCompat="@drawable/ic_voice_broadcast_error"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/headerBottomBarrier"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -60,7 +60,8 @@ class StartVoiceBroadcastUseCaseTest {
context = FakeContext().instance,
buildMeta = mockk(),
getRoomLiveVoiceBroadcastsUseCase = fakeGetRoomLiveVoiceBroadcastsUseCase,
stopVoiceBroadcastUseCase = mockk()
stopVoiceBroadcastUseCase = mockk(),
pauseVoiceBroadcastUseCase = mockk(),
)
)