mirror of
https://github.com/element-hq/element-android
synced 2024-11-24 18:35:40 +03:00
Merge pull request #4556 from vector-im/feature/adm/voice-rotation
Supporting rotation during voice recordings
This commit is contained in:
commit
b59ae53805
10 changed files with 105 additions and 55 deletions
1
changelog.d/4067.bugfix
Normal file
1
changelog.d/4067.bugfix
Normal file
|
@ -0,0 +1 @@
|
|||
Allow voice messages to continue recording during device rotation
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
36
vector/src/main/java/im/vector/app/core/time/Clock.kt
Normal file
36
vector/src/main/java/im/vector/app/core/time/Clock.kt
Normal 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()
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
|
|
Loading…
Reference in a new issue