Merge pull request #7485 from vector-im/feature/fre/start_voice_broadcast_error

Voice Broadcast - Show start voice broadcast errors in a dialog
This commit is contained in:
Florian Renaud 2022-10-31 16:23:23 +01:00 committed by GitHub
commit 48cca9973b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 115 additions and 18 deletions

1
changelog.d/7485.wip Normal file
View File

@ -0,0 +1 @@
[Voice Broadcast] Display an error dialog if the user fails to start a voice broadcast

View File

@ -3085,6 +3085,10 @@
<string name="a11y_play_voice_broadcast">Play or resume voice broadcast</string> <string name="a11y_play_voice_broadcast">Play or resume voice broadcast</string>
<string name="a11y_pause_voice_broadcast">Pause voice broadcast</string> <string name="a11y_pause_voice_broadcast">Pause voice broadcast</string>
<string name="a11y_voice_broadcast_buffering">Buffering</string> <string name="a11y_voice_broadcast_buffering">Buffering</string>
<string name="error_voice_broadcast_unauthorized_title">Cant start a new voice broadcast</string>
<string name="error_voice_broadcast_permission_denied_message">You dont have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.</string>
<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="upgrade_room_for_restricted">Anyone in %s will be able to find and join this room - no need to manually invite everyone. Youll be able to change this in room settings anytime.</string> <string name="upgrade_room_for_restricted">Anyone in %s will be able to find and join this room - no need to manually invite everyone. Youll be able to change this in room settings anytime.</string>
<string name="upgrade_room_for_restricted_no_param">Anyone in a parent space will be able to find and join this room - no need to manually invite everyone. Youll be able to change this in room settings anytime.</string> <string name="upgrade_room_for_restricted_no_param">Anyone in a parent space will be able to find and join this room - no need to manually invite everyone. Youll be able to change this in room settings anytime.</string>

View File

@ -21,6 +21,8 @@ import im.vector.app.R
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.features.call.dialpad.DialPadLookup import im.vector.app.features.call.dialpad.DialPadLookup
import im.vector.app.features.voice.VoiceFailure import im.vector.app.features.voice.VoiceFailure
import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure
import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure.RecordingError
import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.failure.MatrixError
import org.matrix.android.sdk.api.failure.MatrixIdFailure import org.matrix.android.sdk.api.failure.MatrixIdFailure
@ -135,6 +137,7 @@ class DefaultErrorFormatter @Inject constructor(
is MatrixIdFailure.InvalidMatrixId -> is MatrixIdFailure.InvalidMatrixId ->
stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id) stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id)
is VoiceFailure -> voiceMessageError(throwable) is VoiceFailure -> voiceMessageError(throwable)
is VoiceBroadcastFailure -> voiceBroadcastMessageError(throwable)
is ActivityNotFoundException -> is ActivityNotFoundException ->
stringProvider.getString(R.string.error_no_external_application_found) stringProvider.getString(R.string.error_no_external_application_found)
else -> throwable.localizedMessage else -> throwable.localizedMessage
@ -149,6 +152,14 @@ class DefaultErrorFormatter @Inject constructor(
} }
} }
private fun voiceBroadcastMessageError(throwable: VoiceBroadcastFailure): String {
return when (throwable) {
RecordingError.BlockedBySomeoneElse -> stringProvider.getString(R.string.error_voice_broadcast_blocked_by_someone_else_message)
RecordingError.NoPermission -> stringProvider.getString(R.string.error_voice_broadcast_permission_denied_message)
RecordingError.UserAlreadyBroadcasting -> stringProvider.getString(R.string.error_voice_broadcast_already_in_progress_message)
}
}
private fun limitExceededError(error: MatrixError): String { private fun limitExceededError(error: MatrixError): String {
val delay = error.retryAfterMillis val delay = error.retryAfterMillis

View File

@ -33,6 +33,7 @@ import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.activity.addCallback import androidx.activity.addCallback
import androidx.annotation.StringRes
import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.view.menu.MenuBuilder
import androidx.constraintlayout.widget.ConstraintSet import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -1320,8 +1321,12 @@ class TimelineFragment :
} }
private fun displayRoomDetailActionFailure(result: RoomDetailViewEvents.ActionFailure) { private fun displayRoomDetailActionFailure(result: RoomDetailViewEvents.ActionFailure) {
@StringRes val titleResId = when (result.action) {
RoomDetailAction.VoiceBroadcastAction.Recording.Start -> R.string.error_voice_broadcast_unauthorized_title
else -> R.string.dialog_title_error
}
MaterialAlertDialogBuilder(requireActivity()) MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.dialog_title_error) .setTitle(titleResId)
.setMessage(errorFormatter.toHumanReadable(result.throwable)) .setMessage(errorFormatter.toHumanReadable(result.throwable))
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()

View File

@ -604,7 +604,12 @@ class TimelineViewModel @AssistedInject constructor(
if (room == null) return if (room == null) return
viewModelScope.launch { viewModelScope.launch {
when (action) { when (action) {
RoomDetailAction.VoiceBroadcastAction.Recording.Start -> voiceBroadcastHelper.startVoiceBroadcast(room.roomId) RoomDetailAction.VoiceBroadcastAction.Recording.Start -> {
voiceBroadcastHelper.startVoiceBroadcast(room.roomId).fold(
{ _viewEvents.post(RoomDetailViewEvents.ActionSuccess(action)) },
{ _viewEvents.post(RoomDetailViewEvents.ActionFailure(action, it)) },
)
}
RoomDetailAction.VoiceBroadcastAction.Recording.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId) RoomDetailAction.VoiceBroadcastAction.Recording.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId)
RoomDetailAction.VoiceBroadcastAction.Recording.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId) RoomDetailAction.VoiceBroadcastAction.Recording.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId)
RoomDetailAction.VoiceBroadcastAction.Recording.Stop -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) RoomDetailAction.VoiceBroadcastAction.Recording.Stop -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId)

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.voicebroadcast
sealed class VoiceBroadcastFailure : Throwable() {
sealed class RecordingError : VoiceBroadcastFailure() {
object NoPermission : RecordingError()
object BlockedBySomeoneElse : RecordingError()
object UserAlreadyBroadcasting : RecordingError()
}
}

