Merge pull request #7363 from vector-im/feature/fre/voice_broadcast_start_record

Voice Broadcast - Start record
This commit is contained in:
Florian Renaud 2022-10-18 16:43:18 +02:00 committed by GitHub
commit b67500515c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 581 additions and 226 deletions

1
changelog.d/7363.wip Normal file
View file

@ -0,0 +1 @@
[Voice Broadcast] Record and send not aggregated voice messages to the room

View file

@ -21,6 +21,7 @@ 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
import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.PollType import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.Cancelable
@ -71,7 +72,7 @@ interface SendService {
text: String, text: String,
formattedText: String? = null, formattedText: String? = null,
autoMarkdown: Boolean, autoMarkdown: Boolean,
rootThreadEventId: String? = null rootThreadEventId: String? = null,
): Cancelable ): Cancelable
/** /**
@ -81,13 +82,15 @@ interface SendService {
* @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present. * @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present.
* It can be useful to send media to multiple room. It's safe to include the current roomId in this set * It can be useful to send media to multiple room. It's safe to include the current roomId in this set
* @param rootThreadEventId when this param is not null, the Media will be sent in this specific thread * @param rootThreadEventId when this param is not null, the Media will be sent in this specific thread
* @param relatesTo add a relation content to the media event
* @return a [Cancelable] * @return a [Cancelable]
*/ */
fun sendMedia( fun sendMedia(
attachment: ContentAttachmentData, attachment: ContentAttachmentData,
compressBeforeSending: Boolean, compressBeforeSending: Boolean,
roomIds: Set<String>, roomIds: Set<String>,
rootThreadEventId: String? = null rootThreadEventId: String? = null,
relatesTo: RelationDefaultContent? = null,
): Cancelable ): Cancelable
/** /**
@ -103,7 +106,7 @@ interface SendService {
attachments: List<ContentAttachmentData>, attachments: List<ContentAttachmentData>,
compressBeforeSending: Boolean, compressBeforeSending: Boolean,
roomIds: Set<String>, roomIds: Set<String>,
rootThreadEventId: String? = null rootThreadEventId: String? = null,
): Cancelable ): Cancelable
/** /**

View file

@ -39,6 +39,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
import org.matrix.android.sdk.api.session.room.model.message.PollType import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.session.room.send.SendService
import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
@ -280,7 +281,8 @@ internal class DefaultSendService @AssistedInject constructor(
attachment: ContentAttachmentData, attachment: ContentAttachmentData,
compressBeforeSending: Boolean, compressBeforeSending: Boolean,
roomIds: Set<String>, roomIds: Set<String>,
rootThreadEventId: String? rootThreadEventId: String?,
relatesTo: RelationDefaultContent?,
): Cancelable { ): Cancelable {
// Ensure that the event will not be send in a thread if we are a different flow. // Ensure that the event will not be send in a thread if we are a different flow.
// Like sending files to multiple rooms // Like sending files to multiple rooms
@ -295,7 +297,8 @@ internal class DefaultSendService @AssistedInject constructor(
localEchoEventFactory.createMediaEvent( localEchoEventFactory.createMediaEvent(
roomId = it, roomId = it,
attachment = attachment, attachment = attachment,
rootThreadEventId = rootThreadId rootThreadEventId = rootThreadId,
relatesTo,
).also { event -> ).also { event ->
createLocalEcho(event) createLocalEcho(event)
} }

View file

@ -127,7 +127,7 @@ internal class LocalEchoEventFactory @Inject constructor(
newBodyFormattedText: CharSequence?, newBodyFormattedText: CharSequence?,
newBodyAutoMarkdown: Boolean, newBodyAutoMarkdown: Boolean,
msgType: String, msgType: String,
compatibilityText: String compatibilityText: String,
): Event { ): Event {
val content = if (newBodyFormattedText != null) { val content = if (newBodyFormattedText != null) {
TextContent(newBodyText.toString(), newBodyFormattedText.toString()).toMessageTextContent(msgType) TextContent(newBodyText.toString(), newBodyFormattedText.toString()).toMessageTextContent(msgType)
@ -148,7 +148,7 @@ internal class LocalEchoEventFactory @Inject constructor(
private fun createPollContent( private fun createPollContent(
question: String, question: String,
options: List<String>, options: List<String>,
pollType: PollType pollType: PollType,
): MessagePollContent { ): MessagePollContent {
return MessagePollContent( return MessagePollContent(
unstablePollCreationInfo = PollCreationInfo( unstablePollCreationInfo = PollCreationInfo(
@ -166,7 +166,7 @@ internal class LocalEchoEventFactory @Inject constructor(
pollType: PollType, pollType: PollType,
targetEventId: String, targetEventId: String,
question: String, question: String,
options: List<String> options: List<String>,
): Event { ): Event {
val newContent = MessagePollContent( val newContent = MessagePollContent(
relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId), relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId),
@ -186,7 +186,7 @@ internal class LocalEchoEventFactory @Inject constructor(
fun createPollReplyEvent( fun createPollReplyEvent(
roomId: String, roomId: String,
pollEventId: String, pollEventId: String,
answerId: String answerId: String,
): Event { ): Event {
val content = MessagePollResponseContent( val content = MessagePollResponseContent(
body = answerId, body = answerId,
@ -212,7 +212,7 @@ internal class LocalEchoEventFactory @Inject constructor(
roomId: String, roomId: String,
pollType: PollType, pollType: PollType,
question: String, question: String,
options: List<String> options: List<String>,
): Event { ): Event {
val content = createPollContent(question, options, pollType) val content = createPollContent(question, options, pollType)
val localId = LocalEcho.createLocalEchoId() val localId = LocalEcho.createLocalEchoId()
@ -229,7 +229,7 @@ internal class LocalEchoEventFactory @Inject constructor(
fun createEndPollEvent( fun createEndPollEvent(
roomId: String, roomId: String,
eventId: String eventId: String,
): Event { ): Event {
val content = MessageEndPollContent( val content = MessageEndPollContent(
relatesTo = RelationDefaultContent( relatesTo = RelationDefaultContent(
@ -254,7 +254,7 @@ internal class LocalEchoEventFactory @Inject constructor(
latitude: Double, latitude: Double,
longitude: Double, longitude: Double,
uncertainty: Double?, uncertainty: Double?,
isUserLocation: Boolean isUserLocation: Boolean,
): Event { ): Event {
val geoUri = buildGeoUri(latitude, longitude, uncertainty) val geoUri = buildGeoUri(latitude, longitude, uncertainty)
val assetType = if (isUserLocation) LocationAssetType.SELF else LocationAssetType.PIN val assetType = if (isUserLocation) LocationAssetType.SELF else LocationAssetType.PIN
@ -274,7 +274,7 @@ internal class LocalEchoEventFactory @Inject constructor(
roomId: String, roomId: String,
latitude: Double, latitude: Double,
longitude: Double, longitude: Double,
uncertainty: Double? uncertainty: Double?,
): Event { ): Event {
val geoUri = buildGeoUri(latitude, longitude, uncertainty) val geoUri = buildGeoUri(latitude, longitude, uncertainty)
val content = MessageBeaconLocationDataContent( val content = MessageBeaconLocationDataContent(
@ -305,7 +305,7 @@ internal class LocalEchoEventFactory @Inject constructor(
newBodyText: String, newBodyText: String,
autoMarkdown: Boolean, autoMarkdown: Boolean,
msgType: String, msgType: String,
compatibilityText: String compatibilityText: String,
): Event { ): Event {
val permalink = permalinkFactory.createPermalink(roomId, originalEvent.root.eventId ?: "", false) val permalink = permalinkFactory.createPermalink(roomId, originalEvent.root.eventId ?: "", false)
val userLink = originalEvent.root.senderId?.let { permalinkFactory.createPermalink(it, false) } ?: "" val userLink = originalEvent.root.senderId?.let { permalinkFactory.createPermalink(it, false) } ?: ""
@ -347,14 +347,21 @@ internal class LocalEchoEventFactory @Inject constructor(
fun createMediaEvent( fun createMediaEvent(
roomId: String, roomId: String,
attachment: ContentAttachmentData, attachment: ContentAttachmentData,
rootThreadEventId: String? rootThreadEventId: String?,
relatesTo: RelationDefaultContent?,
): Event { ): Event {
return when (attachment.type) { return when (attachment.type) {
ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment, rootThreadEventId) ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment, rootThreadEventId, relatesTo)
ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment, rootThreadEventId) ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment, rootThreadEventId, relatesTo)
ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment, isVoiceMessage = false, rootThreadEventId = rootThreadEventId) ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment, isVoiceMessage = false, rootThreadEventId = rootThreadEventId, relatesTo)
ContentAttachmentData.Type.VOICE_MESSAGE -> createAudioEvent(roomId, attachment, isVoiceMessage = true, rootThreadEventId = rootThreadEventId) ContentAttachmentData.Type.VOICE_MESSAGE -> createAudioEvent(
ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment, rootThreadEventId) roomId,
attachment,
isVoiceMessage = true,
rootThreadEventId = rootThreadEventId,
relatesTo,
)
ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment, rootThreadEventId, relatesTo)
} }
} }
@ -378,7 +385,12 @@ internal class LocalEchoEventFactory @Inject constructor(
) )
} }
private fun createImageEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event { private fun createImageEvent(
roomId: String,
attachment: ContentAttachmentData,
rootThreadEventId: String?,
relatesTo: RelationDefaultContent?,
): Event {
var width = attachment.width var width = attachment.width
var height = attachment.height var height = attachment.height
@ -403,19 +415,17 @@ internal class LocalEchoEventFactory @Inject constructor(
size = attachment.size size = attachment.size
), ),
url = attachment.queryUri.toString(), url = attachment.queryUri.toString(),
relatesTo = rootThreadEventId?.let { relatesTo = relatesTo ?: rootThreadEventId?.let { generateThreadRelationContent(it) }
RelationDefaultContent(
type = RelationType.THREAD,
eventId = it,
isFallingBack = true,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
)
}
) )
return createMessageEvent(roomId, content) return createMessageEvent(roomId, content)
} }
private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event { private fun createVideoEvent(
roomId: String,
attachment: ContentAttachmentData,
rootThreadEventId: String?,
relatesTo: RelationDefaultContent?,
): Event {
val mediaDataRetriever = MediaMetadataRetriever() val mediaDataRetriever = MediaMetadataRetriever()
mediaDataRetriever.setDataSource(context, attachment.queryUri) mediaDataRetriever.setDataSource(context, attachment.queryUri)
@ -447,14 +457,7 @@ internal class LocalEchoEventFactory @Inject constructor(
thumbnailInfo = thumbnailInfo thumbnailInfo = thumbnailInfo
), ),
url = attachment.queryUri.toString(), url = attachment.queryUri.toString(),
relatesTo = rootThreadEventId?.let { relatesTo = relatesTo ?: rootThreadEventId?.let { generateThreadRelationContent(it) }
RelationDefaultContent(
type = RelationType.THREAD,
eventId = it,
isFallingBack = true,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
)
}
) )
return createMessageEvent(roomId, content) return createMessageEvent(roomId, content)
} }
@ -463,7 +466,8 @@ internal class LocalEchoEventFactory @Inject constructor(
roomId: String, roomId: String,
attachment: ContentAttachmentData, attachment: ContentAttachmentData,
isVoiceMessage: Boolean, isVoiceMessage: Boolean,
rootThreadEventId: String? rootThreadEventId: String?,
relatesTo: RelationDefaultContent?,
): Event { ): Event {
val content = MessageAudioContent( val content = MessageAudioContent(
msgType = MessageType.MSGTYPE_AUDIO, msgType = MessageType.MSGTYPE_AUDIO,
@ -479,19 +483,17 @@ internal class LocalEchoEventFactory @Inject constructor(
waveform = waveformSanitizer.sanitize(attachment.waveform) waveform = waveformSanitizer.sanitize(attachment.waveform)
), ),
voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap(), voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap(),
relatesTo = rootThreadEventId?.let { relatesTo = relatesTo ?: rootThreadEventId?.let { generateThreadRelationContent(it) }
RelationDefaultContent(
type = RelationType.THREAD,
eventId = it,
isFallingBack = true,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
)
}
) )
return createMessageEvent(roomId, content) return createMessageEvent(roomId, content)
} }
private fun createFileEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event { private fun createFileEvent(
roomId: String,
attachment: ContentAttachmentData,
rootThreadEventId: String?,
relatesTo: RelationDefaultContent?,
): Event {
val content = MessageFileContent( val content = MessageFileContent(
msgType = MessageType.MSGTYPE_FILE, msgType = MessageType.MSGTYPE_FILE,
body = attachment.name ?: "file", body = attachment.name ?: "file",
@ -500,14 +502,7 @@ internal class LocalEchoEventFactory @Inject constructor(
size = attachment.size size = attachment.size
), ),
url = attachment.queryUri.toString(), url = attachment.queryUri.toString(),
relatesTo = rootThreadEventId?.let { relatesTo = relatesTo ?: rootThreadEventId?.let { generateThreadRelationContent(it) }
RelationDefaultContent(
type = RelationType.THREAD,
eventId = it,
isFallingBack = true,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
)
}
) )
return createMessageEvent(roomId, content) return createMessageEvent(roomId, content)
} }
@ -559,7 +554,7 @@ internal class LocalEchoEventFactory @Inject constructor(
text: CharSequence, text: CharSequence,
msgType: String, msgType: String,
autoMarkdown: Boolean, autoMarkdown: Boolean,
formattedText: String? formattedText: String?,
): Event { ): Event {
val content = formattedText?.let { TextContent(text.toString(), it) } ?: createTextContent(text, autoMarkdown) val content = formattedText?.let { TextContent(text.toString(), it) } ?: createTextContent(text, autoMarkdown)
return createEvent( return createEvent(
@ -588,7 +583,7 @@ internal class LocalEchoEventFactory @Inject constructor(
replyTextFormatted: CharSequence?, replyTextFormatted: CharSequence?,
autoMarkdown: Boolean, autoMarkdown: Boolean,
rootThreadEventId: String? = null, rootThreadEventId: String? = null,
showInThread: Boolean showInThread: Boolean,
): Event? { ): Event? {
// Fallbacks and event representation // Fallbacks and event representation
// TODO Add error/warning logs when any of this is null // TODO Add error/warning logs when any of this is null
@ -629,6 +624,14 @@ internal class LocalEchoEventFactory @Inject constructor(
return createMessageEvent(roomId, content) return createMessageEvent(roomId, content)
} }
private fun generateThreadRelationContent(rootThreadEventId: String) =
RelationDefaultContent(
type = RelationType.THREAD,
eventId = rootThreadEventId,
isFallingBack = true,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId)),
)
/** /**
* Generates the appropriate relatesTo object for a reply event. * Generates the appropriate relatesTo object for a reply event.
* It can either be a regular reply or a reply within a thread * It can either be a regular reply or a reply within a thread
@ -772,7 +775,7 @@ internal class LocalEchoEventFactory @Inject constructor(
text: String, text: String,
formattedText: String?, formattedText: String?,
autoMarkdown: Boolean, autoMarkdown: Boolean,
rootThreadEventId: String? rootThreadEventId: String?,
): Event { ): Event {
val messageContent = quotedEvent.getLastMessageContent() val messageContent = quotedEvent.getLastMessageContent()
val textMsg = if (messageContent is MessageContentWithFormattedBody) { messageContent.formattedBody } else { messageContent?.body } val textMsg = if (messageContent is MessageContentWithFormattedBody) { messageContent.formattedBody } else { messageContent?.body }

View file

@ -0,0 +1,41 @@
/*
* 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.core.di
import android.content.Context
import android.os.Build
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder
import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorderQ
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object VoiceModule {
@Provides
@Singleton
fun providesVoiceBroadcastRecorder(context: Context): VoiceBroadcastRecorder? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
VoiceBroadcastRecorderQ(context)
} else {
null
}
}
}

View file

@ -16,7 +16,7 @@
package im.vector.app.core.extensions package im.vector.app.core.extensions
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
@ -39,7 +39,9 @@ fun TimelineEvent.canReact(): Boolean {
fun TimelineEvent.getVectorLastMessageContent(): MessageContent? { fun TimelineEvent.getVectorLastMessageContent(): MessageContent? {
// Iterate on event types which are not part of the matrix sdk, otherwise fallback to the sdk method // Iterate on event types which are not part of the matrix sdk, otherwise fallback to the sdk method
return when (root.getClearType()) { return when (root.getClearType()) {
STATE_ROOM_VOICE_BROADCAST_INFO -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessageVoiceBroadcastInfoContent>() VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> {
(annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessageVoiceBroadcastInfoContent>()
}
else -> getLastMessageContent() else -> getLastMessageContent()
} }
} }

View file

@ -55,8 +55,8 @@ class AudioMessageHelper @Inject constructor(
private var amplitudeTicker: CountUpTimer? = null private var amplitudeTicker: CountUpTimer? = null
private var playbackTicker: CountUpTimer? = null private var playbackTicker: CountUpTimer? = null
fun initializeRecorder(attachmentData: ContentAttachmentData) { fun initializeRecorder(roomId: String, attachmentData: ContentAttachmentData) {
voiceRecorder.initializeRecord(attachmentData) voiceRecorder.initializeRecord(roomId, attachmentData)
amplitudeList.clear() amplitudeList.clear()
attachmentData.waveform?.let { attachmentData.waveform?.let {
amplitudeList.addAll(it) amplitudeList.addAll(it)
@ -114,6 +114,7 @@ class AudioMessageHelper @Inject constructor(
* When entering in playback mode actually. * When entering in playback mode actually.
*/ */
fun pauseRecording() { fun pauseRecording() {
// TODO should we pause instead of stop?
voiceRecorder.stopRecord() voiceRecorder.stopRecord()
stopRecordingAmplitudes() stopRecordingAmplitudes()
} }
@ -221,6 +222,10 @@ class AudioMessageHelper @Inject constructor(
} }
} }
private fun resumeRecordingAmplitudes() {
amplitudeTicker?.resume()
}
private fun stopRecordingAmplitudes() { private fun stopRecordingAmplitudes() {
amplitudeTicker?.stop() amplitudeTicker?.stop()
amplitudeTicker = null amplitudeTicker = null

View file

@ -227,12 +227,20 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
if (withState(messageComposerViewModel) { it.isVoiceRecording } && requireActivity().isChangingConfigurations) { withState(messageComposerViewModel) {
when {
it.isVoiceRecording && requireActivity().isChangingConfigurations -> {
// we're rotating, maintain any active recordings // we're rotating, maintain any active recordings
} else { }
// TODO remove this when there will be a recording indicator outside of the timeline
// Pause voice broadcast if the timeline is not shown anymore
it.isVoiceBroadcasting && !requireActivity().isChangingConfigurations -> timelineViewModel.handle(VoiceBroadcastAction.Pause)
else -> {
messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(composer.text.toString())) messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(composer.text.toString()))
} }
} }
}
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()

