diff --git a/changelog.d/7588.wip b/changelog.d/7588.wip new file mode 100644 index 0000000000..b3fdda55fc --- /dev/null +++ b/changelog.d/7588.wip @@ -0,0 +1 @@ +Voice Broadcast - Add maximum length diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 372692770e..e503cb3fe7 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3101,6 +3101,8 @@ You don’t have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions. 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. + + %1$s left Anyone in %s will be able to find and join this room - no need to manually invite everyone. You’ll be able to change this in room settings anytime. Anyone in a parent space will be able to find and join this room - no need to manually invite everyone. You’ll be able to change this in room settings anytime. diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt index 07d7ad4d0e..b5ea528bd7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt @@ -66,7 +66,7 @@ class AudioMessageHelper @Inject constructor( fun startRecording(roomId: String) { stopPlayback() - playbackTracker.makeAllPlaybacksIdle() + playbackTracker.pauseAllPlaybacks() amplitudeList.clear() try { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt index b7b3846a10..90fd66f9ab 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt @@ -51,15 +51,7 @@ class AudioMessagePlaybackTracker @Inject constructor() { } fun pauseAllPlaybacks() { - listeners.keys.forEach { key -> - pausePlayback(key) - } - } - - fun makeAllPlaybacksIdle() { - listeners.keys.forEach { key -> - setState(key, Listener.State.Idle) - } + listeners.keys.forEach(::pausePlayback) } /** 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 9bd6fc45ec..39d2d73c68 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 @@ -21,10 +21,12 @@ import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R 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.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView +import org.threeten.bp.Duration @EpoxyModelClass abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem() { @@ -37,11 +39,15 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem } 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 { override fun onStateUpdated(state: VoiceBroadcastRecorder.State) { renderRecordingState(holder, state) } + + override fun onRemainingTimeUpdated(remainingTime: Long?) { + renderRemainingTime(holder, remainingTime) + } }.also { recorder?.addListener(it) } } else { renderVoiceBroadcastState(holder) @@ -58,9 +64,19 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem } override fun renderMetadata(holder: Holder) { - with(holder) { - listenersCountMetadata.isVisible = false - remainingTimeMetadata.isVisible = false + holder.listenersCountMetadata.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 } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt index 551eaa4dac..11b4f50d2f 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt @@ -28,4 +28,7 @@ object VoiceBroadcastConstants { /** Default voice broadcast chunk duration, in seconds. */ 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 } 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 8bc33ed769..bc13d1fea8 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 @@ -22,16 +22,23 @@ import java.io.File interface VoiceBroadcastRecorder : VoiceRecorder { + /** The current chunk number. */ 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 removeListener(listener: Listener) interface Listener { fun onVoiceMessageCreated(file: File, @IntRange(from = 1) sequence: Int) = Unit fun onStateUpdated(state: State) = Unit + fun onRemainingTimeUpdated(remainingTime: Long?) = Unit } enum class State { 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 519f1f24aa..c5408b768b 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 @@ -21,9 +21,11 @@ import android.media.MediaRecorder import android.os.Build import androidx.annotation.RequiresApi 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.session.content.ContentAttachmentData import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit @RequiresApi(Build.VERSION_CODES.Q) class VoiceBroadcastRecorderQ( @@ -32,13 +34,21 @@ class VoiceBroadcastRecorderQ( private var maxFileSize = 0L // zero or negative for no limit private var currentRoomId: String? = null + private var currentMaxLength: Int = 0 + override var currentSequence = 0 - override var state = VoiceBroadcastRecorder.State.Idle + override var recordingState = VoiceBroadcastRecorder.State.Idle set(value) { field = 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() 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 maxFileSize = (chunkLength * audioEncodingBitRate / 8).toLong() + currentMaxLength = maxLength currentSequence = 1 startRecord(roomId) - state = VoiceBroadcastRecorder.State.Recording + recordingState = VoiceBroadcastRecorder.State.Recording + recordingTicker.start() } override fun pauseRecord() { tryOrNull { mediaRecorder?.stop() } mediaRecorder?.reset() + recordingState = VoiceBroadcastRecorder.State.Paused + recordingTicker.pause() notifyOutputFileCreated() - state = VoiceBroadcastRecorder.State.Paused } override fun resumeRecord() { currentSequence++ currentRoomId?.let { startRecord(it) } - state = VoiceBroadcastRecorder.State.Recording + recordingState = VoiceBroadcastRecorder.State.Recording + recordingTicker.resume() } override fun stopRecord() { super.stopRecord() + + // Stop recording + recordingState = VoiceBroadcastRecorder.State.Idle + recordingTicker.stop() notifyOutputFileCreated() + + // Remove listeners listeners.clear() + + // Reset data currentSequence = 0 - state = VoiceBroadcastRecorder.State.Idle + currentMaxLength = 0 + currentRemainingTime = null + currentRoomId = null } override fun release() { @@ -94,7 +118,8 @@ class VoiceBroadcastRecorderQ( override fun addListener(listener: VoiceBroadcastRecorder.Listener) { listeners.add(listener) - listener.onStateUpdated(state) + listener.onStateUpdated(recordingState) + listener.onRemainingTimeUpdated(currentRemainingTime) } override fun removeListener(listener: VoiceBroadcastRecorder.Listener) { @@ -117,4 +142,53 @@ class VoiceBroadcastRecorderQ( 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) + } + } } 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 85f72c09da..45f622ad92 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 @@ -20,6 +20,7 @@ import android.content.Context import androidx.core.content.FileProvider import im.vector.app.core.resources.BuildMeta 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.VoiceBroadcastFailure 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.usecase.GetOngoingVoiceBroadcastsUseCase import im.vector.lib.multipicker.utils.toMultiPickerAudioType +import kotlinx.coroutines.launch import org.jetbrains.annotations.VisibleForTesting import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session @@ -51,6 +53,7 @@ class StartVoiceBroadcastUseCase @Inject constructor( private val context: Context, private val buildMeta: BuildMeta, private val getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase, + private val stopVoiceBroadcastUseCase: StopVoiceBroadcastUseCase, ) { suspend fun execute(roomId: String): Result = runCatching { @@ -64,7 +67,8 @@ class StartVoiceBroadcastUseCase @Inject constructor( private suspend fun startVoiceBroadcast(room: Room) { 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( eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, stateKey = session.myUserId, @@ -75,16 +79,22 @@ class StartVoiceBroadcastUseCase @Inject constructor( ).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 { override fun onVoiceMessageCreated(file: File, sequence: Int) { 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) { @@ -127,7 +137,8 @@ class StartVoiceBroadcastUseCase @Inject constructor( @VisibleForTesting fun assertNoOngoingVoiceBroadcast(room: Room) { 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") throw VoiceBroadcastFailure.RecordingError.UserAlreadyBroadcasting } 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 ef78f1c80d..5b4076378c 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,6 +60,7 @@ class StartVoiceBroadcastUseCaseTest { context = FakeContext().instance, buildMeta = mockk(), getOngoingVoiceBroadcastsUseCase = fakeGetOngoingVoiceBroadcastsUseCase, + stopVoiceBroadcastUseCase = mockk() ) ) @@ -67,7 +68,7 @@ class StartVoiceBroadcastUseCaseTest { fun setup() { every { fakeRoom.roomId } returns A_ROOM_ID justRun { startVoiceBroadcastUseCase.assertHasEnoughPowerLevels(fakeRoom) } - every { fakeVoiceBroadcastRecorder.state } returns VoiceBroadcastRecorder.State.Idle + every { fakeVoiceBroadcastRecorder.recordingState } returns VoiceBroadcastRecorder.State.Idle } @Test