Merge pull request #4556 from vector-im/feature/adm/voice-rotation

Supporting rotation during voice recordings
This commit is contained in:
Benoit Marty 2021-11-29 11:40:55 +01:00 committed by GitHub
commit b59ae53805
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 105 additions and 55 deletions

1
changelog.d/4067.bugfix Normal file
View file

@ -0,0 +1 @@
Allow voice messages to continue recording during device rotation

View file

@ -42,6 +42,7 @@ import im.vector.app.features.home.UnknownDeviceDetectorSharedViewModel
import im.vector.app.features.home.UnreadMessagesSharedViewModel import im.vector.app.features.home.UnreadMessagesSharedViewModel
import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsViewModel import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsViewModel
import im.vector.app.features.home.room.detail.RoomDetailViewModel import im.vector.app.features.home.room.detail.RoomDetailViewModel
import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel
import im.vector.app.features.home.room.detail.search.SearchViewModel import im.vector.app.features.home.room.detail.search.SearchViewModel
import im.vector.app.features.home.room.detail.timeline.action.MessageActionsViewModel import im.vector.app.features.home.room.detail.timeline.action.MessageActionsViewModel
import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryViewModel import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryViewModel
@ -508,6 +509,11 @@ interface MavericksViewModelModule {
@MavericksViewModelKey(RoomDetailViewModel::class) @MavericksViewModelKey(RoomDetailViewModel::class)
fun roomDetailViewModelFactory(factory: RoomDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *> fun roomDetailViewModelFactory(factory: RoomDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(MessageComposerViewModel::class)
fun messageComposerViewModelFactory(factory: MessageComposerViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds @Binds
@IntoMap @IntoMap
@MavericksViewModelKey(SetIdentityServerViewModel::class) @MavericksViewModelKey(SetIdentityServerViewModel::class)

View file

@ -29,6 +29,8 @@ import dagger.hilt.components.SingletonComponent
import im.vector.app.core.dispatchers.CoroutineDispatchers import im.vector.app.core.dispatchers.CoroutineDispatchers
import im.vector.app.core.error.DefaultErrorFormatter import im.vector.app.core.error.DefaultErrorFormatter
import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.time.Clock
import im.vector.app.core.time.DefaultClock
import im.vector.app.features.invite.AutoAcceptInvites import im.vector.app.features.invite.AutoAcceptInvites
import im.vector.app.features.invite.CompileTimeAutoAcceptInvites import im.vector.app.features.invite.CompileTimeAutoAcceptInvites
import im.vector.app.features.navigation.DefaultNavigator import im.vector.app.features.navigation.DefaultNavigator
@ -66,6 +68,9 @@ abstract class VectorBindModule {
@Binds @Binds
abstract fun bindAutoAcceptInvites(autoAcceptInvites: CompileTimeAutoAcceptInvites): AutoAcceptInvites abstract fun bindAutoAcceptInvites(autoAcceptInvites: CompileTimeAutoAcceptInvites): AutoAcceptInvites
@Binds
abstract fun bindDefaultClock(clock: DefaultClock): Clock
} }
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)

View file

@ -0,0 +1,36 @@
/*
* 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.core.time
import javax.inject.Inject
interface Clock {
fun epochMillis(): Long
}
class DefaultClock @Inject constructor() : Clock {
/**
* Provides a UTC epoch in milliseconds
*
* This value is not guaranteed to be correct with reality
* as a User can override the system time and date to any values.
*/
override fun epochMillis(): Long {
return System.currentTimeMillis()
}
}

View file

@ -87,6 +87,7 @@ import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.platform.lifecycleAwareLazy import im.vector.app.core.platform.lifecycleAwareLazy
import im.vector.app.core.platform.showOptimizedSnackbar import im.vector.app.core.platform.showOptimizedSnackbar
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.time.Clock
import im.vector.app.core.ui.views.CurrentCallsView import im.vector.app.core.ui.views.CurrentCallsView
import im.vector.app.core.ui.views.CurrentCallsViewPresenter import im.vector.app.core.ui.views.CurrentCallsViewPresenter
import im.vector.app.core.ui.views.FailedMessagesWarningView import im.vector.app.core.ui.views.FailedMessagesWarningView
@ -240,7 +241,6 @@ class RoomDetailFragment @Inject constructor(
autoCompleterFactory: AutoCompleter.Factory, autoCompleterFactory: AutoCompleter.Factory,
private val permalinkHandler: PermalinkHandler, private val permalinkHandler: PermalinkHandler,
private val notificationDrawerManager: NotificationDrawerManager, private val notificationDrawerManager: NotificationDrawerManager,
val messageComposerViewModelFactory: MessageComposerViewModel.Factory,
private val eventHtmlRenderer: EventHtmlRenderer, private val eventHtmlRenderer: EventHtmlRenderer,
private val vectorPreferences: VectorPreferences, private val vectorPreferences: VectorPreferences,
private val colorProvider: ColorProvider, private val colorProvider: ColorProvider,
@ -251,7 +251,8 @@ class RoomDetailFragment @Inject constructor(
private val roomDetailPendingActionStore: RoomDetailPendingActionStore, private val roomDetailPendingActionStore: RoomDetailPendingActionStore,
private val pillsPostProcessorFactory: PillsPostProcessor.Factory, private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val callManager: WebRtcCallManager, private val callManager: WebRtcCallManager,
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker,
private val clock: Clock
) : ) :
VectorBaseFragment<FragmentRoomDetailBinding>(), VectorBaseFragment<FragmentRoomDetailBinding>(),
TimelineEventController.Callback, TimelineEventController.Callback,
@ -393,8 +394,8 @@ class RoomDetailFragment @Inject constructor(
when (mode) { when (mode) {
is SendMode.Regular -> renderRegularMode(mode.text) is SendMode.Regular -> renderRegularMode(mode.text)
is SendMode.Edit -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text) is SendMode.Edit -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text)
is SendMode.Quote -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.quote, mode.text) is SendMode.Quote -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.quote, mode.text)
is SendMode.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text) is SendMode.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text)
} }
} }
@ -700,7 +701,7 @@ class RoomDetailFragment @Inject constructor(
if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) { if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
messageComposerViewModel.handle(MessageComposerAction.StartRecordingVoiceMessage) messageComposerViewModel.handle(MessageComposerAction.StartRecordingVoiceMessage)
vibrate(requireContext()) vibrate(requireContext())
updateRecordingUiState(RecordingUiState.Started) updateRecordingUiState(RecordingUiState.Started(clock.epochMillis()))
} }
} }
@ -714,7 +715,9 @@ class RoomDetailFragment @Inject constructor(
} }
override fun onVoiceRecordingLocked() { override fun onVoiceRecordingLocked() {
updateRecordingUiState(RecordingUiState.Locked) val startedState = withState(messageComposerViewModel) { it.voiceRecordingUiState as? RecordingUiState.Started }
val startTime = startedState?.recordingStartTimestamp ?: clock.epochMillis()
updateRecordingUiState(RecordingUiState.Locked(startTime))
} }
override fun onVoiceRecordingEnded() { override fun onVoiceRecordingEnded() {
@ -1130,14 +1133,17 @@ class RoomDetailFragment @Inject constructor(
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
notificationDrawerManager.setCurrentRoom(null) notificationDrawerManager.setCurrentRoom(null)
voiceMessagePlaybackTracker.unTrack(VoiceMessagePlaybackTracker.RECORDING_ID)
messageComposerViewModel.handle(MessageComposerAction.SaveDraft(views.composerLayout.text.toString())) if (withState(messageComposerViewModel) { it.isVoiceRecording } && requireActivity().isChangingConfigurations) {
// we're rotating, maintain any active recordings
// We should improve the UX to support going into playback mode when paused and delete the media when the view is destroyed. } else {
messageComposerViewModel.handle(MessageComposerAction.EndAllVoiceActions(deleteRecord = false)) messageComposerViewModel.handle(MessageComposerAction.SaveDraft(views.composerLayout.text.toString()))
views.voiceMessageRecorderView.render(RecordingUiState.None) // We should improve the UX to support going into playback mode when paused and delete the media when the view is destroyed.
messageComposerViewModel.handle(MessageComposerAction.EndAllVoiceActions(deleteRecord = false))
views.voiceMessageRecorderView.render(RecordingUiState.None)
}
} }
private val attachmentFileActivityResultLauncher = registerStartForActivityResult { private val attachmentFileActivityResultLauncher = registerStartForActivityResult {

View file

@ -16,13 +16,13 @@
package im.vector.app.features.home.room.detail.composer package im.vector.app.features.home.room.detail.composer
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
@ -30,7 +30,6 @@ import im.vector.app.features.attachments.toContentAttachmentData
import im.vector.app.features.command.CommandParser import im.vector.app.features.command.CommandParser
import im.vector.app.features.command.ParsedCommand import im.vector.app.features.command.ParsedCommand
import im.vector.app.features.home.room.detail.ChatEffect import im.vector.app.features.home.room.detail.ChatEffect
import im.vector.app.features.home.room.detail.RoomDetailFragment
import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator
import im.vector.app.features.home.room.detail.toMessageType import im.vector.app.features.home.room.detail.toMessageType
import im.vector.app.features.powerlevel.PowerLevelsFlowFactory import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
@ -764,23 +763,9 @@ class MessageComposerViewModel @AssistedInject constructor(
} }
@AssistedFactory @AssistedFactory
interface Factory { interface Factory : MavericksAssistedViewModelFactory<MessageComposerViewModel, MessageComposerViewState> {
fun create(initialState: MessageComposerViewState): MessageComposerViewModel override fun create(initialState: MessageComposerViewState): MessageComposerViewModel
} }
/** companion object : MavericksViewModelFactory<MessageComposerViewModel, MessageComposerViewState> by hiltMavericksViewModelFactory()
* We're unable to create this ViewModel with `by hiltMavericksViewModelFactory()` due to the
* VoiceMessagePlaybackTracker being ActivityScoped
*
* This factory allows us to provide the ViewModel instance from the Fragment directly
* bypassing the Singleton scope requirement
*/
companion object : MavericksViewModelFactory<MessageComposerViewModel, MessageComposerViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: MessageComposerViewState): MessageComposerViewModel {
val fragment: RoomDetailFragment = (viewModelContext as FragmentViewModelContext).fragment()
return fragment.messageComposerViewModelFactory.create(state)
}
}
} }

