Basic return to call Ux in Room detail

This commit is contained in:
Valere 2020-06-12 17:38:17 +02:00
parent a1907aaddb
commit c4b977c6e1
11 changed files with 274 additions and 33 deletions

View file

@ -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)

View file

@ -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() }
}
}

View file

@ -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()
}
}

View file

@ -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<MxCall?> = 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()
}
}

View file

@ -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()
}
}

View file

@ -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<CurrentCallListener>().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<SurfaceViewRenderer>? = 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
}

View file

@ -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)
}
}
}
}

View file

@ -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" />
<ImageView
android:id="@+id/iv_end_call"

View file

@ -97,6 +97,12 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomToolbar" />
<im.vector.riotx.core.ui.views.ActiveCallView
android:id="@+id/activeCallView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/syncStateView" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="0dp"
@ -105,7 +111,7 @@
app:layout_constraintBottom_toTopOf="@+id/recyclerViewBarrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/syncStateView"
app:layout_constraintTop_toBottomOf="@id/activeCallView"
tools:listitem="@layout/item_timeline_event_base" />

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?colorPrimary"
tools:parentTag="android.widget.RelativeLayout">
<TextView
android:id="@+id/activeCallInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_toStartOf="@id/returnToCallButton"
android:background="?attr/selectableItemBackground"
android:drawableStart="@drawable/ic_call"
android:drawableTint="@color/white"
android:drawablePadding="10dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingTop="12dp"
android:paddingEnd="16dp"
android:paddingBottom="12dp"
android:text="@string/active_call"
android:textColor="@color/white" />
<TextView
android:id="@+id/returnToCallButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignTop="@+id/activeCallInfo"
android:layout_alignBottom="@+id/activeCallInfo"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:textColor="@color/white"
android:gravity="center"
android:textAllCaps="true"
android:textStyle="bold"
android:paddingEnd="16dp"
android:paddingStart="8dp"
android:textSize="15sp"
android:text="@string/return_to_call" />
</merge>

View file

@ -362,6 +362,8 @@
<string name="incoming_voice_call">Incoming Voice Call</string>
<string name="call_in_progress">Call In Progress…</string>
<string name="video_call_in_progress">Video Call In Progress…</string>
<string name="active_call_with_duration">Active Call (%s)</string>
<string name="return_to_call">Return to call</string>
<string name="call_error_user_not_responding">The remote side failed to pick up.</string>
<string name="call_error_ice_failed">Media Connection Failed</string>