diff --git a/changelog.d/4067.bugfix b/changelog.d/4067.bugfix new file mode 100644 index 0000000000..63d62df840 --- /dev/null +++ b/changelog.d/4067.bugfix @@ -0,0 +1 @@ +Allow voice messages to continue recording during device rotation \ No newline at end of file diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index 69257f1f05..cac694e84e 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -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) diff --git a/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt b/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt index 429b88be69..350e1f6b7a 100644 --- a/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt @@ -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) diff --git a/vector/src/main/java/im/vector/app/core/time/Clock.kt b/vector/src/main/java/im/vector/app/core/time/Clock.kt new file mode 100644 index 0000000000..b7b6e88f8d --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/time/Clock.kt @@ -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() + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index d8f1846d62..08a2e6cd9c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -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(), TimelineEventController.Callback, @@ -393,8 +394,8 @@ class RoomDetailFragment @Inject constructor( when (mode) { is SendMode.Regular -> renderRegularMode(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.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, 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) } } @@ -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,14 +1133,17 @@ class RoomDetailFragment @Inject constructor( override fun onPause() { super.onPause() - notificationDrawerManager.setCurrentRoom(null) + voiceMessagePlaybackTracker.unTrack(VoiceMessagePlaybackTracker.RECORDING_ID) - 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) + 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 { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt index f4bfb32fce..16a2d16a50 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -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 { + 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 { - - @JvmStatic - override fun create(viewModelContext: ViewModelContext, state: MessageComposerViewState): MessageComposerViewModel { - val fragment: RoomDetailFragment = (viewModelContext as FragmentViewModelContext).fragment() - return fragment.messageComposerViewModelFactory.create(state) - } - } + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt index 0df093c661..f9c32d3194 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt index 14d5a58279..0989337264 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt @@ -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,32 +111,35 @@ class VoiceMessageRecorderView @JvmOverloads constructor( fun render(recordingState: RecordingUiState) { if (lastKnownState == recordingState) return - lastKnownState = recordingState when (recordingState) { - RecordingUiState.None -> { + 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 } - RecordingUiState.Cancelled -> { + RecordingUiState.Cancelled -> { reset() vibrate(context) } - RecordingUiState.Locked -> { + is RecordingUiState.Locked -> { + if (lastKnownState == null) { + startRecordingTicker(startFromLocked = true, startAt = recordingState.recordingStartTimestamp) + } voiceMessageViews.renderLocked() postDelayed({ voiceMessageViews.showRecordingLockedViews(recordingState) }, 500) } - RecordingUiState.Playback -> { + RecordingUiState.Playback -> { stopRecordingTicker() 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 } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt index 32f21a3177..e138e14261 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/VoiceMessagePlaybackTracker.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/VoiceMessagePlaybackTracker.kt index 2e8f6d9336..86cc792e7b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/VoiceMessagePlaybackTracker.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/VoiceMessagePlaybackTracker.kt @@ -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())