View file

@ -16,6 +16,7 @@
package im.vector.app.features.home.room.detail.composer package im.vector.app.features.home.room.detail.composer
import androidx.lifecycle.asFlow
import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
@ -40,8 +41,11 @@ import im.vector.app.features.home.room.detail.toMessageType
import im.vector.app.features.powerlevel.PowerLevelsFlowFactory import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
import im.vector.app.features.session.coroutineScope import im.vector.app.features.session.coroutineScope
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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
@ -90,6 +94,7 @@ class MessageComposerViewModel @AssistedInject constructor(
init { init {
loadDraftIfAny() loadDraftIfAny()
observePowerLevelAndEncryption() observePowerLevelAndEncryption()
observeVoiceBroadcast()
subscribeToStateInternal() subscribeToStateInternal()
} }
@ -182,6 +187,16 @@ class MessageComposerViewModel @AssistedInject constructor(
} }
} }
private fun observeVoiceBroadcast() {
room.stateService().getStateEventLive(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, QueryStringValue.Equals(session.myUserId))
.asFlow()
.unwrap()
.mapNotNull { it.asVoiceBroadcastEvent()?.content?.voiceBroadcastState }
.setOnEach {
copy(voiceBroadcastState = it)
}
}
private fun handleEnterQuoteMode(action: MessageComposerAction.EnterQuoteMode) { private fun handleEnterQuoteMode(action: MessageComposerAction.EnterQuoteMode) {
room.getTimelineEvent(action.eventId)?.let { timelineEvent -> room.getTimelineEvent(action.eventId)?.let { timelineEvent ->
setState { copy(sendMode = SendMode.Quote(timelineEvent, currentComposerText)) } setState { copy(sendMode = SendMode.Quote(timelineEvent, currentComposerText)) }
@ -943,7 +958,7 @@ class MessageComposerViewModel @AssistedInject constructor(
} }
private fun handleInitializeVoiceRecorder(attachmentData: ContentAttachmentData) { private fun handleInitializeVoiceRecorder(attachmentData: ContentAttachmentData) {
audioMessageHelper.initializeRecorder(attachmentData) audioMessageHelper.initializeRecorder(room.roomId, attachmentData)
setState { copy(voiceRecordingUiState = VoiceMessageRecorderView.RecordingUiState.Draft) } setState { copy(voiceRecordingUiState = VoiceMessageRecorderView.RecordingUiState.Draft) }
} }

View file

@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail.composer
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.arguments.TimelineArgs
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import kotlin.random.Random import kotlin.random.Random
@ -67,6 +68,7 @@ data class MessageComposerViewState(
val startsThread: Boolean = false, val startsThread: Boolean = false,
val sendMode: SendMode = SendMode.Regular("", false), val sendMode: SendMode = SendMode.Regular("", false),
val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle, val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle,
val voiceBroadcastState: VoiceBroadcastState? = null,
val text: CharSequence? = null, val text: CharSequence? = null,
) : MavericksState { ) : MavericksState {
@ -77,6 +79,13 @@ data class MessageComposerViewState(
is VoiceMessageRecorderView.RecordingUiState.Recording -> true is VoiceMessageRecorderView.RecordingUiState.Recording -> true
} }
val isVoiceBroadcasting = when (voiceBroadcastState) {
VoiceBroadcastState.STARTED,
VoiceBroadcastState.PAUSED,
VoiceBroadcastState.RESUMED -> true
else -> false
}
val isVoiceMessageIdle = !isVoiceRecording val isVoiceMessageIdle = !isVoiceRecording
val isComposerVisible = canSendMessage.boolean() && !isVoiceRecording val isComposerVisible = canSendMessage.boolean() && !isVoiceRecording

View file

@ -17,7 +17,7 @@
package im.vector.app.features.home.room.detail.timeline.action package im.vector.app.features.home.room.detail.timeline.action
import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject import javax.inject.Inject
@ -31,7 +31,7 @@ class CheckIfCanRedactEventUseCase @Inject constructor(
val canRedactEventTypes: List<String> = listOf( val canRedactEventTypes: List<String> = listOf(
EventType.MESSAGE, EventType.MESSAGE,
EventType.STICKER, EventType.STICKER,
STATE_ROOM_VOICE_BROADCAST_INFO, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
) + ) +
EventType.POLL_START + EventType.POLL_START +
EventType.STATE_ROOM_BEACON_INFO EventType.STATE_ROOM_BEACON_INFO

View file

@ -21,7 +21,7 @@ import im.vector.app.core.epoxy.TimelineEmptyItem_
import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.features.analytics.DecryptionFailureTracker import im.vector.app.features.analytics.DecryptionFailureTracker
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import timber.log.Timber import timber.log.Timber
@ -89,7 +89,7 @@ class TimelineItemFactory @Inject constructor(
// State room create // State room create
EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(params) EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(params)
in EventType.STATE_ROOM_BEACON_INFO -> messageItemFactory.create(params) in EventType.STATE_ROOM_BEACON_INFO -> messageItemFactory.create(params)
STATE_ROOM_VOICE_BROADCAST_INFO -> messageItemFactory.create(params) VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> messageItemFactory.create(params)
// Unhandled state event types // Unhandled state event types
else -> { else -> {
// Should only happen when shouldShowHiddenEvents() settings is ON // Should only happen when shouldShowHiddenEvents() settings is ON

View file

@ -16,7 +16,7 @@
package im.vector.app.features.home.room.detail.timeline.helper package im.vector.app.features.home.room.detail.timeline.helper
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
@ -52,7 +52,7 @@ object TimelineDisplayableEvents {
EventType.STATE_ROOM_JOIN_RULES, EventType.STATE_ROOM_JOIN_RULES,
EventType.KEY_VERIFICATION_DONE, EventType.KEY_VERIFICATION_DONE,
EventType.KEY_VERIFICATION_CANCEL, EventType.KEY_VERIFICATION_CANCEL,
STATE_ROOM_VOICE_BROADCAST_INFO, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
) + ) +
EventType.POLL_START + EventType.POLL_START +
EventType.STATE_ROOM_BEACON_INFO + EventType.STATE_ROOM_BEACON_INFO +

View file

@ -17,7 +17,7 @@
package im.vector.app.features.home.room.detail.timeline.helper package im.vector.app.features.home.room.detail.timeline.helper
import im.vector.app.core.utils.TextUtils import im.vector.app.core.utils.TextUtils
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
@ -59,7 +59,7 @@ class TimelineEventsGroups {
val content = root.getClearContent() val content = root.getClearContent()
return when { return when {
EventType.isCallEvent(type) -> (content?.get("call_id") as? String) EventType.isCallEvent(type) -> (content?.get("call_id") as? String)
type == STATE_ROOM_VOICE_BROADCAST_INFO -> root.asVoiceBroadcastEvent()?.reference?.eventId type == VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> root.asVoiceBroadcastEvent()?.reference?.eventId
type == EventType.STATE_ROOM_WIDGET || type == EventType.STATE_ROOM_WIDGET_LEGACY -> root.stateKey type == EventType.STATE_ROOM_WIDGET || type == EventType.STATE_ROOM_WIDGET_LEGACY -> root.stateKey
else -> { else -> {
null null

View file

@ -17,75 +17,22 @@
package im.vector.app.features.voice package im.vector.app.features.voice
import android.content.Context import android.content.Context
import android.media.MediaRecorder
import android.os.Build
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 org.matrix.android.sdk.api.util.md5 import org.matrix.android.sdk.api.util.md5
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.util.UUID import java.util.UUID
abstract class AbstractVoiceRecorder( abstract class AbstractVoiceRecorder(
private val context: Context, private val context: Context,
private val filenameExt: String,
) : VoiceRecorder { ) : VoiceRecorder {
private val outputDirectory: File by lazy { ensureAudioDirectory(context) } private val outputDirectory: File by lazy { ensureAudioDirectory(context) }
protected var outputFile: File? = null
private var mediaRecorder: MediaRecorder? = null override fun initializeRecord(roomId: String, attachmentData: ContentAttachmentData?) {
private var outputFile: File? = null if (attachmentData != null) {
abstract fun setOutputFormat(mediaRecorder: MediaRecorder)
private fun init() {
createMediaRecorder().let {
it.setAudioSource(MediaRecorder.AudioSource.DEFAULT)
setOutputFormat(it)
it.setAudioEncodingBitRate(24000)
it.setAudioSamplingRate(48000)
mediaRecorder = it
}
}
private fun createMediaRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
}
override fun initializeRecord(attachmentData: ContentAttachmentData) {
outputFile = attachmentData.findVoiceFile(outputDirectory) outputFile = attachmentData.findVoiceFile(outputDirectory)
} }
override fun startRecord(roomId: String) {
init()
val fileName = "${UUID.randomUUID()}.$filenameExt"
val outputDirectoryForRoom = File(outputDirectory, roomId.md5()).apply {
mkdirs()
}
outputFile = File(outputDirectoryForRoom, fileName)
val mr = mediaRecorder ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
mr.setOutputFile(outputFile)
} else {
mr.setOutputFile(FileOutputStream(outputFile).fd)
}
mr.prepare()
mr.start()
}
override fun stopRecord() {
// Can throw when the record is less than 1 second.
mediaRecorder?.let {
tryOrNull { it.stop() }
it.reset()
it.release()
}
mediaRecorder = null
} }
override fun cancelRecord() { override fun cancelRecord() {
@ -95,11 +42,15 @@ abstract class AbstractVoiceRecorder(
outputFile = null outputFile = null
} }
override fun getMaxAmplitude(): Int {
return mediaRecorder?.maxAmplitude ?: 0
}
override fun getVoiceMessageFile(): File? { override fun getVoiceMessageFile(): File? {
return outputFile return outputFile
} }
protected fun createOutputFile(roomId: String): File {
val fileName = "${UUID.randomUUID()}.$fileNameExt"
val outputDirectoryForRoom = File(outputDirectory, roomId.md5()).apply {
mkdirs()
}
return File(outputDirectoryForRoom, fileName)
}
} }

View file

@ -0,0 +1,116 @@
/*
* Copyright (c) 2021 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.voice
import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import androidx.annotation.RequiresApi
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import java.io.File
/**
* VoiceRecorder abstraction to be used on Android versions >= [Build.VERSION_CODES.Q].
*/
@RequiresApi(Build.VERSION_CODES.Q)
abstract class AbstractVoiceRecorderQ(private val context: Context) : AbstractVoiceRecorder(context) {
var mediaRecorder: MediaRecorder? = null
protected var nextOutputFile: File? = null
private val audioSource: Int = MediaRecorder.AudioSource.DEFAULT
private val audioSamplingRate: Int = 48_000
protected val audioEncodingBitRate: Int = 24_000
abstract val outputFormat: Int // see MediaRecorder.OutputFormat
abstract val audioEncoder: Int // see MediaRecorder.AudioEncoder
override fun initializeRecord(roomId: String, attachmentData: ContentAttachmentData?) {
super.initializeRecord(roomId, attachmentData)
mediaRecorder = createMediaRecorder().apply {
setAudioSource(audioSource)
setOutputFormat()
setAudioEncodingBitRate(audioEncodingBitRate)
setAudioSamplingRate(audioSamplingRate)
}
setOutputFile(roomId)
}
override fun startRecord(roomId: String) {
initializeRecord(roomId = roomId)
mediaRecorder?.prepare()
mediaRecorder?.start()
}
override fun pauseRecord() {
// Can throw when the record is less than 1 second.
tryOrNull { mediaRecorder?.pause() }
}
override fun resumeRecord() {
mediaRecorder?.resume()
}
override fun stopRecord() {
// Can throw when the record is less than 1 second.
tryOrNull { mediaRecorder?.stop() }
mediaRecorder?.reset()
release()
}
override fun cancelRecord() {
super.cancelRecord()
nextOutputFile?.delete()
nextOutputFile = null
}
override fun getMaxAmplitude(): Int {
return mediaRecorder?.maxAmplitude ?: 0
}
protected open fun release() {
mediaRecorder?.release()
mediaRecorder = null
}
fun setNextOutputFile(roomId: String) {
val mediaRecorder = mediaRecorder ?: return
nextOutputFile = createOutputFile(roomId)
mediaRecorder.setNextOutputFile(nextOutputFile)
}
private fun createMediaRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
}
private fun MediaRecorder.setOutputFormat() {
setOutputFormat(outputFormat)
setAudioEncoder(audioEncoder)
}
private fun setOutputFile(roomId: String) {
val mediaRecorder = mediaRecorder ?: return
outputFile = outputFile ?: createOutputFile(roomId)
mediaRecorder.setOutputFile(outputFile)
}
}

View file

@ -22,11 +22,19 @@ import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import java.io.File import java.io.File
interface VoiceRecorder { interface VoiceRecorder {
/** /**
* Initialize recording with a pre-recorded file. * Audio file extension (eg. `mp4`).
* @param attachmentData data of the recorded file
*/ */
fun initializeRecord(attachmentData: ContentAttachmentData) val fileNameExt: String
/**
* Initialize recording with an optional pre-recorded file.
*
* @param roomId id of the room to initialize record
* @param attachmentData data of the pre-recorded file, if any.
*/
fun initializeRecord(roomId: String, attachmentData: ContentAttachmentData? = null)
/** /**
* Start the recording. * Start the recording.
@ -34,6 +42,16 @@ interface VoiceRecorder {
*/ */
fun startRecord(roomId: String) fun startRecord(roomId: String)
/**
* Pause the recording.
*/
fun pauseRecord()
/**
* Resume the recording.
*/
fun resumeRecord()
/** /**
* Stop the recording. * Stop the recording.
*/ */

View file

@ -23,6 +23,7 @@ import android.media.MediaRecorder
import android.media.audiofx.AutomaticGainControl import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor import android.media.audiofx.NoiseSuppressor
import android.os.Build import android.os.Build
import android.widget.Toast
import io.element.android.opusencoder.OggOpusEncoder import io.element.android.opusencoder.OggOpusEncoder
import io.element.android.opusencoder.configuration.SampleRate import io.element.android.opusencoder.configuration.SampleRate
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -30,29 +31,22 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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.util.md5
import java.io.File
import java.util.UUID
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
/** /**
* VoiceRecorder to be used on Android versions < [Build.VERSION_CODES.Q]. It uses libopus to record ogg files. * VoiceRecorder to be used on Android versions < [Build.VERSION_CODES.Q]. It uses libopus to record ogg files.
*/ */
class VoiceRecorderL( class VoiceRecorderL(
context: Context, private val context: Context,
coroutineContext: CoroutineContext, coroutineContext: CoroutineContext,
private val codec: OggOpusEncoder, private val codec: OggOpusEncoder,
) : VoiceRecorder { ) : AbstractVoiceRecorder(context) {
companion object { companion object {
private val SAMPLE_RATE = SampleRate.Rate48kHz private val SAMPLE_RATE = SampleRate.Rate48kHz
private const val BITRATE = 24 * 1024 private const val BITRATE = 24 * 1024
} }
private val outputDirectory: File by lazy { ensureAudioDirectory(context) }
private var outputFile: File? = null
private val recorderScope = CoroutineScope(coroutineContext) private val recorderScope = CoroutineScope(coroutineContext)
private var recordingJob: Job? = null private var recordingJob: Job? = null
@ -64,6 +58,8 @@ class VoiceRecorderL(
private var bufferSizeInShorts = 0 private var bufferSizeInShorts = 0
private var maxAmplitude = 0 private var maxAmplitude = 0
override val fileNameExt: String = "ogg"
private fun initializeCodec(filePath: String) { private fun initializeCodec(filePath: String) {
codec.init(filePath, SAMPLE_RATE) codec.init(filePath, SAMPLE_RATE)
codec.setBitrate(BITRATE) codec.setBitrate(BITRATE)
@ -85,19 +81,10 @@ class VoiceRecorderL(
} }
} }
override fun initializeRecord(attachmentData: ContentAttachmentData) {
outputFile = attachmentData.findVoiceFile(outputDirectory)
}
override fun startRecord(roomId: String) { override fun startRecord(roomId: String) {
val fileName = "${UUID.randomUUID()}.ogg" outputFile = createOutputFile(roomId).also {
val outputDirectoryForRoom = File(outputDirectory, roomId.md5()).apply { initializeCodec(it.absolutePath)
mkdirs()
} }
val outputFile = File(outputDirectoryForRoom, fileName)
this.outputFile = outputFile
initializeCodec(outputFile.absolutePath)
recordingJob = recorderScope.launch { recordingJob = recorderScope.launch {
audioRecorder?.startRecording() audioRecorder?.startRecording()
@ -112,6 +99,14 @@ class VoiceRecorderL(
} }
} }
override fun pauseRecord() {
Toast.makeText(context, "Not implemented for this Android version", Toast.LENGTH_SHORT).show()
}
override fun resumeRecord() {
Toast.makeText(context, "Not implemented for this Android version", Toast.LENGTH_SHORT).show()
}
override fun stopRecord() { override fun stopRecord() {
val recorder = this.audioRecorder ?: return val recorder = this.audioRecorder ?: return
recordingJob?.cancel() recordingJob?.cancel()
@ -131,19 +126,10 @@ class VoiceRecorderL(
codec.release() codec.release()
} }
override fun cancelRecord() {
outputFile?.delete()
outputFile = null
}
override fun getMaxAmplitude(): Int { override fun getMaxAmplitude(): Int {
return maxAmplitude return maxAmplitude
} }
override fun getVoiceMessageFile(): File? {
return outputFile
}
private fun createAudioRecord() { private fun createAudioRecord() {
val channelConfig = AudioFormat.CHANNEL_IN_MONO val channelConfig = AudioFormat.CHANNEL_IN_MONO
val format = AudioFormat.ENCODING_PCM_16BIT val format = AudioFormat.ENCODING_PCM_16BIT

View file

@ -22,13 +22,15 @@ import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
/** /**
* VoiceRecorder to be used on Android versions >= [Build.VERSION_CODES.Q]. It uses the native OPUS support on Android 10+. * VoiceRecorder to be used on Android versions >= [Build.VERSION_CODES.Q].
* It uses the native OPUS support on Android 10+.
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
class VoiceRecorderQ(context: Context) : AbstractVoiceRecorder(context, "ogg") { class VoiceRecorderQ(context: Context) : AbstractVoiceRecorderQ(context) {
override fun setOutputFormat(mediaRecorder: MediaRecorder) {
// We can directly use OGG here // We can directly use OGG here
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.OGG) override val outputFormat = MediaRecorder.OutputFormat.OGG
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS) override val audioEncoder = MediaRecorder.AudioEncoder.OPUS
}
override val fileNameExt: String = "ogg"
} }

View file

@ -16,5 +16,11 @@
package im.vector.app.features.voicebroadcast package im.vector.app.features.voicebroadcast
/** Voice Broadcast State Event. */ object VoiceBroadcastConstants {
const val STATE_ROOM_VOICE_BROADCAST_INFO = "io.element.voice_broadcast_info"
/** Voice Broadcast State Event. */
const val STATE_ROOM_VOICE_BROADCAST_INFO = "io.element.voice_broadcast_info"
/** Default voice broadcast chunk duration, in seconds. */
const val DEFAULT_CHUNK_LENGTH_IN_SECONDS = 30
}

View file

@ -0,0 +1,31 @@
/*
* 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
import im.vector.app.features.voice.VoiceRecorder
import java.io.File
interface VoiceBroadcastRecorder : VoiceRecorder {
var listener: Listener?
fun startRecord(roomId: String, chunkLength: Int)
fun interface Listener {
fun onVoiceMessageCreated(file: File)
}
}

View file

@ -0,0 +1,83 @@
/*
* 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
import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import androidx.annotation.RequiresApi
import im.vector.app.features.voice.AbstractVoiceRecorderQ
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
@RequiresApi(Build.VERSION_CODES.Q)
class VoiceBroadcastRecorderQ(
context: Context,
) : AbstractVoiceRecorderQ(context), VoiceBroadcastRecorder {
private var maxFileSize = 0L // zero or negative for no limit
override var listener: VoiceBroadcastRecorder.Listener? = null
override val outputFormat = MediaRecorder.OutputFormat.MPEG_4
override val audioEncoder = MediaRecorder.AudioEncoder.HE_AAC
override val fileNameExt: String = "mp4"
override fun initializeRecord(roomId: String, attachmentData: ContentAttachmentData?) {
super.initializeRecord(roomId, attachmentData)
mediaRecorder?.setMaxFileSize(maxFileSize)
mediaRecorder?.setOnInfoListener { _, what, _ ->
when (what) {
MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_APPROACHING -> onMaxFileSizeApproaching(roomId)
MediaRecorder.MEDIA_RECORDER_INFO_NEXT_OUTPUT_FILE_STARTED -> onNextOutputFileStarted()
else -> Unit // Nothing to do
}
}
}
override fun startRecord(roomId: String, chunkLength: Int) {
maxFileSize = (chunkLength * audioEncodingBitRate / 8).toLong()
startRecord(roomId)
}
override fun stopRecord() {
super.stopRecord()
notifyOutputFileCreated()
listener = null
}
override fun release() {
mediaRecorder?.setOnInfoListener(null)
super.release()
}
private fun onMaxFileSizeApproaching(roomId: String) {
setNextOutputFile(roomId)
}
private fun onNextOutputFileStarted() {
notifyOutputFileCreated()
}
private fun notifyOutputFileCreated() {
outputFile?.let {
listener?.onVoiceMessageCreated(it)
outputFile = nextOutputFile
nextOutputFile = null
}
}
}

View file

@ -18,7 +18,7 @@ package im.vector.app.features.voicebroadcast.model
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
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.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType.MSGTYPE_VOICE_BROADCAST_INFO import org.matrix.android.sdk.api.session.room.model.message.MessageType.MSGTYPE_VOICE_BROADCAST_INFO
@ -26,7 +26,7 @@ import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultCon
import timber.log.Timber import timber.log.Timber
/** /**
* Content of the state event of type [STATE_ROOM_VOICE_BROADCAST_INFO]. * Content of the state event of type [VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO].
* *
* It contains general info related to a voice broadcast. * It contains general info related to a voice broadcast.
*/ */
@ -41,7 +41,7 @@ data class MessageVoiceBroadcastInfoContent(
/** The [VoiceBroadcastState] value. **/ /** The [VoiceBroadcastState] value. **/
@Json(name = "state") val voiceBroadcastStateStr: String = "", @Json(name = "state") val voiceBroadcastStateStr: String = "",
/** The length of the voice chunks in seconds. **/ /** The length of the voice chunks in seconds. **/
@Json(name = "chunk_length") val chunkLength: Long? = null, @Json(name = "chunk_length") val chunkLength: Int? = null,
) : MessageContent { ) : MessageContent {
val voiceBroadcastState: VoiceBroadcastState? = VoiceBroadcastState.values() val voiceBroadcastState: VoiceBroadcastState? = VoiceBroadcastState.values()

View file

@ -16,14 +16,14 @@
package im.vector.app.features.voicebroadcast.model package im.vector.app.features.voicebroadcast.model
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
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.toModel import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
/** /**
* [Event] wrapper for [STATE_ROOM_VOICE_BROADCAST_INFO] event type. * [Event] wrapper for [VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO] event type.
* Provides additional fields and functions related to voice broadcast. * Provides additional fields and functions related to voice broadcast.
*/ */
@JvmInline @JvmInline
@ -50,6 +50,6 @@ value class VoiceBroadcastEvent(val root: Event) {
} }
/** /**
* Map a [STATE_ROOM_VOICE_BROADCAST_INFO] state event to a [VoiceBroadcastEvent]. * Map a [VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO] state event to a [VoiceBroadcastEvent].
*/ */
fun Event.asVoiceBroadcastEvent() = if (type == STATE_ROOM_VOICE_BROADCAST_INFO) VoiceBroadcastEvent(this) else null fun Event.asVoiceBroadcastEvent() = if (type == VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO) VoiceBroadcastEvent(this) else null

View file

@ -16,7 +16,8 @@
package im.vector.app.features.voicebroadcast.usecase package im.vector.app.features.voicebroadcast.usecase
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
@ -31,6 +32,7 @@ import javax.inject.Inject
class PauseVoiceBroadcastUseCase @Inject constructor( class PauseVoiceBroadcastUseCase @Inject constructor(
private val session: Session, private val session: Session,
private val voiceBroadcastRecorder: VoiceBroadcastRecorder?,
) { ) {
suspend fun execute(roomId: String): Result<Unit> = runCatching { suspend fun execute(roomId: String): Result<Unit> = runCatching {
@ -39,7 +41,7 @@ class PauseVoiceBroadcastUseCase @Inject constructor(
Timber.d("## PauseVoiceBroadcastUseCase: Pause voice broadcast requested") Timber.d("## PauseVoiceBroadcastUseCase: Pause voice broadcast requested")
val lastVoiceBroadcastEvent = room.stateService().getStateEvent( val lastVoiceBroadcastEvent = room.stateService().getStateEvent(
STATE_ROOM_VOICE_BROADCAST_INFO, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
QueryStringValue.Equals(session.myUserId) QueryStringValue.Equals(session.myUserId)
)?.asVoiceBroadcastEvent() )?.asVoiceBroadcastEvent()
when (val voiceBroadcastState = lastVoiceBroadcastEvent?.content?.voiceBroadcastState) { when (val voiceBroadcastState = lastVoiceBroadcastEvent?.content?.voiceBroadcastState) {
@ -52,7 +54,7 @@ class PauseVoiceBroadcastUseCase @Inject constructor(
private suspend fun pauseVoiceBroadcast(room: Room, reference: RelationDefaultContent?) { private suspend fun pauseVoiceBroadcast(room: Room, reference: RelationDefaultContent?) {
Timber.d("## PauseVoiceBroadcastUseCase: Send new voice broadcast info state event") Timber.d("## PauseVoiceBroadcastUseCase: Send new voice broadcast info state event")
room.stateService().sendStateEvent( room.stateService().sendStateEvent(
eventType = STATE_ROOM_VOICE_BROADCAST_INFO, eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = session.myUserId, stateKey = session.myUserId,
body = MessageVoiceBroadcastInfoContent( body = MessageVoiceBroadcastInfoContent(
relatesTo = reference, relatesTo = reference,
@ -60,6 +62,10 @@ class PauseVoiceBroadcastUseCase @Inject constructor(
).toContent(), ).toContent(),
) )
// TODO pause recording audio files pauseRecording()
}
private fun pauseRecording() {
voiceBroadcastRecorder?.pauseRecord()
} }
} }

View file

@ -16,7 +16,8 @@
package im.vector.app.features.voicebroadcast.usecase package im.vector.app.features.voicebroadcast.usecase
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
@ -31,6 +32,7 @@ import javax.inject.Inject
class ResumeVoiceBroadcastUseCase @Inject constructor( class ResumeVoiceBroadcastUseCase @Inject constructor(
private val session: Session, private val session: Session,
private val voiceBroadcastRecorder: VoiceBroadcastRecorder?,
) { ) {
suspend fun execute(roomId: String): Result<Unit> = runCatching { suspend fun execute(roomId: String): Result<Unit> = runCatching {
@ -39,7 +41,7 @@ class ResumeVoiceBroadcastUseCase @Inject constructor(
Timber.d("## ResumeVoiceBroadcastUseCase: Resume voice broadcast requested") Timber.d("## ResumeVoiceBroadcastUseCase: Resume voice broadcast requested")
val lastVoiceBroadcastEvent = room.stateService().getStateEvent( val lastVoiceBroadcastEvent = room.stateService().getStateEvent(
STATE_ROOM_VOICE_BROADCAST_INFO, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
QueryStringValue.Equals(session.myUserId) QueryStringValue.Equals(session.myUserId)
)?.asVoiceBroadcastEvent() )?.asVoiceBroadcastEvent()
when (val voiceBroadcastState = lastVoiceBroadcastEvent?.content?.voiceBroadcastState) { when (val voiceBroadcastState = lastVoiceBroadcastEvent?.content?.voiceBroadcastState) {
@ -57,7 +59,7 @@ class ResumeVoiceBroadcastUseCase @Inject constructor(
private suspend fun resumeVoiceBroadcast(room: Room, reference: RelationDefaultContent?) { private suspend fun resumeVoiceBroadcast(room: Room, reference: RelationDefaultContent?) {
Timber.d("## ResumeVoiceBroadcastUseCase: Send new voice broadcast info state event") Timber.d("## ResumeVoiceBroadcastUseCase: Send new voice broadcast info state event")
room.stateService().sendStateEvent( room.stateService().sendStateEvent(
eventType = STATE_ROOM_VOICE_BROADCAST_INFO, eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = session.myUserId, stateKey = session.myUserId,
body = MessageVoiceBroadcastInfoContent( body = MessageVoiceBroadcastInfoContent(
relatesTo = reference, relatesTo = reference,
@ -65,6 +67,10 @@ class ResumeVoiceBroadcastUseCase @Inject constructor(
).toContent(), ).toContent(),
) )
// TODO resume recording audio files resumeRecording()
}
private fun resumeRecording() {
voiceBroadcastRecorder?.resumeRecord()
} }
} }

View file

@ -16,20 +16,32 @@
package im.vector.app.features.voicebroadcast.usecase package im.vector.app.features.voicebroadcast.usecase
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO 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.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import im.vector.lib.multipicker.utils.toMultiPickerAudioType
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
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.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.model.relation.RelationDefaultContent
import timber.log.Timber import timber.log.Timber
import java.io.File
import javax.inject.Inject import javax.inject.Inject
class StartVoiceBroadcastUseCase @Inject constructor( class StartVoiceBroadcastUseCase @Inject constructor(
private val session: Session, private val session: Session,
private val voiceBroadcastRecorder: VoiceBroadcastRecorder?,
private val context: Context,
private val buildMeta: BuildMeta,
) { ) {
suspend fun execute(roomId: String): Result<Unit> = runCatching { suspend fun execute(roomId: String): Result<Unit> = runCatching {
@ -38,7 +50,7 @@ class StartVoiceBroadcastUseCase @Inject constructor(
Timber.d("## StartVoiceBroadcastUseCase: Start voice broadcast requested") Timber.d("## StartVoiceBroadcastUseCase: Start voice broadcast requested")
val onGoingVoiceBroadcastEvents = room.stateService().getStateEvents( val onGoingVoiceBroadcastEvents = room.stateService().getStateEvents(
setOf(STATE_ROOM_VOICE_BROADCAST_INFO), setOf(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO),
QueryStringValue.IsNotEmpty QueryStringValue.IsNotEmpty
) )
.mapNotNull { it.asVoiceBroadcastEvent() } .mapNotNull { it.asVoiceBroadcastEvent() }
@ -53,15 +65,39 @@ 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")
room.stateService().sendStateEvent( val chunkLength = VoiceBroadcastConstants.DEFAULT_CHUNK_LENGTH_IN_SECONDS // Todo Get the length from the room settings
eventType = STATE_ROOM_VOICE_BROADCAST_INFO, val eventId = room.stateService().sendStateEvent(
eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = session.myUserId, stateKey = session.myUserId,
body = MessageVoiceBroadcastInfoContent( body = MessageVoiceBroadcastInfoContent(
voiceBroadcastStateStr = VoiceBroadcastState.STARTED.value, voiceBroadcastStateStr = VoiceBroadcastState.STARTED.value,
chunkLength = 5L, // TODO Get length from voice broadcast settings chunkLength = chunkLength,
).toContent() ).toContent()
) )
// TODO start recording audio files startRecording(room, eventId, chunkLength)
}
private fun startRecording(room: Room, eventId: String, chunkLength: Int) {
voiceBroadcastRecorder?.listener = VoiceBroadcastRecorder.Listener { file ->
sendVoiceFile(room, file, eventId)
}
voiceBroadcastRecorder?.startRecord(room.roomId, chunkLength)
}
private fun sendVoiceFile(room: Room, voiceMessageFile: File, referenceEventId: String) {
val outputFileUri = FileProvider.getUriForFile(
context,
buildMeta.applicationId + ".fileProvider",
voiceMessageFile,
"Voice message.${voiceMessageFile.extension}"
)
val audioType = outputFileUri.toMultiPickerAudioType(context) ?: return
room.sendService().sendMedia(
attachment = audioType.toContentAttachmentData(isVoiceMessage = true),
compressBeforeSending = false,
roomIds = emptySet(),
relatesTo = RelationDefaultContent(RelationType.REFERENCE, referenceEventId)
)
} }
} }

View file

@ -16,7 +16,8 @@
package im.vector.app.features.voicebroadcast.usecase package im.vector.app.features.voicebroadcast.usecase
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
@ -31,6 +32,7 @@ import javax.inject.Inject
class StopVoiceBroadcastUseCase @Inject constructor( class StopVoiceBroadcastUseCase @Inject constructor(
private val session: Session, private val session: Session,
private val voiceBroadcastRecorder: VoiceBroadcastRecorder?,
) { ) {
suspend fun execute(roomId: String): Result<Unit> = runCatching { suspend fun execute(roomId: String): Result<Unit> = runCatching {
@ -39,7 +41,7 @@ class StopVoiceBroadcastUseCase @Inject constructor(
Timber.d("## StopVoiceBroadcastUseCase: Stop voice broadcast requested") Timber.d("## StopVoiceBroadcastUseCase: Stop voice broadcast requested")
val lastVoiceBroadcastEvent = room.stateService().getStateEvent( val lastVoiceBroadcastEvent = room.stateService().getStateEvent(
STATE_ROOM_VOICE_BROADCAST_INFO, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
QueryStringValue.Equals(session.myUserId) QueryStringValue.Equals(session.myUserId)
)?.asVoiceBroadcastEvent() )?.asVoiceBroadcastEvent()
when (val voiceBroadcastState = lastVoiceBroadcastEvent?.content?.voiceBroadcastState) { when (val voiceBroadcastState = lastVoiceBroadcastEvent?.content?.voiceBroadcastState) {
@ -53,7 +55,7 @@ class StopVoiceBroadcastUseCase @Inject constructor(
private suspend fun stopVoiceBroadcast(room: Room, reference: RelationDefaultContent?) { private suspend fun stopVoiceBroadcast(room: Room, reference: RelationDefaultContent?) {
Timber.d("## StopVoiceBroadcastUseCase: Send new voice broadcast info state event") Timber.d("## StopVoiceBroadcastUseCase: Send new voice broadcast info state event")
room.stateService().sendStateEvent( room.stateService().sendStateEvent(
eventType = STATE_ROOM_VOICE_BROADCAST_INFO, eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = session.myUserId, stateKey = session.myUserId,
body = MessageVoiceBroadcastInfoContent( body = MessageVoiceBroadcastInfoContent(
relatesTo = reference, relatesTo = reference,
@ -61,6 +63,10 @@ class StopVoiceBroadcastUseCase @Inject constructor(
).toContent(), ).toContent(),
) )
// TODO stop recording audio files stopRecording()
}
private fun stopRecording() {
voiceBroadcastRecorder?.stopRecord()
} }
} }

View file

@ -16,7 +16,7 @@
package im.vector.app.features.home.room.detail.timeline.action package im.vector.app.features.home.room.detail.timeline.action
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.test.fakes.FakeActiveSessionHolder import im.vector.app.test.fakes.FakeActiveSessionHolder
import io.mockk.mockk import io.mockk.mockk
import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBe
@ -35,7 +35,7 @@ class CheckIfCanRedactEventUseCaseTest {
@Test @Test
fun `given an event which can be redacted and owned by user when use case executes then the result is true`() { fun `given an event which can be redacted and owned by user when use case executes then the result is true`() {
val canRedactEventTypes = listOf(EventType.MESSAGE, EventType.STICKER, STATE_ROOM_VOICE_BROADCAST_INFO) + val canRedactEventTypes = listOf(EventType.MESSAGE, EventType.STICKER, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO) +
EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
canRedactEventTypes.forEach { eventType -> canRedactEventTypes.forEach { eventType ->

View file

@ -16,7 +16,7 @@
package im.vector.app.features.voicebroadcast.model package im.vector.app.features.voicebroadcast.model
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeNull import org.amshove.kluent.shouldBeNull
import org.amshove.kluent.shouldNotBeNull import org.amshove.kluent.shouldNotBeNull
@ -34,7 +34,7 @@ import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent
private const val AN_EVENT_ID = "event_id" private const val AN_EVENT_ID = "event_id"
private const val A_REFERENCED_EVENT_ID = "event_id_ref" private const val A_REFERENCED_EVENT_ID = "event_id_ref"
private const val A_CHUNK_LENGTH = 3_600L private const val A_CHUNK_LENGTH = 30
class VoiceBroadcastEventTest { class VoiceBroadcastEventTest {
@ -48,7 +48,7 @@ class VoiceBroadcastEventTest {
) )
val event = Event( val event = Event(
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
type = STATE_ROOM_VOICE_BROADCAST_INFO, type = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
content = content.toContent(), content = content.toContent(),
) )
val expectedReference = RelationDefaultContent(RelationType.REFERENCE, event.eventId) val expectedReference = RelationDefaultContent(RelationType.REFERENCE, event.eventId)
@ -71,7 +71,7 @@ class VoiceBroadcastEventTest {
relatesTo = RelationDefaultContent(RelationType.REFERENCE, A_REFERENCED_EVENT_ID), relatesTo = RelationDefaultContent(RelationType.REFERENCE, A_REFERENCED_EVENT_ID),
) )
val event = Event( val event = Event(
type = STATE_ROOM_VOICE_BROADCAST_INFO, type = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
content = content.toContent(), content = content.toContent(),
) )
val expectedReference = content.relatesTo val expectedReference = content.relatesTo

View file

@ -16,7 +16,8 @@
package im.vector.app.features.voicebroadcast.usecase package im.vector.app.features.voicebroadcast.usecase
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoom
@ -25,6 +26,7 @@ import im.vector.app.test.fakes.FakeSession
import io.mockk.clearAllMocks import io.mockk.clearAllMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot import io.mockk.slot
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBe
@ -44,7 +46,8 @@ class PauseVoiceBroadcastUseCaseTest {
private val fakeRoom = FakeRoom() private val fakeRoom = FakeRoom()
private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom))
private val pauseVoiceBroadcastUseCase = PauseVoiceBroadcastUseCase(fakeSession) private val fakeVoiceBroadcastRecorder = mockk<VoiceBroadcastRecorder>(relaxed = true)
private val pauseVoiceBroadcastUseCase = PauseVoiceBroadcastUseCase(fakeSession, fakeVoiceBroadcastRecorder)
@Test @Test
fun `given a room id with a potential existing voice broadcast state when calling execute then the voice broadcast is paused or not`() = runTest { fun `given a room id with a potential existing voice broadcast state when calling execute then the voice broadcast is paused or not`() = runTest {
@ -80,7 +83,7 @@ class PauseVoiceBroadcastUseCaseTest {
// Then // Then
coVerify { coVerify {
fakeRoom.stateService().sendStateEvent( fakeRoom.stateService().sendStateEvent(
eventType = STATE_ROOM_VOICE_BROADCAST_INFO, eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = fakeSession.myUserId, stateKey = fakeSession.myUserId,
body = any(), body = any(),
) )
@ -114,7 +117,7 @@ class PauseVoiceBroadcastUseCaseTest {
val event = state?.let { val event = state?.let {
Event( Event(
eventId = if (state == VoiceBroadcastState.STARTED) A_STARTED_VOICE_BROADCAST_EVENT_ID else AN_EVENT_ID, eventId = if (state == VoiceBroadcastState.STARTED) A_STARTED_VOICE_BROADCAST_EVENT_ID else AN_EVENT_ID,
type = STATE_ROOM_VOICE_BROADCAST_INFO, type = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = fakeSession.myUserId, stateKey = fakeSession.myUserId,
content = MessageVoiceBroadcastInfoContent( content = MessageVoiceBroadcastInfoContent(
voiceBroadcastStateStr = state.value, voiceBroadcastStateStr = state.value,

View file

@ -16,7 +16,8 @@
package im.vector.app.features.voicebroadcast.usecase package im.vector.app.features.voicebroadcast.usecase
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoom
@ -25,6 +26,7 @@ import im.vector.app.test.fakes.FakeSession
import io.mockk.clearAllMocks import io.mockk.clearAllMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot import io.mockk.slot
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBe
@ -44,7 +46,8 @@ class ResumeVoiceBroadcastUseCaseTest {
private val fakeRoom = FakeRoom() private val fakeRoom = FakeRoom()
private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom))
private val resumeVoiceBroadcastUseCase = ResumeVoiceBroadcastUseCase(fakeSession) private val fakeVoiceBroadcastRecorder = mockk<VoiceBroadcastRecorder>(relaxed = true)
private val resumeVoiceBroadcastUseCase = ResumeVoiceBroadcastUseCase(fakeSession, fakeVoiceBroadcastRecorder)
@Test @Test
fun `given a room id with a potential existing voice broadcast state when calling execute then the voice broadcast is resumed or not`() = runTest { fun `given a room id with a potential existing voice broadcast state when calling execute then the voice broadcast is resumed or not`() = runTest {
@ -80,7 +83,7 @@ class ResumeVoiceBroadcastUseCaseTest {
// Then // Then
coVerify { coVerify {
fakeRoom.stateService().sendStateEvent( fakeRoom.stateService().sendStateEvent(
eventType = STATE_ROOM_VOICE_BROADCAST_INFO, eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = fakeSession.myUserId, stateKey = fakeSession.myUserId,
body = any(), body = any(),
) )
@ -114,7 +117,7 @@ class ResumeVoiceBroadcastUseCaseTest {
val event = state?.let { val event = state?.let {
Event( Event(
eventId = if (state == VoiceBroadcastState.STARTED) A_STARTED_VOICE_BROADCAST_EVENT_ID else AN_EVENT_ID, eventId = if (state == VoiceBroadcastState.STARTED) A_STARTED_VOICE_BROADCAST_EVENT_ID else AN_EVENT_ID,
type = STATE_ROOM_VOICE_BROADCAST_INFO, type = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = fakeSession.myUserId, stateKey = fakeSession.myUserId,
content = MessageVoiceBroadcastInfoContent( content = MessageVoiceBroadcastInfoContent(
voiceBroadcastStateStr = state.value, voiceBroadcastStateStr = state.value,

View file

@ -16,15 +16,18 @@
package im.vector.app.features.voicebroadcast.usecase package im.vector.app.features.voicebroadcast.usecase
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
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.clearAllMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot import io.mockk.slot
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBe
@ -44,7 +47,13 @@ class StartVoiceBroadcastUseCaseTest {
private val fakeRoom = FakeRoom() private val fakeRoom = FakeRoom()
private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom))
private val startVoiceBroadcastUseCase = StartVoiceBroadcastUseCase(fakeSession) private val fakeVoiceBroadcastRecorder = mockk<VoiceBroadcastRecorder>(relaxed = true)
private val startVoiceBroadcastUseCase = StartVoiceBroadcastUseCase(
fakeSession,
fakeVoiceBroadcastRecorder,
FakeContext().instance,
mockk()
)
@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 {
@ -81,7 +90,7 @@ class StartVoiceBroadcastUseCaseTest {
// Then // Then
coVerify { coVerify {
fakeRoom.stateService().sendStateEvent( fakeRoom.stateService().sendStateEvent(
eventType = STATE_ROOM_VOICE_BROADCAST_INFO, eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = fakeSession.myUserId, stateKey = fakeSession.myUserId,
body = any(), body = any(),
) )
@ -106,7 +115,7 @@ class StartVoiceBroadcastUseCaseTest {
private fun givenAVoiceBroadcasts(voiceBroadcasts: List<VoiceBroadcast>) { private fun givenAVoiceBroadcasts(voiceBroadcasts: List<VoiceBroadcast>) {
val events = voiceBroadcasts.map { val events = voiceBroadcasts.map {
Event( Event(
type = STATE_ROOM_VOICE_BROADCAST_INFO, type = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = it.userId, stateKey = it.userId,
content = MessageVoiceBroadcastInfoContent( content = MessageVoiceBroadcastInfoContent(
voiceBroadcastStateStr = it.state.value voiceBroadcastStateStr = it.state.value

View file

@ -16,7 +16,8 @@
package im.vector.app.features.voicebroadcast.usecase package im.vector.app.features.voicebroadcast.usecase
import im.vector.app.features.voicebroadcast.STATE_ROOM_VOICE_BROADCAST_INFO import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoom
@ -25,6 +26,7 @@ import im.vector.app.test.fakes.FakeSession
import io.mockk.clearAllMocks import io.mockk.clearAllMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot import io.mockk.slot
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBe
@ -44,7 +46,8 @@ class StopVoiceBroadcastUseCaseTest {
private val fakeRoom = FakeRoom() private val fakeRoom = FakeRoom()
private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom))
private val stopVoiceBroadcastUseCase = StopVoiceBroadcastUseCase(fakeSession) private val fakeVoiceBroadcastRecorder = mockk<VoiceBroadcastRecorder>(relaxed = true)
private val stopVoiceBroadcastUseCase = StopVoiceBroadcastUseCase(fakeSession, fakeVoiceBroadcastRecorder)
@Test @Test
fun `given a room id with a potential existing voice broadcast state when calling execute then the voice broadcast is stopped or not`() = runTest { fun `given a room id with a potential existing voice broadcast state when calling execute then the voice broadcast is stopped or not`() = runTest {
@ -80,7 +83,7 @@ class StopVoiceBroadcastUseCaseTest {
// Then // Then
coVerify { coVerify {
fakeRoom.stateService().sendStateEvent( fakeRoom.stateService().sendStateEvent(
eventType = STATE_ROOM_VOICE_BROADCAST_INFO, eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = fakeSession.myUserId, stateKey = fakeSession.myUserId,
body = any(), body = any(),
) )
@ -114,7 +117,7 @@ class StopVoiceBroadcastUseCaseTest {
val event = state?.let { val event = state?.let {
Event( Event(
eventId = if (state == VoiceBroadcastState.STARTED) A_STARTED_VOICE_BROADCAST_EVENT_ID else AN_EVENT_ID, eventId = if (state == VoiceBroadcastState.STARTED) A_STARTED_VOICE_BROADCAST_EVENT_ID else AN_EVENT_ID,
type = STATE_ROOM_VOICE_BROADCAST_INFO, type = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
stateKey = fakeSession.myUserId, stateKey = fakeSession.myUserId,
content = MessageVoiceBroadcastInfoContent( content = MessageVoiceBroadcastInfoContent(
voiceBroadcastStateStr = state.value, voiceBroadcastStateStr = state.value,