View file

@ -54,8 +54,8 @@ data class MessageComposerViewState(
VoiceMessageRecorderView.RecordingUiState.None, VoiceMessageRecorderView.RecordingUiState.None,
VoiceMessageRecorderView.RecordingUiState.Cancelled, VoiceMessageRecorderView.RecordingUiState.Cancelled,
VoiceMessageRecorderView.RecordingUiState.Playback -> false VoiceMessageRecorderView.RecordingUiState.Playback -> false
VoiceMessageRecorderView.RecordingUiState.Locked, is VoiceMessageRecorderView.RecordingUiState.Locked,
VoiceMessageRecorderView.RecordingUiState.Started -> true is VoiceMessageRecorderView.RecordingUiState.Started -> true
} }
val isVoiceMessageIdle = !isVoiceRecording val isVoiceMessageIdle = !isVoiceRecording

View file

@ -20,19 +20,23 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.hardware.vibrate import im.vector.app.core.hardware.vibrate
import im.vector.app.core.time.Clock
import im.vector.app.core.utils.CountUpTimer import im.vector.app.core.utils.CountUpTimer
import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.DimensionConverter
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import javax.inject.Inject
import kotlin.math.floor import kotlin.math.floor
/** /**
* Encapsulates the voice message recording view and animations. * Encapsulates the voice message recording view and animations.
*/ */
@AndroidEntryPoint
class VoiceMessageRecorderView @JvmOverloads constructor( class VoiceMessageRecorderView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
@ -51,6 +55,8 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
fun onRecordingWaveformClicked() fun onRecordingWaveformClicked()
} }
@Inject lateinit var clock: Clock
// We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22. // We need to define views as lateinit var to be able to check if initialized for the bug fix on api 21 and 22.
@Suppress("UNNECESSARY_LATEINIT") @Suppress("UNNECESSARY_LATEINIT")
private lateinit var voiceMessageViews: VoiceMessageViews private lateinit var voiceMessageViews: VoiceMessageViews
@ -105,32 +111,35 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
fun render(recordingState: RecordingUiState) { fun render(recordingState: RecordingUiState) {
if (lastKnownState == recordingState) return if (lastKnownState == recordingState) return
lastKnownState = recordingState
when (recordingState) { when (recordingState) {
RecordingUiState.None -> { RecordingUiState.None -> {
reset() reset()
} }
RecordingUiState.Started -> { is RecordingUiState.Started -> {
startRecordingTicker() startRecordingTicker(startFromLocked = false, startAt = recordingState.recordingStartTimestamp)
voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast)) voiceMessageViews.renderToast(context.getString(R.string.voice_message_release_to_send_toast))
voiceMessageViews.showRecordingViews() voiceMessageViews.showRecordingViews()
dragState = DraggingState.Ready dragState = DraggingState.Ready
} }
RecordingUiState.Cancelled -> { RecordingUiState.Cancelled -> {
reset() reset()
vibrate(context) vibrate(context)
} }
RecordingUiState.Locked -> { is RecordingUiState.Locked -> {
if (lastKnownState == null) {
startRecordingTicker(startFromLocked = true, startAt = recordingState.recordingStartTimestamp)
}
voiceMessageViews.renderLocked() voiceMessageViews.renderLocked()
postDelayed({ postDelayed({
voiceMessageViews.showRecordingLockedViews(recordingState) voiceMessageViews.showRecordingLockedViews(recordingState)
}, 500) }, 500)
} }
RecordingUiState.Playback -> { RecordingUiState.Playback -> {
stopRecordingTicker() stopRecordingTicker()
voiceMessageViews.showPlaybackViews() voiceMessageViews.showPlaybackViews()
} }
} }
lastKnownState = recordingState
} }
private fun reset() { private fun reset() {
@ -140,6 +149,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
} }
private fun onDrag(currentDragState: DraggingState, newDragState: DraggingState) { private fun onDrag(currentDragState: DraggingState, newDragState: DraggingState) {
if (currentDragState == newDragState) return
when (newDragState) { when (newDragState) {
is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(newDragState.distanceX) is DraggingState.Cancelling -> voiceMessageViews.renderCancelling(newDragState.distanceX)
is DraggingState.Locking -> { is DraggingState.Locking -> {
@ -158,22 +168,23 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
dragState = newDragState dragState = newDragState
} }
private fun startRecordingTicker() { private fun startRecordingTicker(startFromLocked: Boolean, startAt: Long) {
val startMs = ((clock.epochMillis() - startAt)).coerceAtLeast(0)
recordingTicker?.stop() recordingTicker?.stop()
recordingTicker = CountUpTimer().apply { recordingTicker = CountUpTimer().apply {
tickListener = object : CountUpTimer.TickListener { tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) { override fun onTick(milliseconds: Long) {
onRecordingTick(milliseconds) val isLocked = startFromLocked || lastKnownState is RecordingUiState.Locked
onRecordingTick(isLocked, milliseconds + startMs)
} }
} }
resume() resume()
} }
onRecordingTick(0L) onRecordingTick(startFromLocked, milliseconds = startMs)
} }
private fun onRecordingTick(milliseconds: Long) { private fun onRecordingTick(isLocked: Boolean, milliseconds: Long) {
val currentState = lastKnownState ?: return voiceMessageViews.renderRecordingTimer(isLocked, milliseconds / 1_000)
voiceMessageViews.renderRecordingTimer(currentState, milliseconds / 1_000)
val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds val timeDiffToRecordingLimit = BuildConfig.VOICE_MESSAGE_DURATION_LIMIT_MS - milliseconds
if (timeDiffToRecordingLimit <= 0) { if (timeDiffToRecordingLimit <= 0) {
post { post {
@ -210,9 +221,9 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
sealed interface RecordingUiState { sealed interface RecordingUiState {
object None : RecordingUiState object None : RecordingUiState
object Started : RecordingUiState data class Started(val recordingStartTimestamp: Long) : RecordingUiState
object Cancelled : RecordingUiState object Cancelled : RecordingUiState
object Locked : RecordingUiState data class Locked(val recordingStartTimestamp: Long) : RecordingUiState
object Playback : RecordingUiState object Playback : RecordingUiState
} }

View file

@ -154,7 +154,7 @@ class VoiceMessageViews(
fun hideRecordingViews(recordingState: RecordingUiState) { fun hideRecordingViews(recordingState: RecordingUiState) {
// We need to animate the lock image first // We need to animate the lock image first
if (recordingState != RecordingUiState.Locked) { if (recordingState !is RecordingUiState.Locked) {
views.voiceMessageLockImage.isVisible = false views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockImage.animate().translationY(0f).start() views.voiceMessageLockImage.animate().translationY(0f).start()
views.voiceMessageLockBackground.isVisible = false views.voiceMessageLockBackground.isVisible = false
@ -171,7 +171,7 @@ class VoiceMessageViews(
views.voiceMessageTimerIndicator.isVisible = false views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false views.voiceMessageTimer.isVisible = false
if (recordingState != RecordingUiState.Locked) { if (recordingState !is RecordingUiState.Locked) {
views.voiceMessageMicButton views.voiceMessageMicButton
.animate() .animate()
.scaleX(1f) .scaleX(1f)
@ -304,9 +304,9 @@ class VoiceMessageViews(
views.voiceMessageToast.isVisible = false views.voiceMessageToast.isVisible = false
} }
fun renderRecordingTimer(recordingState: RecordingUiState, recordingTimeMillis: Long) { fun renderRecordingTimer(isLocked: Boolean, recordingTimeMillis: Long) {
val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis) val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
if (recordingState == RecordingUiState.Locked) { if (isLocked) {
views.voicePlaybackTime.apply { views.voicePlaybackTime.apply {
post { post {
text = formattedTimerText text = formattedTimerText

View file

@ -18,10 +18,10 @@ package im.vector.app.features.home.room.detail.timeline.helper
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import dagger.hilt.android.scopes.ActivityScoped
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@ActivityScoped @Singleton
class VoiceMessagePlaybackTracker @Inject constructor() { class VoiceMessagePlaybackTracker @Inject constructor() {
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())