Voice Broadcast - Add maximum length for recording

This commit is contained in:
Florian Renaud 2022-11-14 17:30:03 +01:00
parent 7349bc90c0
commit 361538254b
7 changed files with 132 additions and 19 deletions

View File

@ -3101,6 +3101,8 @@
<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_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_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_already_in_progress_message">You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.</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="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,10 +21,12 @@ import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.epoxy.onClick import im.vector.app.core.epoxy.onClick
import im.vector.app.core.utils.TextUtils
import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction
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.views.VoiceBroadcastMetadataView import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
import org.threeten.bp.Duration
@EpoxyModelClass @EpoxyModelClass
abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem<MessageVoiceBroadcastRecordingItem.Holder>() { abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem<MessageVoiceBroadcastRecordingItem.Holder>() {
@ -37,11 +39,15 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem
} }
private fun bindVoiceBroadcastItem(holder: Holder) { private fun bindVoiceBroadcastItem(holder: Holder) {
if (recorder != null && recorder?.state != VoiceBroadcastRecorder.State.Idle) { if (recorder != null && recorder?.recordingState != VoiceBroadcastRecorder.State.Idle) {
recorderListener = object : VoiceBroadcastRecorder.Listener { recorderListener = object : VoiceBroadcastRecorder.Listener {
override fun onStateUpdated(state: VoiceBroadcastRecorder.State) { override fun onStateUpdated(state: VoiceBroadcastRecorder.State) {
renderRecordingState(holder, state) renderRecordingState(holder, state)
} }
override fun onRemainingTimeUpdated(remainingTime: Long?) {
renderRemainingTime(holder, remainingTime)
}
}.also { recorder?.addListener(it) } }.also { recorder?.addListener(it) }
} else { } else {
renderVoiceBroadcastState(holder) renderVoiceBroadcastState(holder)
@ -58,9 +64,19 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem
} }
override fun renderMetadata(holder: Holder) { override fun renderMetadata(holder: Holder) {
with(holder) { holder.listenersCountMetadata.isVisible = false
listenersCountMetadata.isVisible = false }
remainingTimeMetadata.isVisible = false
private fun renderRemainingTime(holder: Holder, remainingTime: Long?) {
if (remainingTime != null) {
val formattedDuration = TextUtils.formatDurationWithUnits(
holder.view.context,
Duration.ofSeconds(remainingTime.coerceAtLeast(0L))
)
holder.remainingTimeMetadata.value = holder.view.resources.getString(R.string.voice_broadcast_recording_time_left, formattedDuration)
holder.remainingTimeMetadata.isVisible = true
} else {
holder.remainingTimeMetadata.isVisible = false
} }
} }

View File

@ -28,4 +28,7 @@ object VoiceBroadcastConstants {
/** Default voice broadcast chunk duration, in seconds. */ /** Default voice broadcast chunk duration, in seconds. */
const val DEFAULT_CHUNK_LENGTH_IN_SECONDS = 120 const val DEFAULT_CHUNK_LENGTH_IN_SECONDS = 120
/** Maximum length of the voice broadcast in seconds. */
const val MAX_VOICE_BROADCAST_LENGTH_IN_SECONDS = 14_400 // 4 hours
} }

View File

@ -22,16 +22,23 @@ import java.io.File
interface VoiceBroadcastRecorder : VoiceRecorder { interface VoiceBroadcastRecorder : VoiceRecorder {
/** The current chunk number. */
val currentSequence: Int val currentSequence: Int
val state: State
fun startRecord(roomId: String, chunkLength: Int) /** Current state of the recorder. */
val recordingState: State
/** Current remaining time of recording, in seconds, if any. */
val currentRemainingTime: Long?
fun startRecord(roomId: String, chunkLength: Int, maxLength: Int)
fun addListener(listener: Listener) fun addListener(listener: Listener)
fun removeListener(listener: Listener) fun removeListener(listener: Listener)
interface Listener { interface Listener {
fun onVoiceMessageCreated(file: File, @IntRange(from = 1) sequence: Int) = Unit fun onVoiceMessageCreated(file: File, @IntRange(from = 1) sequence: Int) = Unit
fun onStateUpdated(state: State) = Unit fun onStateUpdated(state: State) = Unit
fun onRemainingTimeUpdated(remainingTime: Long?) = Unit
} }
enum class State { enum class State {

View File

@ -21,9 +21,11 @@ import android.media.MediaRecorder
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import im.vector.app.features.voice.AbstractVoiceRecorderQ import im.vector.app.features.voice.AbstractVoiceRecorderQ
import im.vector.lib.core.utils.timer.CountUpTimer
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
class VoiceBroadcastRecorderQ( class VoiceBroadcastRecorderQ(
@ -32,13 +34,21 @@ class VoiceBroadcastRecorderQ(
private var maxFileSize = 0L // zero or negative for no limit private var maxFileSize = 0L // zero or negative for no limit
private var currentRoomId: String? = null private var currentRoomId: String? = null
private var currentMaxLength: Int = 0
override var currentSequence = 0 override var currentSequence = 0
override var state = VoiceBroadcastRecorder.State.Idle override var recordingState = VoiceBroadcastRecorder.State.Idle
set(value) { set(value) {
field = value field = value
listeners.forEach { it.onStateUpdated(value) } listeners.forEach { it.onStateUpdated(value) }
} }
override var currentRemainingTime: Long? = null
set(value) {
field = value
listeners.forEach { it.onRemainingTimeUpdated(value) }
}
private val recordingTicker = RecordingTicker()
private val listeners = CopyOnWriteArrayList<VoiceBroadcastRecorder.Listener>() private val listeners = CopyOnWriteArrayList<VoiceBroadcastRecorder.Listener>()
override val outputFormat = MediaRecorder.OutputFormat.MPEG_4 override val outputFormat = MediaRecorder.OutputFormat.MPEG_4
@ -58,33 +68,47 @@ class VoiceBroadcastRecorderQ(
} }
} }
override fun startRecord(roomId: String, chunkLength: Int) { override fun startRecord(roomId: String, chunkLength: Int, maxLength: Int) {
currentRoomId = roomId currentRoomId = roomId
maxFileSize = (chunkLength * audioEncodingBitRate / 8).toLong() maxFileSize = (chunkLength * audioEncodingBitRate / 8).toLong()
currentMaxLength = maxLength
currentSequence = 1 currentSequence = 1
startRecord(roomId) startRecord(roomId)
state = VoiceBroadcastRecorder.State.Recording recordingState = VoiceBroadcastRecorder.State.Recording
recordingTicker.start()
} }
override fun pauseRecord() { override fun pauseRecord() {
tryOrNull { mediaRecorder?.stop() } tryOrNull { mediaRecorder?.stop() }
mediaRecorder?.reset() mediaRecorder?.reset()
recordingState = VoiceBroadcastRecorder.State.Paused
recordingTicker.pause()
notifyOutputFileCreated() notifyOutputFileCreated()
state = VoiceBroadcastRecorder.State.Paused
} }
override fun resumeRecord() { override fun resumeRecord() {
currentSequence++ currentSequence++
currentRoomId?.let { startRecord(it) } currentRoomId?.let { startRecord(it) }
state = VoiceBroadcastRecorder.State.Recording recordingState = VoiceBroadcastRecorder.State.Recording
recordingTicker.resume()
} }
override fun stopRecord() { override fun stopRecord() {
super.stopRecord() super.stopRecord()
// Stop recording
recordingState = VoiceBroadcastRecorder.State.Idle
recordingTicker.stop()
notifyOutputFileCreated() notifyOutputFileCreated()
// Remove listeners
listeners.clear() listeners.clear()
// Reset data
currentSequence = 0 currentSequence = 0
state = VoiceBroadcastRecorder.State.Idle currentMaxLength = 0
currentRemainingTime = null
currentRoomId = null
} }
override fun release() { override fun release() {
@ -94,7 +118,8 @@ class VoiceBroadcastRecorderQ(
override fun addListener(listener: VoiceBroadcastRecorder.Listener) { override fun addListener(listener: VoiceBroadcastRecorder.Listener) {
listeners.add(listener) listeners.add(listener)
listener.onStateUpdated(state) listener.onStateUpdated(recordingState)
listener.onRemainingTimeUpdated(currentRemainingTime)
} }
override fun removeListener(listener: VoiceBroadcastRecorder.Listener) { override fun removeListener(listener: VoiceBroadcastRecorder.Listener) {
@ -117,4 +142,53 @@ class VoiceBroadcastRecorderQ(
nextOutputFile = null nextOutputFile = null
} }
} }
private fun onElapsedTimeUpdated(elapsedTimeMillis: Long) {
currentRemainingTime = if (currentMaxLength > 0 && recordingState != VoiceBroadcastRecorder.State.Idle) {
val currentMaxLengthMillis = TimeUnit.SECONDS.toMillis(currentMaxLength.toLong())
val remainingTimeMillis = currentMaxLengthMillis - elapsedTimeMillis
TimeUnit.MILLISECONDS.toSeconds(remainingTimeMillis)
} else {
null
}
}
private inner class RecordingTicker(
private var recordingTicker: CountUpTimer? = null,
) {
fun start() {
recordingTicker?.stop()
recordingTicker = CountUpTimer().apply {
tickListener = CountUpTimer.TickListener { onTick(elapsedTime()) }
resume()
onTick(elapsedTime())
}
}
fun pause() {
recordingTicker?.apply {
pause()
onTick(elapsedTime())
}
}
fun resume() {
recordingTicker?.apply {
resume()
onTick(elapsedTime())
}
}
fun stop() {
recordingTicker?.apply {
stop()
onTick(elapsedTime())
recordingTicker = null
}
}
private fun onTick(elapsedTimeMillis: Long) {
onElapsedTimeUpdated(elapsedTimeMillis)
}
}
} }

View File

@ -20,6 +20,7 @@ import android.content.Context
import androidx.core.content.FileProvider 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.session.coroutineScope
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.VoiceBroadcastFailure
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
@ -28,6 +29,7 @@ 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 kotlinx.coroutines.launch
import org.jetbrains.annotations.VisibleForTesting import org.jetbrains.annotations.VisibleForTesting
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
@ -51,6 +53,7 @@ class StartVoiceBroadcastUseCase @Inject constructor(
private val context: Context, private val context: Context,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase, private val getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase,
private val stopVoiceBroadcastUseCase: StopVoiceBroadcastUseCase,
) { ) {
suspend fun execute(roomId: String): Result<Unit> = runCatching { suspend fun execute(roomId: String): Result<Unit> = runCatching {
@ -64,7 +67,8 @@ class StartVoiceBroadcastUseCase @Inject constructor(
private suspend fun startVoiceBroadcast(room: Room) { private suspend fun startVoiceBroadcast(room: Room) {
Timber.d("## StartVoiceBroadcastUseCase: Send new voice broadcast info state event") Timber.d("## StartVoiceBroadcastUseCase: Send new voice broadcast info state event")
val chunkLength = VoiceBroadcastConstants.DEFAULT_CHUNK_LENGTH_IN_SECONDS // Todo Get the length from the room settings val chunkLength = VoiceBroadcastConstants.DEFAULT_CHUNK_LENGTH_IN_SECONDS // Todo Get the chunk length from the room settings
val maxLength = VoiceBroadcastConstants.MAX_VOICE_BROADCAST_LENGTH_IN_SECONDS // Todo Get the max length from the room settings
val eventId = room.stateService().sendStateEvent( val eventId = room.stateService().sendStateEvent(
eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = session.myUserId, stateKey = session.myUserId,
@ -75,16 +79,22 @@ class StartVoiceBroadcastUseCase @Inject constructor(
).toContent() ).toContent()
) )
startRecording(room, eventId, chunkLength) startRecording(room, eventId, chunkLength, maxLength)
} }
private fun startRecording(room: Room, eventId: String, chunkLength: Int) { private fun startRecording(room: Room, eventId: String, chunkLength: Int, maxLength: Int) {
voiceBroadcastRecorder?.addListener(object : VoiceBroadcastRecorder.Listener { voiceBroadcastRecorder?.addListener(object : VoiceBroadcastRecorder.Listener {
override fun onVoiceMessageCreated(file: File, sequence: Int) { override fun onVoiceMessageCreated(file: File, sequence: Int) {
sendVoiceFile(room, file, eventId, sequence) sendVoiceFile(room, file, eventId, sequence)
} }
override fun onRemainingTimeUpdated(remainingTime: Long?) {
if (remainingTime != null && remainingTime <= 0) {
session.coroutineScope.launch { stopVoiceBroadcastUseCase.execute(room.roomId) }
}
}
}) })
voiceBroadcastRecorder?.startRecord(room.roomId, chunkLength) voiceBroadcastRecorder?.startRecord(room.roomId, chunkLength, maxLength)
} }
private fun sendVoiceFile(room: Room, voiceMessageFile: File, referenceEventId: String, sequence: Int) { private fun sendVoiceFile(room: Room, voiceMessageFile: File, referenceEventId: String, sequence: Int) {
@ -127,7 +137,7 @@ class StartVoiceBroadcastUseCase @Inject constructor(
@VisibleForTesting @VisibleForTesting
fun assertNoOngoingVoiceBroadcast(room: Room) { fun assertNoOngoingVoiceBroadcast(room: Room) {
when { when {
voiceBroadcastRecorder?.state == VoiceBroadcastRecorder.State.Recording || voiceBroadcastRecorder?.state == VoiceBroadcastRecorder.State.Paused -> { voiceBroadcastRecorder?.recordingState == VoiceBroadcastRecorder.State.Recording || voiceBroadcastRecorder?.recordingState == VoiceBroadcastRecorder.State.Paused -> {
Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: another voice broadcast") Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: another voice broadcast")
throw VoiceBroadcastFailure.RecordingError.UserAlreadyBroadcasting throw VoiceBroadcastFailure.RecordingError.UserAlreadyBroadcasting
} }

View File

@ -60,6 +60,7 @@ class StartVoiceBroadcastUseCaseTest {
context = FakeContext().instance, context = FakeContext().instance,
buildMeta = mockk(), buildMeta = mockk(),
getOngoingVoiceBroadcastsUseCase = fakeGetOngoingVoiceBroadcastsUseCase, getOngoingVoiceBroadcastsUseCase = fakeGetOngoingVoiceBroadcastsUseCase,
stopVoiceBroadcastUseCase = mockk()
) )
) )
@ -67,7 +68,7 @@ class StartVoiceBroadcastUseCaseTest {
fun setup() { fun setup() {
every { fakeRoom.roomId } returns A_ROOM_ID every { fakeRoom.roomId } returns A_ROOM_ID
justRun { startVoiceBroadcastUseCase.assertHasEnoughPowerLevels(fakeRoom) } justRun { startVoiceBroadcastUseCase.assertHasEnoughPowerLevels(fakeRoom) }
every { fakeVoiceBroadcastRecorder.state } returns VoiceBroadcastRecorder.State.Idle every { fakeVoiceBroadcastRecorder.recordingState } returns VoiceBroadcastRecorder.State.Idle
} }
@Test @Test