From c4b977c6e156c863a4e82016e19076d0afcbe9a3 Mon Sep 17 00:00:00 2001 From: Valere Date: Fri, 12 Jun 2020 17:38:17 +0200 Subject: [PATCH] Basic return to call Ux in Room detail --- .../vector/riotx/core/di/ViewModelModule.kt | 6 +++ .../riotx/core/ui/views/ActiveCallView.kt | 46 ++++++++++++++++ .../riotx/features/call/CallControlsView.kt | 10 ++++ .../call/SharedActiveCallViewModel.kt | 51 ++++++++++++++++++ .../riotx/features/call/VectorCallActivity.kt | 45 +++++++++------- .../call/WebRtcPeerConnectionManager.kt | 28 ++++++++-- .../home/room/detail/RoomDetailFragment.kt | 54 ++++++++++++++++++- .../res/layout/fragment_call_controls.xml | 14 ++--- .../main/res/layout/fragment_room_detail.xml | 8 ++- .../main/res/layout/view_active_call_view.xml | 43 +++++++++++++++ vector/src/main/res/values/strings.xml | 2 + 11 files changed, 274 insertions(+), 33 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/core/ui/views/ActiveCallView.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/call/SharedActiveCallViewModel.kt create mode 100644 vector/src/main/res/layout/view_active_call_view.xml diff --git a/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt b/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt index a214073104..badfdd96c1 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt @@ -22,6 +22,7 @@ import dagger.Binds import dagger.Module import dagger.multibindings.IntoMap import im.vector.riotx.core.platform.ConfigurationViewModel +import im.vector.riotx.features.call.SharedActiveCallViewModel import im.vector.riotx.features.crypto.keysbackup.restore.KeysBackupRestoreFromKeyViewModel import im.vector.riotx.features.crypto.keysbackup.restore.KeysBackupRestoreFromPassphraseViewModel import im.vector.riotx.features.crypto.keysbackup.restore.KeysBackupRestoreSharedViewModel @@ -85,6 +86,11 @@ interface ViewModelModule { @ViewModelKey(ConfigurationViewModel::class) fun bindConfigurationViewModel(viewModel: ConfigurationViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(SharedActiveCallViewModel::class) + fun bindSharedActiveCallViewModel(viewModel: SharedActiveCallViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(UserDirectorySharedActionViewModel::class) diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/ActiveCallView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/ActiveCallView.kt new file mode 100644 index 0000000000..9507a4daf8 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/ActiveCallView.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 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.riotx.core.ui.views + +import android.content.Context +import android.util.AttributeSet +import android.widget.RelativeLayout +import im.vector.riotx.R +import im.vector.riotx.features.themes.ThemeUtils + +class ActiveCallView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RelativeLayout(context, attrs, defStyleAttr) { + + interface Callback { + fun onTapToReturnToCall() + } + + var callback: Callback? = null + + init { + setupView() + } + + private fun setupView() { + inflate(context, R.layout.view_active_call_view, this) + setBackgroundColor(ThemeUtils.getColor(context, R.attr.colorPrimary)) + setOnClickListener { callback?.onTapToReturnToCall() } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/CallControlsView.kt b/vector/src/main/java/im/vector/riotx/features/call/CallControlsView.kt index 4c7919acc2..6dbf339373 100644 --- a/vector/src/main/java/im/vector/riotx/features/call/CallControlsView.kt +++ b/vector/src/main/java/im/vector/riotx/features/call/CallControlsView.kt @@ -22,12 +22,14 @@ import android.view.ViewGroup import android.widget.ImageView import android.widget.LinearLayout import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isInvisible import androidx.core.view.isVisible import butterknife.BindView import butterknife.ButterKnife import butterknife.OnClick import im.vector.matrix.android.api.session.call.CallState import im.vector.riotx.R +import kotlinx.android.synthetic.main.fragment_call_controls.view.* class CallControlsView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 @@ -82,6 +84,12 @@ class CallControlsView @JvmOverloads constructor( interactionListener?.didTapToggleVideo() } + @OnClick(R.id.iv_leftMiniControl) + fun returnToChat() { + interactionListener?.returnToChat() + } + + fun updateForState(state: VectorCallViewState) { val callState = state.callState.invoke() muteIcon.setImageResource(if (state.isAudioMuted) R.drawable.ic_microphone_off else R.drawable.ic_microphone_on) @@ -106,6 +114,7 @@ class CallControlsView @JvmOverloads constructor( CallState.CONNECTED -> { ringingControls.isVisible = false connectedControls.isVisible = true + iv_video_toggle.isInvisible = !state.isVideoCall } CallState.TERMINATED, null -> { @@ -121,5 +130,6 @@ class CallControlsView @JvmOverloads constructor( fun didEndCall() fun didTapToggleMute() fun didTapToggleVideo() + fun returnToChat() } } diff --git a/vector/src/main/java/im/vector/riotx/features/call/SharedActiveCallViewModel.kt b/vector/src/main/java/im/vector/riotx/features/call/SharedActiveCallViewModel.kt new file mode 100644 index 0000000000..efd8541e1c --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/SharedActiveCallViewModel.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2020 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.riotx.features.call + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import im.vector.matrix.android.api.session.call.MxCall +import im.vector.riotx.core.platform.VectorSharedAction +import javax.inject.Inject + +sealed class CallActions : VectorSharedAction { + data class GoToCallActivity(val mxCall: MxCall) : CallActions() + data class ToggleVisibility(val visible: Boolean) : CallActions() +} + +class SharedActiveCallViewModel @Inject constructor( + private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager +) : ViewModel() { + + val activeCall: MutableLiveData = MutableLiveData() + + private val listener = object : WebRtcPeerConnectionManager.CurrentCallListener { + override fun onCurrentCallChange(call: MxCall?) { + activeCall.postValue(call) + } + } + + init { + activeCall.postValue(webRtcPeerConnectionManager.currentCall?.mxCall) + webRtcPeerConnectionManager.addCurrentCallListener(listener) + } + + override fun onCleared() { + webRtcPeerConnectionManager.removeCurrentCallListener(listener) + super.onCleared() + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/VectorCallActivity.kt b/vector/src/main/java/im/vector/riotx/features/call/VectorCallActivity.kt index 5108883fed..d201588bf9 100644 --- a/vector/src/main/java/im/vector/riotx/features/call/VectorCallActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/call/VectorCallActivity.kt @@ -171,59 +171,59 @@ class VectorCallActivity : VectorBaseActivity(), CallControlsView.InteractionLis callControlsView.updateForState(state) when (state.callState.invoke()) { CallState.IDLE, - CallState.DIALING -> { + CallState.DIALING -> { callVideoGroup.isInvisible = true callInfoGroup.isVisible = true callStatusText.setText(R.string.call_ring) - state.otherUserMatrixItem.invoke()?.let { - avatarRenderer.render(it, otherMemberAvatar) - participantNameText.text = it.getBestName() - callTypeText.setText(if (state.isVideoCall) R.string.action_video_call else R.string.action_voice_call) - } + configureCallInfo(state) } - CallState.LOCAL_RINGING -> { + CallState.LOCAL_RINGING -> { callVideoGroup.isInvisible = true callInfoGroup.isVisible = true callStatusText.text = null - state.otherUserMatrixItem.invoke()?.let { - avatarRenderer.render(it, otherMemberAvatar) - participantNameText.text = it.getBestName() - callTypeText.setText(if (state.isVideoCall) R.string.action_video_call else R.string.action_voice_call) - } + configureCallInfo(state) } - CallState.ANSWERING -> { + CallState.ANSWERING -> { callVideoGroup.isInvisible = true callInfoGroup.isVisible = true callStatusText.setText(R.string.call_connecting) - state.otherUserMatrixItem.invoke()?.let { - avatarRenderer.render(it, otherMemberAvatar) - } + configureCallInfo(state) } - CallState.CONNECTING -> { + CallState.CONNECTING -> { callVideoGroup.isInvisible = true callInfoGroup.isVisible = true + configureCallInfo(state) callStatusText.setText(R.string.call_connecting) } - CallState.CONNECTED -> { + CallState.CONNECTED -> { if (callArgs.isVideoCall) { callVideoGroup.isVisible = true callInfoGroup.isVisible = false } else { callVideoGroup.isInvisible = true callInfoGroup.isVisible = true + configureCallInfo(state) callStatusText.text = null } } - CallState.TERMINATED -> { + CallState.TERMINATED -> { finish() } - null -> { + null -> { } } } + private fun configureCallInfo(state: VectorCallViewState) { + state.otherUserMatrixItem.invoke()?.let { + avatarRenderer.render(it, otherMemberAvatar) + participantNameText.text = it.getBestName() + callTypeText.setText(if (state.isVideoCall) R.string.action_video_call else R.string.action_voice_call) + } + } + private fun configureCallViews() { callControlsView.interactionListener = this // if (callArgs.isVideoCall) { @@ -337,4 +337,9 @@ class VectorCallActivity : VectorBaseActivity(), CallControlsView.InteractionLis override fun didTapToggleVideo() { callViewModel.handle(VectorCallViewActions.ToggleVideo) } + + override fun returnToChat() { + // TODO, what if the room is not in backstack?? + finish() + } } diff --git a/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt b/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt index bb7ca8f8f4..1a60fc97e4 100644 --- a/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt +++ b/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt @@ -18,6 +18,7 @@ package im.vector.riotx.features.call import android.content.Context import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.extensions.tryThis import im.vector.matrix.android.api.session.call.CallState import im.vector.matrix.android.api.session.call.CallsListener import im.vector.matrix.android.api.session.call.EglUtils @@ -68,6 +69,19 @@ class WebRtcPeerConnectionManager @Inject constructor( private val sessionHolder: ActiveSessionHolder ) : CallsListener { + interface CurrentCallListener { + fun onCurrentCallChange(call: MxCall?) + } + + private val currentCallsListeners = emptyList().toMutableList() + fun addCurrentCallListener(listener: CurrentCallListener) { + currentCallsListeners.add(listener) + } + + fun removeCurrentCallListener(listener: CurrentCallListener) { + currentCallsListeners.remove(listener) + } + data class CallContext( val mxCall: MxCall, @@ -137,6 +151,12 @@ class WebRtcPeerConnectionManager @Inject constructor( var remoteSurfaceRenderer: WeakReference? = null var currentCall: CallContext? = null + set(value) { + field = value + currentCallsListeners.forEach { + tryThis { it.onCurrentCallChange(value?.mxCall) } + } + } init { // TODO do this lazyly @@ -569,10 +589,10 @@ class WebRtcPeerConnectionManager @Inject constructor( */ PeerConnection.PeerConnectionState.NEW, - /** - * One or more of the ICE transports are currently in the process of establishing a connection; - * that is, their RTCIceConnectionState is either "checking" or "connected", and no transports are in the "failed" state - */ + /** + * One or more of the ICE transports are currently in the process of establishing a connection; + * that is, their RTCIceConnectionState is either "checking" or "connected", and no transports are in the "failed" state + */ PeerConnection.PeerConnectionState.CONNECTING -> { callContext.mxCall.state = CallState.CONNECTING } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index d0ec5a7894..c1e5e032b0 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -42,6 +42,7 @@ import androidx.core.util.Pair import androidx.core.view.ViewCompat import androidx.core.view.forEach import androidx.core.view.isVisible +import androidx.lifecycle.Observer import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -100,6 +101,7 @@ import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.intent.getMimeTypeFromUri import im.vector.riotx.core.platform.VectorBaseFragment import im.vector.riotx.core.resources.ColorProvider +import im.vector.riotx.core.ui.views.ActiveCallView import im.vector.riotx.core.ui.views.JumpToReadMarkerView import im.vector.riotx.core.ui.views.NotificationAreaView import im.vector.riotx.core.utils.Debouncer @@ -127,6 +129,8 @@ import im.vector.riotx.features.attachments.ContactAttachment import im.vector.riotx.features.attachments.preview.AttachmentsPreviewActivity import im.vector.riotx.features.attachments.preview.AttachmentsPreviewArgs import im.vector.riotx.features.attachments.toGroupedContentAttachmentData +import im.vector.riotx.features.call.SharedActiveCallViewModel +import im.vector.riotx.features.call.VectorCallActivity import im.vector.riotx.features.call.WebRtcPeerConnectionManager import im.vector.riotx.features.command.Command import im.vector.riotx.features.crypto.keysbackup.restore.KeysBackupRestoreActivity @@ -205,7 +209,8 @@ class RoomDetailFragment @Inject constructor( JumpToReadMarkerView.Callback, AttachmentTypeSelectorView.Callback, AttachmentsHelper.Callback, - RoomWidgetsBannerView.Callback { + RoomWidgetsBannerView.Callback, + ActiveCallView.Callback { companion object { @@ -245,6 +250,8 @@ class RoomDetailFragment @Inject constructor( override fun getMenuRes() = R.menu.menu_timeline private lateinit var sharedActionViewModel: MessageSharedActionViewModel + private lateinit var sharedCallActionViewModel: SharedActiveCallViewModel + private lateinit var layoutManager: LinearLayoutManager private lateinit var jumpToBottomViewVisibilityManager: JumpToBottomViewVisibilityManager private var modelBuildListener: OnModelBuildFinishedListener? = null @@ -261,6 +268,7 @@ class RoomDetailFragment @Inject constructor( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java) + sharedCallActionViewModel = activityViewModelProvider.get(SharedActiveCallViewModel::class.java) attachmentsHelper = AttachmentsHelper(requireContext(), this).register() keyboardStateUtils = KeyboardStateUtils(requireActivity()) setupToolbar(roomToolbar) @@ -269,6 +277,7 @@ class RoomDetailFragment @Inject constructor( setupInviteView() setupNotificationView() setupJumpToReadMarkerView() + setupActiveCallView() setupJumpToBottomView() setupWidgetsBannerView() @@ -283,6 +292,13 @@ class RoomDetailFragment @Inject constructor( } .disposeOnDestroyView() + sharedCallActionViewModel + .activeCall + .observe(viewLifecycleOwner, Observer { + //TODO delay a bit if it's a new call to let call activity launch before .. + activeCallView.isVisible = it != null + }) + roomDetailViewModel.selectSubscribe(this, RoomDetailViewState::tombstoneEventHandling, uniqueOnly("tombstoneEventHandling")) { renderTombstoneEventHandling(it) } @@ -374,6 +390,7 @@ class RoomDetailFragment @Inject constructor( override fun onDestroyView() { timelineEventController.callback = null timelineEventController.removeModelBuildListener(modelBuildListener) + activeCallView.callback = null modelBuildListener = null autoCompleter.clear() debouncer.cancelAll() @@ -412,6 +429,10 @@ class RoomDetailFragment @Inject constructor( jumpToReadMarkerView.callback = this } + private fun setupActiveCallView() { + activeCallView.callback = this + } + private fun navigateToEvent(action: RoomDetailViewEvents.NavigateToEvent) { val scrollPosition = timelineEventController.searchPositionOfEvent(action.eventId) if (scrollPosition == null) { @@ -483,7 +504,19 @@ class RoomDetailFragment @Inject constructor( R.id.video_call -> { roomDetailViewModel.getOtherUserIds()?.firstOrNull()?.let { // TODO CALL We should check/ask for permission here first - webRtcPeerConnectionManager.startOutgoingCall(requireContext(), roomDetailArgs.roomId, it, item.itemId == R.id.video_call) + val activeCall = sharedCallActionViewModel.activeCall.value + if (activeCall != null) { + // resume existing if same room, if not prompt to kill and then restart new call? + if (activeCall.roomId == roomDetailArgs.roomId) { + onTapToReturnToCall() + } else { + // TODO might not work well, and should prompt + webRtcPeerConnectionManager.endCall() + webRtcPeerConnectionManager.startOutgoingCall(requireContext(), roomDetailArgs.roomId, it, item.itemId == R.id.video_call) + } + } else { + webRtcPeerConnectionManager.startOutgoingCall(requireContext(), roomDetailArgs.roomId, it, item.itemId == R.id.video_call) + } } true } @@ -1479,4 +1512,21 @@ class RoomDetailFragment @Inject constructor( RoomWidgetsBottomSheet.newInstance() .show(childFragmentManager, "ROOM_WIDGETS_BOTTOM_SHEET") } + + override fun onTapToReturnToCall() { + sharedCallActionViewModel.activeCall.value?.let { call -> + VectorCallActivity.newIntent( + requireContext(), + call.callId, + call.roomId, + call.otherUserId, + !call.isOutgoing, + call.isVideoCall, + false, + null + ).let { + startActivity(it) + } + } + } } diff --git a/vector/src/main/res/layout/fragment_call_controls.xml b/vector/src/main/res/layout/fragment_call_controls.xml index a1c8c313a7..6f1861afec 100644 --- a/vector/src/main/res/layout/fragment_call_controls.xml +++ b/vector/src/main/res/layout/fragment_call_controls.xml @@ -11,8 +11,8 @@ android:id="@+id/ringingControls" android:layout_width="match_parent" android:layout_height="wrap_content" - android:padding="16dp" android:orientation="horizontal" + android:padding="16dp" tools:background="@color/password_strength_bar_ok" tools:visibility="visible"> @@ -55,8 +55,8 @@ android:id="@+id/connectedControls" android:layout_width="match_parent" android:layout_height="wrap_content" - android:padding="16dp" android:orientation="horizontal" + android:padding="16dp" android:visibility="gone" tools:background="@color/password_strength_bar_low" tools:visibility="visible"> @@ -66,11 +66,13 @@ android:layout_width="44dp" android:layout_height="44dp" android:layout_marginBottom="32dp" + android:background="@drawable/oval_positive" + android:backgroundTint="?attr/riotx_background" android:clickable="true" android:focusable="true" - android:padding="8dp" + android:padding="10dp" android:src="@drawable/ic_home_bottom_chat" - android:tint="?attr/riotx_background" + android:tint="?attr/riotx_text_primary" tools:ignore="MissingConstraints" /> @@ -85,9 +87,9 @@ android:focusable="true" android:padding="16dp" android:src="@drawable/ic_microphone_off" - tools:src="@drawable/ic_microphone_on" android:tint="?attr/riotx_text_primary" - tools:ignore="MissingConstraints" /> + tools:ignore="MissingConstraints" + tools:src="@drawable/ic_microphone_on" /> + + diff --git a/vector/src/main/res/layout/view_active_call_view.xml b/vector/src/main/res/layout/view_active_call_view.xml new file mode 100644 index 0000000000..a457c5164e --- /dev/null +++ b/vector/src/main/res/layout/view_active_call_view.xml @@ -0,0 +1,43 @@ + + + + + + + + diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 931a7896eb..91a3388bd2 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -362,6 +362,8 @@ Incoming Voice Call Call In Progress… Video Call In Progress… + Active Call (%s) + Return to call The remote side failed to pick up. Media Connection Failed