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