Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/7588.wip
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Voice Broadcast - Add maximum length
2 changes: 2 additions & 0 deletions library/ui-strings/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3101,6 +3101,8 @@
<string name="error_voice_broadcast_permission_denied_message">You don’t 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>
<!-- 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. You’ll 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. You’ll be able to change this in room settings anytime.</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class AudioMessageHelper @Inject constructor(

fun startRecording(roomId: String) {
stopPlayback()
playbackTracker.makeAllPlaybacksIdle()
playbackTracker.pauseAllPlaybacks()
amplitudeList.clear()

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MessageVoiceBroadcastRecordingItem.Holder>() {
Expand All @@ -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)
Expand All @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<VoiceBroadcastRecorder.Listener>()

override val outputFormat = MediaRecorder.OutputFormat.MPEG_4
Expand All @@ -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() {
Expand All @@ -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) {
Expand All @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<Unit> = runCatching {
Expand All @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,15 @@ class StartVoiceBroadcastUseCaseTest {
context = FakeContext().instance,
buildMeta = mockk(),
getOngoingVoiceBroadcastsUseCase = fakeGetOngoingVoiceBroadcastsUseCase,
stopVoiceBroadcastUseCase = mockk()
)
)

@Before
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
Expand Down