Skip to content

Commit

Permalink
Record and send voice messages (#1596)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
  • Loading branch information
jonnyandrew and ElementBot authored Oct 23, 2023
1 parent 503efbf commit b476654
Show file tree
Hide file tree
Showing 68 changed files with 2,271 additions and 79 deletions.
1 change: 1 addition & 0 deletions changelog.d/1596.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Record and send voice messages
2 changes: 2 additions & 0 deletions features/messages/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ dependencies {
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.voicerecorder.api)
implementation(projects.features.networkmonitor.api)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)
Expand Down Expand Up @@ -80,6 +81,7 @@ dependencies {
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.textcomposer.test)
testImplementation(projects.libraries.voicerecorder.test)
testImplementation(libs.test.mockk)

ksp(libs.showkase.processor)
Expand Down
20 changes: 20 additions & 0 deletions features/messages/impl/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2023 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.
-->

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
Expand Down Expand Up @@ -101,6 +102,7 @@ class MessagesPresenter @AssistedInject constructor(
private val preferencesStore: PreferencesStore,
private val featureFlagsService: FeatureFlagService,
@Assisted private val navigator: MessagesNavigator,
private val buildMeta: BuildMeta,
) : Presenter<MessagesState> {

@AssistedFactory
Expand Down Expand Up @@ -203,6 +205,7 @@ class MessagesPresenter @AssistedInject constructor(
enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages,
enableInRoomCalls = enableInRoomCalls,
appName = buildMeta.applicationName,
eventSink = { handleEvents(it) }
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,6 @@ data class MessagesState(
val enableTextFormatting: Boolean,
val enableVoiceMessages: Boolean,
val enableInRoomCalls: Boolean,
val appName: String,
val eventSink: (MessagesEvents) -> Unit
)
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,6 @@ fun aMessagesState() = MessagesState(
enableTextFormatting = true,
enableVoiceMessages = true,
enableInRoomCalls = true,
appName = "Element",
eventSink = {}
)
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents
import io.element.android.features.messages.impl.voicemessages.VoiceMessagePermissionRationaleDialog
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
Expand All @@ -83,6 +85,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.UserId
Expand All @@ -107,6 +110,10 @@ fun MessagesView(
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")

OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
}

AttachmentStateView(
state = state.composerState.attachmentsState,
onPreviewAttachments = onPreviewAttachments,
Expand Down Expand Up @@ -306,6 +313,18 @@ private fun MessagesViewContent(
enableTextFormatting = state.enableTextFormatting,
)

if (state.enableVoiceMessages && state.voiceMessageComposerState.showPermissionRationaleDialog) {
VoiceMessagePermissionRationaleDialog(
onContinue = {
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale)
},
onDismiss = {
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale)
},
appName = state.appName
)
}

ExpandableBottomSheetScaffold(
sheetDragHandle = if (state.composerState.showTextFormatting) {
@Composable { BottomSheetDragHandle() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,14 @@ internal fun MessageComposerView(
}
}

fun onVoiceRecordButtonEvent(press: PressEvent) {
val onVoiceRecordButtonEvent = { press: PressEvent ->
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press))
}

fun onSendVoiceMessage() {
voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
}

TextComposer(
modifier = modifier,
state = state.richTextEditorState,
Expand All @@ -89,7 +93,8 @@ internal fun MessageComposerView(
onDismissTextFormatting = ::onDismissTextFormatting,
enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages,
onVoiceRecordButtonEvent = ::onVoiceRecordButtonEvent,
onVoiceRecordButtonEvent = onVoiceRecordButtonEvent,
onSendVoiceMessage = ::onSendVoiceMessage,
onError = ::onError,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@

package io.element.android.features.messages.impl.voicemessages

import androidx.lifecycle.Lifecycle
import io.element.android.libraries.textcomposer.model.PressEvent

sealed interface VoiceMessageComposerEvents {
data class RecordButtonEvent(
val pressEvent: PressEvent
): VoiceMessageComposerEvents
data object SendVoiceMessage: VoiceMessageComposerEvents
data object AcceptPermissionRationale: VoiceMessageComposerEvents
data object DismissPermissionsRationale: VoiceMessageComposerEvents
data class LifecycleEvent(val event: Lifecycle.Event): VoiceMessageComposerEvents
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,49 +16,171 @@

package io.element.android.features.messages.impl.voicemessages

import android.Manifest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.textcomposer.model.PressEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import javax.inject.Inject

@SingleIn(RoomScope::class)
class VoiceMessageComposerPresenter @Inject constructor() : Presenter<VoiceMessageComposerState> {
class VoiceMessageComposerPresenter @Inject constructor(
private val appCoroutineScope: CoroutineScope,
private val voiceRecorder: VoiceRecorder,
private val analyticsService: AnalyticsService,
private val mediaSender: MediaSender,
permissionsPresenterFactory: PermissionsPresenter.Factory
) : Presenter<VoiceMessageComposerState> {
private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)

@Composable
override fun present(): VoiceMessageComposerState {
var voiceMessageState by remember { mutableStateOf<VoiceMessageState>(VoiceMessageState.Idle) }
val localCoroutineScope = rememberCoroutineScope()
val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle)

fun onRecordButtonPress(event: VoiceMessageComposerEvents.RecordButtonEvent) = when(event.pressEvent) {
PressEvent.PressStart -> {
// TODO start the recording
voiceMessageState = VoiceMessageState.Recording
}
PressEvent.LongPressEnd -> {
// TODO finish the recording
voiceMessageState = VoiceMessageState.Idle
val permissionState = permissionsPresenter.present()
var isSending by remember { mutableStateOf(false) }

val onLifecycleEvent = { event: Lifecycle.Event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
appCoroutineScope.finishRecording()
}
Lifecycle.Event.ON_DESTROY -> {
appCoroutineScope.cancelRecording()
}
else -> {}
}
PressEvent.Tapped -> {
// TODO discard the recording and show the 'hold to record' tooltip
voiceMessageState = VoiceMessageState.Idle
}

val onRecordButtonPress = { event: VoiceMessageComposerEvents.RecordButtonEvent ->
val permissionGranted = permissionState.permissionGranted
when (event.pressEvent) {
PressEvent.PressStart -> {
Timber.v("Voice message record button pressed")
when {
permissionGranted -> {
localCoroutineScope.startRecording()
}
else -> {
Timber.i("Voice message permission needed")
permissionState.eventSink(PermissionsEvents.RequestPermissions)
}
}
}
PressEvent.LongPressEnd -> {
Timber.v("Voice message record button released")
localCoroutineScope.finishRecording()
}
PressEvent.Tapped -> {
Timber.v("Voice message record button tapped")
localCoroutineScope.cancelRecording()
}
}
}

val onAcceptPermissionsRationale = {
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
}

val onDismissPermissionsRationale = {
permissionState.eventSink(PermissionsEvents.CloseDialog)
}

val onSendButtonPress = lambda@{
val finishedState = recorderState as? VoiceRecorderState.Finished
if (finishedState == null) {
val exception = VoiceMessageException.FileException("No file to send")
analyticsService.trackError(exception)
Timber.e(exception)
return@lambda
}
if (isSending) {
return@lambda
}
isSending = true
appCoroutineScope.sendMessage(
file = finishedState.file,
mimeType = finishedState.mimeType,
).invokeOnCompletion {
isSending = false
}
}

fun handleEvents(event: VoiceMessageComposerEvents) {
val handleEvents: (VoiceMessageComposerEvents) -> Unit = { event ->
when (event) {
is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event)
is VoiceMessageComposerEvents.SendVoiceMessage -> localCoroutineScope.launch {
onSendButtonPress()
}
VoiceMessageComposerEvents.DismissPermissionsRationale -> onDismissPermissionsRationale()
VoiceMessageComposerEvents.AcceptPermissionRationale -> onAcceptPermissionsRationale()
is VoiceMessageComposerEvents.LifecycleEvent -> onLifecycleEvent(event.event)
}
}

return VoiceMessageComposerState(
voiceMessageState = voiceMessageState,
eventSink = { handleEvents(it) }
voiceMessageState = when (val state = recorderState) {
is VoiceRecorderState.Recording -> VoiceMessageState.Recording(level = state.level)
is VoiceRecorderState.Finished -> VoiceMessageState.Preview
else -> VoiceMessageState.Idle
},
showPermissionRationaleDialog = permissionState.showDialog,
eventSink = handleEvents,
)
}

private fun CoroutineScope.startRecording() = launch {
try {
voiceRecorder.startRecord()
} catch (e: SecurityException) {
Timber.e(e, "Voice message error")
analyticsService.trackError(VoiceMessageException.PermissionMissing("Expected permission to record but none", e))
}
}

private fun CoroutineScope.finishRecording() = launch {
voiceRecorder.stopRecord()
}

private fun CoroutineScope.cancelRecording() = launch {
voiceRecorder.stopRecord(cancelled = true)
}

private fun CoroutineScope.sendMessage(
file: File, mimeType: String,
) = launch {
val result = mediaSender.sendVoiceMessage(
uri = file.toUri(),
mimeType = mimeType,
waveForm = emptyList(), // TODO generate waveform
)

if (result.isFailure) {
Timber.e(result.exceptionOrNull(), "Voice message error")
return@launch
}

voiceRecorder.deleteRecording()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
@Stable
data class VoiceMessageComposerState(
val voiceMessageState: VoiceMessageState,
val showPermissionRationaleDialog: Boolean,
val eventSink: (VoiceMessageComposerEvents) -> Unit,
)

Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
override val values: Sequence<VoiceMessageComposerState>
get() = sequenceOf(
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording),
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(level = 0.5)),
)
}

internal fun aVoiceMessageComposerState(
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
) = VoiceMessageComposerState(
voiceMessageState = voiceMessageState,
showPermissionRationaleDialog = false,
eventSink = {},
)
Loading

0 comments on commit b476654

Please sign in to comment.