diff --git a/changelog.d/7890.feature b/changelog.d/7890.feature new file mode 100644 index 0000000000..d86e01c36c --- /dev/null +++ b/changelog.d/7890.feature @@ -0,0 +1 @@ +[Voice Broadcast] Handle connection errors while recording diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 8b51a6f95b..852478a173 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3125,6 +3125,7 @@ Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one. You are already recording a voice broadcast. Please end your current voice broadcast to start a new one. Unable to play this voice broadcast. + Connection error - Recording paused %1$s left Stop live broadcasting? diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt index 39d2d73c68..abf14c0867 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt @@ -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(R.id.remainingTimeMetadata) val recordButton by bind(R.id.recordButton) val stopRecordButton by bind(R.id.stopRecordButton) + val errorView by bind(R.id.errorView) + val controlsGroup by bind(R.id.controlsGroup) } companion object { diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt index 00e4bb17dd..4f8b614e3a 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt @@ -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, } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt index 2da807293f..7ca6ab3c9c 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt @@ -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, ) { diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt index 0b22d7adf5..ee51f8280b 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt @@ -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 + } } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt index 87ea49cece..d807c67f74 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt @@ -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 = 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) } diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml index 2bac6a8e42..ebf4618692 100644 --- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml @@ -107,4 +107,27 @@ android:contentDescription="@string/a11y_stop_voice_broadcast_record" android:src="@drawable/ic_stop" /> + + + + diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt index 5dfdd379e0..9aa0ddf3b2 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt @@ -60,7 +60,8 @@ class StartVoiceBroadcastUseCaseTest { context = FakeContext().instance, buildMeta = mockk(), getRoomLiveVoiceBroadcastsUseCase = fakeGetRoomLiveVoiceBroadcastsUseCase, - stopVoiceBroadcastUseCase = mockk() + stopVoiceBroadcastUseCase = mockk(), + pauseVoiceBroadcastUseCase = mockk(), ) )