View File

@ -21,18 +21,26 @@ import androidx.core.content.FileProvider
import im.vector.app.core.resources.BuildMeta import im.vector.app.core.resources.BuildMeta
import im.vector.app.features.attachments.toContentAttachmentData import im.vector.app.features.attachments.toContentAttachmentData
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk import im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder
import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase
import im.vector.lib.multipicker.utils.toMultiPickerAudioType import im.vector.lib.multipicker.utils.toMultiPickerAudioType
import org.jetbrains.annotations.VisibleForTesting
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.getRoom 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.Room
import org.matrix.android.sdk.api.session.room.getStateEvent
import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -50,13 +58,8 @@ class StartVoiceBroadcastUseCase @Inject constructor(
Timber.d("## StartVoiceBroadcastUseCase: Start voice broadcast requested") Timber.d("## StartVoiceBroadcastUseCase: Start voice broadcast requested")
val onGoingVoiceBroadcastEvents = getOngoingVoiceBroadcastsUseCase.execute(roomId) assertCanStartVoiceBroadcast(room)
if (onGoingVoiceBroadcastEvents.isEmpty()) {
startVoiceBroadcast(room) startVoiceBroadcast(room)
} else {
Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: currentVoiceBroadcastEvents=$onGoingVoiceBroadcastEvents")
}
} }
private suspend fun startVoiceBroadcast(room: Room) { private suspend fun startVoiceBroadcast(room: Room) {
@ -102,4 +105,36 @@ class StartVoiceBroadcastUseCase @Inject constructor(
) )
) )
} }
private fun assertCanStartVoiceBroadcast(room: Room) {
assertHasEnoughPowerLevels(room)
assertNoOngoingVoiceBroadcast(room)
}
@VisibleForTesting
fun assertHasEnoughPowerLevels(room: Room) {
val powerLevelsHelper = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.IsEmpty)
?.content
?.toModel<PowerLevelsContent>()
?.let { PowerLevelsHelper(it) }
if (powerLevelsHelper?.isUserAllowedToSend(session.myUserId, true, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO) != true) {
Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: no permission")
throw VoiceBroadcastFailure.RecordingError.NoPermission
}
}
@VisibleForTesting
fun assertNoOngoingVoiceBroadcast(room: Room) {
when {
voiceBroadcastRecorder?.state == VoiceBroadcastRecorder.State.Recording || voiceBroadcastRecorder?.state == VoiceBroadcastRecorder.State.Paused -> {
Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: another voice broadcast")
throw VoiceBroadcastFailure.RecordingError.UserAlreadyBroadcasting
}
getOngoingVoiceBroadcastsUseCase.execute(room.roomId).isNotEmpty() -> {
Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: user already broadcasting")
throw VoiceBroadcastFailure.RecordingError.BlockedBySomeoneElse
}
}
}
} }

