Record and send voice messages (#1596)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
da345f9afa
commit
5c582bba1b
68 changed files with 2274 additions and 82 deletions
20
features/messages/impl/src/main/AndroidManifest.xml
Normal file
20
features/messages/impl/src/main/AndroidManifest.xml
Normal file
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -203,6 +205,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
enableInRoomCalls = enableInRoomCalls,
|
||||
appName = buildMeta.applicationName,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,5 +50,6 @@ data class MessagesState(
|
|||
val enableTextFormatting: Boolean,
|
||||
val enableVoiceMessages: Boolean,
|
||||
val enableInRoomCalls: Boolean,
|
||||
val appName: String,
|
||||
val eventSink: (MessagesEvents) -> Unit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -86,5 +86,6 @@ fun aMessagesState() = MessagesState(
|
|||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
enableInRoomCalls = true,
|
||||
appName = "Element",
|
||||
eventSink = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -89,7 +93,8 @@ internal fun MessageComposerView(
|
|||
onDismissTextFormatting = ::onDismissTextFormatting,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
onVoiceRecordButtonEvent = ::onVoiceRecordButtonEvent,
|
||||
onVoiceRecordButtonEvent = onVoiceRecordButtonEvent,
|
||||
onSendVoiceMessage = ::onSendVoiceMessage,
|
||||
onError = ::onError,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
PressEvent.Tapped -> {
|
||||
// TODO discard the recording and show the 'hold to record' tooltip
|
||||
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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvents(event: VoiceMessageComposerEvents) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ 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)),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -30,5 +30,6 @@ internal fun aVoiceMessageComposerState(
|
|||
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
|
||||
) = VoiceMessageComposerState(
|
||||
voiceMessageState = voiceMessageState,
|
||||
showPermissionRationaleDialog = false,
|
||||
eventSink = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
|
||||
internal sealed class VoiceMessageException : Exception() {
|
||||
data class FileException(
|
||||
override val message: String?, override val cause: Throwable? = null
|
||||
) : VoiceMessageException()
|
||||
data class PermissionMissing(
|
||||
override val message: String?, override val cause: Throwable?
|
||||
) : VoiceMessageException()
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun VoiceMessagePermissionRationaleDialog(
|
||||
onContinue: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
appName: String,
|
||||
) {
|
||||
ConfirmationDialog(
|
||||
content = stringResource(CommonStrings.error_missing_microphone_voice_rationale_android, appName),
|
||||
onSubmitClicked = onContinue,
|
||||
onDismiss = onDismiss,
|
||||
submitText = stringResource(CommonStrings.action_continue),
|
||||
cancelText = stringResource(CommonStrings.action_cancel),
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue