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.room.breadcrumbs.BreadcrumbsViewModel
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.timeline.action.MessageActionsViewModel
import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryViewModel
@ -508,6 +509,11 @@ interface MavericksViewModelModule {
@MavericksViewModelKey(RoomDetailViewModel::class)
fun roomDetailViewModelFactory(factory: RoomDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(MessageComposerViewModel::class)
fun messageComposerViewModelFactory(factory: MessageComposerViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@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.error.DefaultErrorFormatter
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.CompileTimeAutoAcceptInvites
import im.vector.app.features.navigation.DefaultNavigator
@ -66,6 +68,9 @@ abstract class VectorBindModule {
@Binds
abstract fun bindAutoAcceptInvites(autoAcceptInvites: CompileTimeAutoAcceptInvites): AutoAcceptInvites
@Binds
abstract fun bindDefaultClock(clock: DefaultClock): Clock
}
@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.showOptimizedSnackbar
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.CurrentCallsViewPresenter
import im.vector.app.core.ui.views.FailedMessagesWarningView
@ -240,7 +241,6 @@ class RoomDetailFragment @Inject constructor(
autoCompleterFactory: AutoCompleter.Factory,
private val permalinkHandler: PermalinkHandler,
private val notificationDrawerManager: NotificationDrawerManager,
val messageComposerViewModelFactory: MessageComposerViewModel.Factory,
private val eventHtmlRenderer: EventHtmlRenderer,
private val vectorPreferences: VectorPreferences,
private val colorProvider: ColorProvider,
@ -251,7 +251,8 @@ class RoomDetailFragment @Inject constructor(
private val roomDetailPendingActionStore: RoomDetailPendingActionStore,
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val callManager: WebRtcCallManager,
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker,
private val clock: Clock
) :
VectorBaseFragment<FragmentRoomDetailBinding>(),
TimelineEventController.Callback,
@ -700,7 +701,7 @@ class RoomDetailFragment @Inject constructor(
if (checkPermissions(PERMISSIONS_FOR_VOICE_MESSAGE, requireActivity(), permissionVoiceMessageLauncher)) {
messageComposerViewModel.handle(MessageComposerAction.StartRecordingVoiceMessage)
vibrate(requireContext())
updateRecordingUiState(RecordingUiState.Started)
updateRecordingUiState(RecordingUiState.Started(clock.epochMillis()))
}
}
@ -714,7 +715,9 @@ class RoomDetailFragment @Inject constructor(
}
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() {
@ -1130,15 +1133,18 @@ class RoomDetailFragment @Inject constructor(
override fun onPause() {
super.onPause()
notificationDrawerManager.setCurrentRoom(null)
voiceMessagePlaybackTracker.unTrack(VoiceMessagePlaybackTracker.RECORDING_ID)
if (withState(messageComposerViewModel) { it.isVoiceRecording } && requireActivity().isChangingConfigurations) {
// we're rotating, maintain any active recordings
} else {
messageComposerViewModel.handle(MessageComposerAction.SaveDraft(views.composerLayout.text.toString()))
// 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 {
if (it.resultCode == Activity.RESULT_OK) {

View file

@ -16,13 +16,13 @@
package im.vector.app.features.home.room.detail.composer
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
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.platform.VectorViewModel
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.ParsedCommand
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.toMessageType
import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
@ -764,23 +763,9 @@ class MessageComposerViewModel @AssistedInject constructor(
}
@AssistedFactory
interface Factory {
fun create(initialState: MessageComposerViewState): MessageComposerViewModel
interface Factory : MavericksAssistedViewModelFactory<MessageComposerViewModel, MessageComposerViewState> {
override fun create(initialState: MessageComposerViewState): MessageComposerViewModel
}
/**
* 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)
}
}
companion object : MavericksViewModelFactory<MessageComposerViewModel, MessageComposerViewState> by hiltMavericksViewModelFactory()
}

View file

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

View file

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

View file

@ -154,7 +154,7 @@ class VoiceMessageViews(
fun hideRecordingViews(recordingState: RecordingUiState) {
// We need to animate the lock image first
if (recordingState != RecordingUiState.Locked) {
if (recordingState !is RecordingUiState.Locked) {
views.voiceMessageLockImage.isVisible = false
views.voiceMessageLockImage.animate().translationY(0f).start()
views.voiceMessageLockBackground.isVisible = false
@ -171,7 +171,7 @@ class VoiceMessageViews(
views.voiceMessageTimerIndicator.isVisible = false
views.voiceMessageTimer.isVisible = false
if (recordingState != RecordingUiState.Locked) {
if (recordingState !is RecordingUiState.Locked) {
views.voiceMessageMicButton
.animate()
.scaleX(1f)
@ -304,9 +304,9 @@ class VoiceMessageViews(
views.voiceMessageToast.isVisible = false
}
fun renderRecordingTimer(recordingState: RecordingUiState, recordingTimeMillis: Long) {
fun renderRecordingTimer(isLocked: Boolean, recordingTimeMillis: Long) {
val formattedTimerText = DateUtils.formatElapsedTime(recordingTimeMillis)
if (recordingState == RecordingUiState.Locked) {
if (isLocked) {
views.voicePlaybackTime.apply {
post {
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.Looper
import dagger.hilt.android.scopes.ActivityScoped
import javax.inject.Inject
import javax.inject.Singleton
@ActivityScoped
@Singleton
class VoiceMessagePlaybackTracker @Inject constructor() {
private val mainHandler = Handler(Looper.getMainLooper())