View File

@ -26,15 +26,17 @@ import im.vector.app.test.fakes.FakeContext
import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoom
import im.vector.app.test.fakes.FakeRoomService import im.vector.app.test.fakes.FakeRoomService
import im.vector.app.test.fakes.FakeSession import im.vector.app.test.fakes.FakeSession
import io.mockk.clearAllMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot import io.mockk.slot
import io.mockk.spyk
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeNull import org.amshove.kluent.shouldBeNull
import org.junit.Before
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.Content
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
@ -51,13 +53,22 @@ class StartVoiceBroadcastUseCaseTest {
private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom))
private val fakeVoiceBroadcastRecorder = mockk<VoiceBroadcastRecorder>(relaxed = true) private val fakeVoiceBroadcastRecorder = mockk<VoiceBroadcastRecorder>(relaxed = true)
private val fakeGetOngoingVoiceBroadcastsUseCase = mockk<GetOngoingVoiceBroadcastsUseCase>() private val fakeGetOngoingVoiceBroadcastsUseCase = mockk<GetOngoingVoiceBroadcastsUseCase>()
private val startVoiceBroadcastUseCase = StartVoiceBroadcastUseCase( private val startVoiceBroadcastUseCase = spyk(
StartVoiceBroadcastUseCase(
session = fakeSession, session = fakeSession,
voiceBroadcastRecorder = fakeVoiceBroadcastRecorder, voiceBroadcastRecorder = fakeVoiceBroadcastRecorder,
context = FakeContext().instance, context = FakeContext().instance,
buildMeta = mockk(), buildMeta = mockk(),
getOngoingVoiceBroadcastsUseCase = fakeGetOngoingVoiceBroadcastsUseCase, getOngoingVoiceBroadcastsUseCase = fakeGetOngoingVoiceBroadcastsUseCase,
) )
)
@Before
fun setup() {
every { fakeRoom.roomId } returns A_ROOM_ID
justRun { startVoiceBroadcastUseCase.assertHasEnoughPowerLevels(fakeRoom) }
every { fakeVoiceBroadcastRecorder.state } returns VoiceBroadcastRecorder.State.Idle
}
@Test @Test
fun `given a room id with potential several existing voice broadcast states when calling execute then the voice broadcast is started or not`() = runTest { fun `given a room id with potential several existing voice broadcast states when calling execute then the voice broadcast is started or not`() = runTest {
@ -83,7 +94,7 @@ class StartVoiceBroadcastUseCaseTest {
private suspend fun testVoiceBroadcastStarted(voiceBroadcasts: List<VoiceBroadcast>) { private suspend fun testVoiceBroadcastStarted(voiceBroadcasts: List<VoiceBroadcast>) {
// Given // Given
clearAllMocks() setup()
givenVoiceBroadcasts(voiceBroadcasts) givenVoiceBroadcasts(voiceBroadcasts)
val voiceBroadcastInfoContentInterceptor = slot<Content>() val voiceBroadcastInfoContentInterceptor = slot<Content>()
coEvery { fakeRoom.stateService().sendStateEvent(any(), any(), capture(voiceBroadcastInfoContentInterceptor)) } coAnswers { AN_EVENT_ID } coEvery { fakeRoom.stateService().sendStateEvent(any(), any(), capture(voiceBroadcastInfoContentInterceptor)) } coAnswers { AN_EVENT_ID }
@ -106,7 +117,7 @@ class StartVoiceBroadcastUseCaseTest {
private suspend fun testVoiceBroadcastNotStarted(voiceBroadcasts: List<VoiceBroadcast>) { private suspend fun testVoiceBroadcastNotStarted(voiceBroadcasts: List<VoiceBroadcast>) {
// Given // Given
clearAllMocks() setup()
givenVoiceBroadcasts(voiceBroadcasts) givenVoiceBroadcasts(voiceBroadcasts)
// When // When