Merge pull request #3712 from vector-im/feature/fga/missed_call_notification

Feature/fga/missed call notification
This commit is contained in:
Benoit Marty 2021-07-22 09:46:40 +02:00 committed by GitHub
commit 5d65c83a3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 301 additions and 114 deletions

1
changelog.d/3710.feature Normal file
View File

@ -0,0 +1 @@
Show missed call notification.

View File

@ -16,6 +16,8 @@
package org.matrix.android.sdk.api.session.call package org.matrix.android.sdk.api.session.call
import org.matrix.android.sdk.api.session.room.model.call.EndCallReason
sealed class CallState { sealed class CallState {
/** Idle, setting up objects */ /** Idle, setting up objects */
@ -42,6 +44,6 @@ sealed class CallState {
* */ * */
data class Connected(val iceConnectionState: MxPeerConnectionState) : CallState() data class Connected(val iceConnectionState: MxPeerConnectionState) : CallState()
/** Terminated. Incoming/Outgoing call, the call is terminated */ /** Ended. Incoming/Outgoing call, the call is terminated */
object Terminated : CallState() data class Ended(val reason: EndCallReason? = null) : CallState()
} }

View File

@ -18,7 +18,7 @@ package org.matrix.android.sdk.api.session.call
import org.matrix.android.sdk.api.session.room.model.call.CallCandidate import org.matrix.android.sdk.api.session.room.model.call.CallCandidate
import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities
import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent import org.matrix.android.sdk.api.session.room.model.call.EndCallReason
import org.matrix.android.sdk.api.session.room.model.call.SdpType import org.matrix.android.sdk.api.session.room.model.call.SdpType
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.Optional
@ -69,7 +69,7 @@ interface MxCall : MxCallDetail {
/** /**
* End the call * End the call
*/ */
fun hangUp(reason: CallHangupContent.Reason? = null) fun hangUp(reason: EndCallReason? = null)
/** /**
* Start a call * Start a call

View File

@ -43,29 +43,5 @@ data class CallHangupContent(
* or `invite_timeout` for when the other party did not answer in time. * or `invite_timeout` for when the other party did not answer in time.
* One of: ["ice_failed", "invite_timeout"] * One of: ["ice_failed", "invite_timeout"]
*/ */
@Json(name = "reason") val reason: Reason? = null @Json(name = "reason") val reason: EndCallReason? = null
) : CallSignalingContent { ) : CallSignalingContent
@JsonClass(generateAdapter = false)
enum class Reason {
@Json(name = "ice_failed")
ICE_FAILED,
@Json(name = "ice_timeout")
ICE_TIMEOUT,
@Json(name = "user_hangup")
USER_HANGUP,
@Json(name = "replaced")
REPLACED,
@Json(name = "user_media_failed")
USER_MEDIA_FAILED,
@Json(name = "invite_timeout")
INVITE_TIMEOUT,
@Json(name = "unknown_error")
UNKWOWN_ERROR
}
}

View File

@ -36,5 +36,10 @@ data class CallRejectContent(
/** /**
* Required. The version of the VoIP specification this message adheres to. * Required. The version of the VoIP specification this message adheres to.
*/ */
@Json(name = "version") override val version: String? @Json(name = "version") override val version: String?,
/**
* Optional error reason for the reject.
*/
@Json(name = "reason") val reason: EndCallReason? = null
) : CallSignalingContent ) : CallSignalingContent

View File

@ -0,0 +1,50 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.api.session.room.model.call
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false)
enum class EndCallReason {
@Json(name = "ice_failed")
ICE_FAILED,
@Json(name = "ice_timeout")
ICE_TIMEOUT,
@Json(name = "user_hangup")
USER_HANGUP,
@Json(name = "replaced")
REPLACED,
@Json(name = "user_media_failed")
USER_MEDIA_FAILED,
@Json(name = "invite_timeout")
INVITE_TIMEOUT,
@Json(name = "unknown_error")
UNKWOWN_ERROR,
@Json(name = "user_busy")
USER_BUSY,
@Json(name = "answered_elsewhere")
ANSWERED_ELSEWHERE
}

View File

@ -166,7 +166,7 @@ internal class CallSignalingHandler @Inject constructor(private val activeCallHa
Timber.v("Ignoring hangup from party ID ${content.partyId} we have chosen party ID ${call.opponentPartyId}") Timber.v("Ignoring hangup from party ID ${content.partyId} we have chosen party ID ${call.opponentPartyId}")
return return
} }
if (call.state != CallState.Terminated) { if (call.state !is CallState.Ended) {
activeCallHandler.removeCall(content.callId) activeCallHandler.removeCall(content.callId)
callListenersDispatcher.onCallHangupReceived(content) callListenersDispatcher.onCallHangupReceived(content)
} }

View File

@ -38,6 +38,7 @@ import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent
import org.matrix.android.sdk.api.session.room.model.call.CallReplacesContent import org.matrix.android.sdk.api.session.room.model.call.CallReplacesContent
import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.CallSignalingContent import org.matrix.android.sdk.api.session.room.model.call.CallSignalingContent
import org.matrix.android.sdk.api.session.room.model.call.EndCallReason
import org.matrix.android.sdk.api.session.room.model.call.SdpType import org.matrix.android.sdk.api.session.room.model.call.SdpType
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.session.call.DefaultCallSignalingService import org.matrix.android.sdk.internal.session.call.DefaultCallSignalingService
@ -142,7 +143,7 @@ internal class MxCallImpl(
override fun reject() { override fun reject() {
if (opponentVersion < 1) { if (opponentVersion < 1) {
Timber.v("Opponent version is less than 1 ($opponentVersion): sending hangup instead of reject") Timber.v("Opponent version is less than 1 ($opponentVersion): sending hangup instead of reject")
hangUp() hangUp(EndCallReason.USER_HANGUP)
return return
} }
Timber.v("## VOIP reject $callId") Timber.v("## VOIP reject $callId")
@ -153,20 +154,20 @@ internal class MxCallImpl(
) )
.let { createEventAndLocalEcho(type = EventType.CALL_REJECT, roomId = roomId, content = it.toContent()) } .let { createEventAndLocalEcho(type = EventType.CALL_REJECT, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) } .also { eventSenderProcessor.postEvent(it) }
state = CallState.Terminated state = CallState.Ended(reason = EndCallReason.USER_HANGUP)
} }
override fun hangUp(reason: CallHangupContent.Reason?) { override fun hangUp(reason: EndCallReason?) {
Timber.v("## VOIP hangup $callId") Timber.v("## VOIP hangup $callId")
CallHangupContent( CallHangupContent(
callId = callId, callId = callId,
partyId = ourPartyId, partyId = ourPartyId,
reason = reason ?: CallHangupContent.Reason.USER_HANGUP, reason = reason,
version = MxCall.VOIP_PROTO_VERSION.toString() version = MxCall.VOIP_PROTO_VERSION.toString()
) )
.let { createEventAndLocalEcho(type = EventType.CALL_HANGUP, roomId = roomId, content = it.toContent()) } .let { createEventAndLocalEcho(type = EventType.CALL_HANGUP, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) } .also { eventSenderProcessor.postEvent(it) }
state = CallState.Terminated state = CallState.Ended(reason)
} }
override fun accept(sdpString: String) { override fun accept(sdpString: String) {

View File

@ -37,6 +37,7 @@ import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.notifications.NotificationUtils import im.vector.app.features.notifications.NotificationUtils
import im.vector.app.features.popup.IncomingCallAlert import im.vector.app.features.popup.IncomingCallAlert
import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.popup.PopupAlertManager
import org.matrix.android.sdk.api.session.room.model.call.EndCallReason
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
import timber.log.Timber import timber.log.Timber
@ -46,7 +47,8 @@ import timber.log.Timber
class CallService : VectorService() { class CallService : VectorService() {
private val connections = mutableMapOf<String, CallConnection>() private val connections = mutableMapOf<String, CallConnection>()
private val knownCalls = mutableSetOf<String>() private val knownCalls = mutableSetOf<CallInformation>()
private val connectedCallIds = mutableSetOf<String>()
private lateinit var notificationManager: NotificationManagerCompat private lateinit var notificationManager: NotificationManagerCompat
private lateinit var notificationUtils: NotificationUtils private lateinit var notificationUtils: NotificationUtils
@ -115,19 +117,19 @@ class CallService : VectorService() {
callRingPlayerOutgoing?.start() callRingPlayerOutgoing?.start()
displayOutgoingRingingCallNotification(intent) displayOutgoingRingingCallNotification(intent)
} }
ACTION_ONGOING_CALL -> { ACTION_ONGOING_CALL -> {
callRingPlayerIncoming?.stop() callRingPlayerIncoming?.stop()
callRingPlayerOutgoing?.stop() callRingPlayerOutgoing?.stop()
displayCallInProgressNotification(intent) displayCallInProgressNotification(intent)
} }
ACTION_CALL_CONNECTING -> { ACTION_CALL_CONNECTING -> {
// lower notification priority // lower notification priority
displayCallInProgressNotification(intent) displayCallInProgressNotification(intent)
// stop ringing // stop ringing
callRingPlayerIncoming?.stop() callRingPlayerIncoming?.stop()
callRingPlayerOutgoing?.stop() callRingPlayerOutgoing?.stop()
} }
ACTION_CALL_TERMINATED -> { ACTION_CALL_TERMINATED -> {
handleCallTerminated(intent) handleCallTerminated(intent)
} }
else -> { else -> {
@ -153,9 +155,9 @@ class CallService : VectorService() {
val call = callManager.getCallById(callId) ?: return Unit.also { val call = callManager.getCallById(callId) ?: return Unit.also {
handleUnexpectedState(callId) handleUnexpectedState(callId)
} }
val callInformation = call.toCallInformation()
val isVideoCall = call.mxCall.isVideoCall val isVideoCall = call.mxCall.isVideoCall
val fromBg = intent.getBooleanExtra(EXTRA_IS_IN_BG, false) val fromBg = intent.getBooleanExtra(EXTRA_IS_IN_BG, false)
val opponentMatrixItem = getOpponentMatrixItem(call)
Timber.v("displayIncomingCallNotification : display the dedicated notification") Timber.v("displayIncomingCallNotification : display the dedicated notification")
val incomingCallAlert = IncomingCallAlert(callId, val incomingCallAlert = IncomingCallAlert(callId,
shouldBeDisplayedIn = { activity -> shouldBeDisplayedIn = { activity ->
@ -165,7 +167,7 @@ class CallService : VectorService() {
} }
).apply { ).apply {
viewBinder = IncomingCallAlert.ViewBinder( viewBinder = IncomingCallAlert.ViewBinder(
matrixItem = opponentMatrixItem, matrixItem = callInformation.opponentMatrixItem,
avatarRenderer = avatarRenderer, avatarRenderer = avatarRenderer,
isVideoCall = isVideoCall, isVideoCall = isVideoCall,
onAccept = { showCallScreen(call, VectorCallActivity.INCOMING_ACCEPT) }, onAccept = { showCallScreen(call, VectorCallActivity.INCOMING_ACCEPT) },
@ -177,7 +179,7 @@ class CallService : VectorService() {
alertManager.postVectorAlert(incomingCallAlert) alertManager.postVectorAlert(incomingCallAlert)
val notification = notificationUtils.buildIncomingCallNotification( val notification = notificationUtils.buildIncomingCallNotification(
call = call, call = call,
title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId, title = callInformation.opponentMatrixItem?.getBestName() ?: callInformation.opponentUserId,
fromBg = fromBg fromBg = fromBg
) )
if (knownCalls.isEmpty()) { if (knownCalls.isEmpty()) {
@ -185,23 +187,34 @@ class CallService : VectorService() {
} else { } else {
notificationManager.notify(callId.hashCode(), notification) notificationManager.notify(callId.hashCode(), notification)
} }
knownCalls.add(callId) knownCalls.add(callInformation)
} }
private fun handleCallTerminated(intent: Intent) { private fun handleCallTerminated(intent: Intent) {
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: "" val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
val endCallReason = intent.getSerializableExtra(EXTRA_END_CALL_REASON) as EndCallReason
val rejected = intent.getBooleanExtra(EXTRA_END_CALL_REJECTED, false)
alertManager.cancelAlert(callId) alertManager.cancelAlert(callId)
if (!knownCalls.remove(callId)) { val terminatedCall = knownCalls.firstOrNull { it.callId == callId }
if (terminatedCall == null) {
Timber.v("Call terminated for unknown call $callId$") Timber.v("Call terminated for unknown call $callId$")
handleUnexpectedState(callId) handleUnexpectedState(callId)
return return
} }
val notification = notificationUtils.buildCallEndedNotification() knownCalls.remove(terminatedCall)
notificationManager.notify(callId.hashCode(), notification)
if (knownCalls.isEmpty()) { if (knownCalls.isEmpty()) {
mediaSession?.isActive = false mediaSession?.isActive = false
myStopSelf() myStopSelf()
} }
val wasConnected = connectedCallIds.remove(callId)
if (!wasConnected && !terminatedCall.isOutgoing && !rejected && endCallReason != EndCallReason.ANSWERED_ELSEWHERE) {
val notification = notificationUtils.buildCallMissedNotification(terminatedCall)
notificationManager.cancel(callId.hashCode())
notificationManager.notify(MISSED_CALL_TAG, terminatedCall.nativeRoomId.hashCode(), notification)
} else {
val notification = notificationUtils.buildCallEndedNotification(terminatedCall.isVideoCall)
notificationManager.notify(callId.hashCode(), notification)
}
} }
private fun showCallScreen(call: WebRtcCall, mode: String) { private fun showCallScreen(call: WebRtcCall, mode: String) {
@ -218,18 +231,18 @@ class CallService : VectorService() {
val call = callManager.getCallById(callId) ?: return Unit.also { val call = callManager.getCallById(callId) ?: return Unit.also {
handleUnexpectedState(callId) handleUnexpectedState(callId)
} }
val opponentMatrixItem = getOpponentMatrixItem(call) val callInformation = call.toCallInformation()
Timber.v("displayOutgoingCallNotification : display the dedicated notification") Timber.v("displayOutgoingCallNotification : display the dedicated notification")
val notification = notificationUtils.buildOutgoingRingingCallNotification( val notification = notificationUtils.buildOutgoingRingingCallNotification(
call = call, call = call,
title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId title = callInformation.opponentMatrixItem?.getBestName() ?: callInformation.opponentUserId
) )
if (knownCalls.isEmpty()) { if (knownCalls.isEmpty()) {
startForeground(callId.hashCode(), notification) startForeground(callId.hashCode(), notification)
} else { } else {
notificationManager.notify(callId.hashCode(), notification) notificationManager.notify(callId.hashCode(), notification)
} }
knownCalls.add(callId) knownCalls.add(callInformation)
} }
/** /**
@ -238,21 +251,22 @@ class CallService : VectorService() {
private fun displayCallInProgressNotification(intent: Intent) { private fun displayCallInProgressNotification(intent: Intent) {
Timber.v("## VOIP displayCallInProgressNotification") Timber.v("## VOIP displayCallInProgressNotification")
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: "" val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
connectedCallIds.add(callId)
val call = callManager.getCallById(callId) ?: return Unit.also { val call = callManager.getCallById(callId) ?: return Unit.also {
handleUnexpectedState(callId) handleUnexpectedState(callId)
} }
val opponentMatrixItem = getOpponentMatrixItem(call)
alertManager.cancelAlert(callId) alertManager.cancelAlert(callId)
val callInformation = call.toCallInformation()
val notification = notificationUtils.buildPendingCallNotification( val notification = notificationUtils.buildPendingCallNotification(
call = call, call = call,
title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId title = callInformation.opponentMatrixItem?.getBestName() ?: callInformation.opponentUserId
) )
if (knownCalls.isEmpty()) { if (knownCalls.isEmpty()) {
startForeground(callId.hashCode(), notification) startForeground(callId.hashCode(), notification)
} else { } else {
notificationManager.notify(callId.hashCode(), notification) notificationManager.notify(callId.hashCode(), notification)
} }
knownCalls.add(callId) knownCalls.add(callInformation)
} }
private fun handleUnexpectedState(callId: String?) { private fun handleUnexpectedState(callId: String?) {
@ -262,7 +276,7 @@ class CallService : VectorService() {
if (callId != null) { if (callId != null) {
notificationManager.cancel(callId.hashCode()) notificationManager.cancel(callId.hashCode())
} }
val notification = notificationUtils.buildCallEndedNotification() val notification = notificationUtils.buildCallEndedNotification(false)
startForeground(DEFAULT_NOTIFICATION_ID, notification) startForeground(DEFAULT_NOTIFICATION_ID, notification)
if (knownCalls.isEmpty()) { if (knownCalls.isEmpty()) {
mediaSession?.isActive = false mediaSession?.isActive = false
@ -274,14 +288,31 @@ class CallService : VectorService() {
connections[callConnection.callId] = callConnection connections[callConnection.callId] = callConnection
} }
private fun getOpponentMatrixItem(call: WebRtcCall): MatrixItem? { private fun WebRtcCall.toCallInformation(): CallInformation {
return vectorComponent().activeSessionHolder().getSafeActiveSession()?.let { return CallInformation(
call.getOpponentAsMatrixItem(it) callId = this.callId,
} nativeRoomId = this.nativeRoomId,
opponentUserId = this.mxCall.opponentUserId,
opponentMatrixItem = vectorComponent().activeSessionHolder().getSafeActiveSession()?.let {
this.getOpponentAsMatrixItem(it)
},
isVideoCall = this.mxCall.isVideoCall,
isOutgoing = this.mxCall.isOutgoing
)
} }
data class CallInformation(
val callId: String,
val nativeRoomId: String,
val opponentUserId: String,
val opponentMatrixItem: MatrixItem?,
val isVideoCall: Boolean,
val isOutgoing: Boolean
)
companion object { companion object {
private const val DEFAULT_NOTIFICATION_ID = 6480 private const val DEFAULT_NOTIFICATION_ID = 6480
private const val MISSED_CALL_TAG = "MISSED_CALL_TAG"
private const val ACTION_INCOMING_RINGING_CALL = "im.vector.app.core.services.CallService.ACTION_INCOMING_RINGING_CALL" private const val ACTION_INCOMING_RINGING_CALL = "im.vector.app.core.services.CallService.ACTION_INCOMING_RINGING_CALL"
private const val ACTION_OUTGOING_RINGING_CALL = "im.vector.app.core.services.CallService.ACTION_OUTGOING_RINGING_CALL" private const val ACTION_OUTGOING_RINGING_CALL = "im.vector.app.core.services.CallService.ACTION_OUTGOING_RINGING_CALL"
@ -294,6 +325,8 @@ class CallService : VectorService() {
private const val EXTRA_CALL_ID = "EXTRA_CALL_ID" private const val EXTRA_CALL_ID = "EXTRA_CALL_ID"
private const val EXTRA_IS_IN_BG = "EXTRA_IS_IN_BG" private const val EXTRA_IS_IN_BG = "EXTRA_IS_IN_BG"
private const val EXTRA_END_CALL_REJECTED = "EXTRA_END_CALL_REJECTED"
private const val EXTRA_END_CALL_REASON = "EXTRA_END_CALL_REASON"
fun onIncomingCallRinging(context: Context, fun onIncomingCallRinging(context: Context,
callId: String, callId: String,
@ -329,11 +362,13 @@ class CallService : VectorService() {
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }
fun onCallTerminated(context: Context, callId: String) { fun onCallTerminated(context: Context, callId: String, endCallReason: EndCallReason, rejected: Boolean) {
val intent = Intent(context, CallService::class.java) val intent = Intent(context, CallService::class.java)
.apply { .apply {
action = ACTION_CALL_TERMINATED action = ACTION_CALL_TERMINATED
putExtra(EXTRA_CALL_ID, callId) putExtra(EXTRA_CALL_ID, callId)
putExtra(EXTRA_END_CALL_REASON, endCallReason)
putExtra(EXTRA_END_CALL_REJECTED, rejected)
} }
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }

View File

@ -118,7 +118,7 @@ class CallControlsView @JvmOverloads constructor(
views.connectedControls.isVisible = false views.connectedControls.isVisible = false
} }
} }
is CallState.Terminated, is CallState.Ended,
null -> { null -> {
views.ringingControls.isVisible = false views.ringingControls.isVisible = false
views.connectedControls.isVisible = false views.connectedControls.isVisible = false

View File

@ -196,7 +196,7 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
views.callConnectingProgress.isVisible = true views.callConnectingProgress.isVisible = true
configureCallInfo(state) configureCallInfo(state)
} }
is CallState.Connected -> { is CallState.Connected -> {
if (callState.iceConnectionState == MxPeerConnectionState.CONNECTED) { if (callState.iceConnectionState == MxPeerConnectionState.CONNECTED) {
if (state.isLocalOnHold || state.isRemoteOnHold) { if (state.isLocalOnHold || state.isRemoteOnHold) {
views.smallIsHeldIcon.isVisible = true views.smallIsHeldIcon.isVisible = true
@ -246,10 +246,10 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
views.callConnectingProgress.isVisible = true views.callConnectingProgress.isVisible = true
} }
} }
is CallState.Terminated -> { is CallState.Ended -> {
finish() finish()
} }
null -> { null -> {
} }
} }
} }

View File

@ -57,7 +57,7 @@ class CallTransferViewModel @AssistedInject constructor(@Assisted initialState:
private val call = callManager.getCallById(initialState.callId) private val call = callManager.getCallById(initialState.callId)
private val callListener = object : WebRtcCall.Listener { private val callListener = object : WebRtcCall.Listener {
override fun onStateUpdate(call: MxCall) { override fun onStateUpdate(call: MxCall) {
if (call.state == CallState.Terminated) { if (call.state is CallState.Ended) {
_viewEvents.post(CallTransferViewEvents.Dismiss) _viewEvents.post(CallTransferViewEvents.Dismiss)
} }
} }

View File

@ -57,6 +57,9 @@ import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent
import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent
import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent
import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent
import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent
import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.EndCallReason
import org.matrix.android.sdk.api.session.room.model.call.SdpType import org.matrix.android.sdk.api.session.room.model.call.SdpType
import org.threeten.bp.Duration import org.threeten.bp.Duration
import org.webrtc.AudioSource import org.webrtc.AudioSource
@ -99,7 +102,7 @@ class WebRtcCall(
private val sessionProvider: Provider<Session?>, private val sessionProvider: Provider<Session?>,
private val peerConnectionFactoryProvider: Provider<PeerConnectionFactory?>, private val peerConnectionFactoryProvider: Provider<PeerConnectionFactory?>,
private val onCallBecomeActive: (WebRtcCall) -> Unit, private val onCallBecomeActive: (WebRtcCall) -> Unit,
private val onCallEnded: (String) -> Unit private val onCallEnded: (String, EndCallReason, Boolean) -> Unit
) : MxCall.StateListener { ) : MxCall.StateListener {
interface Listener : MxCall.StateListener { interface Listener : MxCall.StateListener {
@ -227,7 +230,7 @@ class WebRtcCall(
// Allow a short time for initial candidates to be gathered // Allow a short time for initial candidates to be gathered
delay(200) delay(200)
} }
if (mxCall.state == CallState.Terminated) { if (mxCall.state is CallState.Ended) {
return@launch return@launch
} }
if (mxCall.state == CallState.CreateOffer) { if (mxCall.state == CallState.CreateOffer) {
@ -285,7 +288,7 @@ class WebRtcCall(
createCallId = CallIdGenerator.generate(), createCallId = CallIdGenerator.generate(),
awaitCallId = null awaitCallId = null
) )
endCall(sendEndSignaling = false) terminate(EndCallReason.REPLACED)
} }
} }
@ -307,8 +310,8 @@ class WebRtcCall(
createCallId = newCallId, createCallId = newCallId,
awaitCallId = null awaitCallId = null
) )
endCall(sendEndSignaling = false) terminate(EndCallReason.REPLACED)
transferTargetCall.endCall(sendEndSignaling = false) transferTargetCall.terminate(EndCallReason.REPLACED)
} }
} }
@ -461,7 +464,7 @@ class WebRtcCall(
peerConnection?.awaitSetRemoteDescription(offerSdp) peerConnection?.awaitSetRemoteDescription(offerSdp)
} catch (failure: Throwable) { } catch (failure: Throwable) {
Timber.v("Failure putting remote description") Timber.v("Failure putting remote description")
endCall(true, CallHangupContent.Reason.UNKWOWN_ERROR) endCall(reason = EndCallReason.UNKWOWN_ERROR)
return@withContext return@withContext
} }
// 2) Access camera + microphone, create local stream // 2) Access camera + microphone, create local stream
@ -767,7 +770,7 @@ class WebRtcCall(
if (stream.audioTracks.size > 1 || stream.videoTracks.size > 1) { if (stream.audioTracks.size > 1 || stream.videoTracks.size > 1) {
Timber.e("## VOIP StreamObserver weird looking stream: $stream") Timber.e("## VOIP StreamObserver weird looking stream: $stream")
// TODO maybe do something more?? // TODO maybe do something more??
endCall(true) endCall(EndCallReason.UNKWOWN_ERROR)
return@launch return@launch
} }
if (stream.audioTracks.size == 1) { if (stream.audioTracks.size == 1) {
@ -795,32 +798,34 @@ class WebRtcCall(
} }
} }
fun endCall(sendEndSignaling: Boolean = true, reason: CallHangupContent.Reason? = null) { fun endCall(reason: EndCallReason = EndCallReason.USER_HANGUP) {
sessionScope?.launch(dispatcher) { sessionScope?.launch(dispatcher) {
if (mxCall.state == CallState.Terminated) { if (mxCall.state is CallState.Ended) {
return@launch return@launch
} }
// Close tracks ASAP val reject = mxCall.state is CallState.LocalRinging
localVideoTrack?.setEnabled(false) terminate(EndCallReason.USER_HANGUP, reject)
localVideoTrack?.setEnabled(false) if (reject) {
cameraAvailabilityCallback?.let { cameraAvailabilityCallback -> mxCall.reject()
val cameraManager = context.getSystemService<CameraManager>()!! } else {
cameraManager.unregisterAvailabilityCallback(cameraAvailabilityCallback) mxCall.hangUp(reason)
}
val wasRinging = mxCall.state is CallState.LocalRinging
mxCall.state = CallState.Terminated
release()
onCallEnded(callId)
if (sendEndSignaling) {
if (wasRinging) {
mxCall.reject()
} else {
mxCall.hangUp(reason)
}
} }
} }
} }
private suspend fun terminate(reason: EndCallReason? = null, rejected: Boolean = false) = withContext(dispatcher) {
// Close tracks ASAP
localVideoTrack?.setEnabled(false)
localVideoTrack?.setEnabled(false)
cameraAvailabilityCallback?.let { cameraAvailabilityCallback ->
val cameraManager = context.getSystemService<CameraManager>()!!
cameraManager.unregisterAvailabilityCallback(cameraAvailabilityCallback)
}
mxCall.state = CallState.Ended(reason ?: EndCallReason.USER_HANGUP)
release()
onCallEnded(callId, reason ?: EndCallReason.USER_HANGUP, rejected)
}
// Call listener // Call listener
fun onCallIceCandidateReceived(iceCandidatesContent: CallCandidatesContent) { fun onCallIceCandidateReceived(iceCandidatesContent: CallCandidatesContent) {
@ -843,7 +848,7 @@ class WebRtcCall(
try { try {
peerConnection?.awaitSetRemoteDescription(sdp) peerConnection?.awaitSetRemoteDescription(sdp)
} catch (failure: Throwable) { } catch (failure: Throwable) {
endCall(true, CallHangupContent.Reason.UNKWOWN_ERROR) endCall(EndCallReason.UNKWOWN_ERROR)
return@launch return@launch
} }
if (mxCall.opponentPartyId?.hasValue().orFalse()) { if (mxCall.opponentPartyId?.hasValue().orFalse()) {
@ -904,6 +909,29 @@ class WebRtcCall(
} }
} }
fun onCallHangupReceived(callHangupContent: CallHangupContent) {
sessionScope?.launch(dispatcher) {
terminate(callHangupContent.reason)
}
}
fun onCallRejectReceived(callRejectContent: CallRejectContent) {
sessionScope?.launch(dispatcher) {
terminate(callRejectContent.reason, true)
}
}
fun onCallSelectedAnswerReceived(callSelectAnswerContent: CallSelectAnswerContent) {
sessionScope?.launch(dispatcher) {
val selectedPartyId = callSelectAnswerContent.selectedPartyId
if (selectedPartyId != mxCall.ourPartyId) {
Timber.i("Got select_answer for party ID $selectedPartyId: we are party ID ${mxCall.ourPartyId}.")
// The other party has picked somebody else's answer
terminate()
}
}
}
fun onCallAssertedIdentityReceived(callAssertedIdentityContent: CallAssertedIdentityContent) { fun onCallAssertedIdentityReceived(callAssertedIdentityContent: CallAssertedIdentityContent) {
sessionScope?.launch(dispatcher) { sessionScope?.launch(dispatcher) {
val session = sessionProvider.get() ?: return@launch val session = sessionProvider.get() ?: return@launch

View File

@ -21,7 +21,12 @@ import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
fun WebRtcCall.getOpponentAsMatrixItem(session: Session): MatrixItem? { fun WebRtcCall.getOpponentAsMatrixItem(session: Session): MatrixItem? {
return session.getRoomSummary(nativeRoomId)?.otherMemberIds?.firstOrNull()?.let { return session.getRoomSummary(nativeRoomId)?.let { roomSummary ->
session.getUser(it)?.toMatrixItem() // Fallback to RoomSummary if there is no other member.
if (roomSummary.otherMemberIds.isEmpty()) {
roomSummary.toMatrixItem()
} else {
roomSummary.otherMemberIds.first().let { session.getUser(it)?.toMatrixItem() }
}
} }
} }

View File

@ -29,7 +29,9 @@ import im.vector.app.features.call.lookup.CallProtocolsChecker
import im.vector.app.features.call.lookup.CallUserMapper import im.vector.app.features.call.lookup.CallUserMapper
import im.vector.app.features.call.utils.EglUtils import im.vector.app.features.call.utils.EglUtils
import im.vector.app.features.call.vectorCallService import im.vector.app.features.call.vectorCallService
import im.vector.app.features.session.coroutineScope
import im.vector.app.push.fcm.FcmHelper import im.vector.app.push.fcm.FcmHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
@ -45,6 +47,7 @@ import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent
import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent
import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent
import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.EndCallReason
import org.webrtc.DefaultVideoDecoderFactory import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.DefaultVideoEncoderFactory import org.webrtc.DefaultVideoEncoderFactory
import org.webrtc.PeerConnectionFactory import org.webrtc.PeerConnectionFactory
@ -75,6 +78,9 @@ class WebRtcCallManager @Inject constructor(
private val callUserMapper: CallUserMapper? private val callUserMapper: CallUserMapper?
get() = currentSession?.vectorCallService?.userMapper get() = currentSession?.vectorCallService?.userMapper
private val sessionScope: CoroutineScope?
get() = currentSession?.coroutineScope
interface CurrentCallListener { interface CurrentCallListener {
fun onCurrentCallChange(call: WebRtcCall?) {} fun onCurrentCallChange(call: WebRtcCall?) {}
fun onAudioDevicesChange() {} fun onAudioDevicesChange() {}
@ -232,12 +238,12 @@ class WebRtcCallManager @Inject constructor(
this.currentCall.setAndNotify(call) this.currentCall.setAndNotify(call)
} }
private fun onCallEnded(callId: String) { private fun onCallEnded(callId: String, endCallReason: EndCallReason, rejected: Boolean) {
Timber.v("## VOIP WebRtcPeerConnectionManager onCall ended: $callId") Timber.v("## VOIP WebRtcPeerConnectionManager onCall ended: $callId")
val webRtcCall = callsByCallId.remove(callId) ?: return Unit.also { val webRtcCall = callsByCallId.remove(callId) ?: return Unit.also {
Timber.v("On call ended for unknown call $callId") Timber.v("On call ended for unknown call $callId")
} }
CallService.onCallTerminated(context, callId) CallService.onCallTerminated(context, callId, endCallReason, rejected)
callsByRoomId[webRtcCall.signalingRoomId]?.remove(webRtcCall) callsByRoomId[webRtcCall.signalingRoomId]?.remove(webRtcCall)
callsByRoomId[webRtcCall.nativeRoomId]?.remove(webRtcCall) callsByRoomId[webRtcCall.nativeRoomId]?.remove(webRtcCall)
transferees.remove(callId) transferees.remove(callId)
@ -329,8 +335,8 @@ class WebRtcCallManager @Inject constructor(
return webRtcCall return webRtcCall
} }
fun endCallForRoom(roomId: String, originatedByMe: Boolean = true) { fun endCallForRoom(roomId: String) {
callsByRoomId[roomId]?.firstOrNull()?.endCall(originatedByMe) callsByRoomId[roomId]?.firstOrNull()?.endCall()
} }
override fun onCallInviteReceived(mxCall: MxCall, callInviteContent: CallInviteContent) { override fun onCallInviteReceived(mxCall: MxCall, callInviteContent: CallInviteContent) {
@ -386,7 +392,7 @@ class WebRtcCallManager @Inject constructor(
?: return Unit.also { ?: return Unit.also {
Timber.w("onCallHangupReceived for non active call? ${callHangupContent.callId}") Timber.w("onCallHangupReceived for non active call? ${callHangupContent.callId}")
} }
call.endCall(false) call.onCallHangupReceived(callHangupContent)
} }
override fun onCallRejectReceived(callRejectContent: CallRejectContent) { override fun onCallRejectReceived(callRejectContent: CallRejectContent) {
@ -394,7 +400,7 @@ class WebRtcCallManager @Inject constructor(
?: return Unit.also { ?: return Unit.also {
Timber.w("onCallRejectReceived for non active call? ${callRejectContent.callId}") Timber.w("onCallRejectReceived for non active call? ${callRejectContent.callId}")
} }
call.endCall(false) call.onCallRejectReceived(callRejectContent)
} }
override fun onCallSelectAnswerReceived(callSelectAnswerContent: CallSelectAnswerContent) { override fun onCallSelectAnswerReceived(callSelectAnswerContent: CallSelectAnswerContent) {
@ -402,12 +408,7 @@ class WebRtcCallManager @Inject constructor(
?: return Unit.also { ?: return Unit.also {
Timber.w("onCallSelectAnswerReceived for non active call? ${callSelectAnswerContent.callId}") Timber.w("onCallSelectAnswerReceived for non active call? ${callSelectAnswerContent.callId}")
} }
val selectedPartyId = callSelectAnswerContent.selectedPartyId call.onCallSelectedAnswerReceived(callSelectAnswerContent)
if (selectedPartyId != call.mxCall.ourPartyId) {
Timber.i("Got select_answer for party ID $selectedPartyId: we are party ID ${call.mxCall.ourPartyId}.")
// The other party has picked somebody else's answer
call.endCall(false)
}
} }
override fun onCallNegotiateReceived(callNegotiateContent: CallNegotiateContent) { override fun onCallNegotiateReceived(callNegotiateContent: CallNegotiateContent) {
@ -420,7 +421,7 @@ class WebRtcCallManager @Inject constructor(
override fun onCallManagedByOtherSession(callId: String) { override fun onCallManagedByOtherSession(callId: String) {
Timber.v("## VOIP onCallManagedByOtherSession: $callId") Timber.v("## VOIP onCallManagedByOtherSession: $callId")
onCallEnded(callId) onCallEnded(callId, EndCallReason.ANSWERED_ELSEWHERE, false)
} }
override fun onCallAssertedIdentityReceived(callAssertedIdentityContent: CallAssertedIdentityContent) { override fun onCallAssertedIdentityReceived(callAssertedIdentityContent: CallAssertedIdentityContent) {

View File

@ -48,6 +48,7 @@ import androidx.fragment.app.Fragment
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.core.services.CallService
import im.vector.app.core.utils.startNotificationChannelSettingsIntent import im.vector.app.core.utils.startNotificationChannelSettingsIntent
import im.vector.app.features.call.VectorCallActivity import im.vector.app.features.call.VectorCallActivity
import im.vector.app.features.call.service.CallHeadsUpActionReceiver import im.vector.app.features.call.service.CallHeadsUpActionReceiver
@ -298,12 +299,14 @@ class NotificationUtils @Inject constructor(private val context: Context,
.apply { .apply {
if (call.mxCall.isVideoCall) { if (call.mxCall.isVideoCall) {
setContentText(stringProvider.getString(R.string.incoming_video_call)) setContentText(stringProvider.getString(R.string.incoming_video_call))
setSmallIcon(R.drawable.ic_call_answer_video)
} else { } else {
setContentText(stringProvider.getString(R.string.incoming_voice_call)) setContentText(stringProvider.getString(R.string.incoming_voice_call))
setSmallIcon(R.drawable.ic_call_answer)
} }
} }
.setSmallIcon(R.drawable.incoming_call_notification_transparent)
.setCategory(NotificationCompat.CATEGORY_CALL) .setCategory(NotificationCompat.CATEGORY_CALL)
.setColor(ThemeUtils.getColor(context, android.R.attr.colorPrimary))
.setLights(accentColor, 500, 500) .setLights(accentColor, 500, 500)
.setOngoing(true) .setOngoing(true)
@ -339,8 +342,6 @@ class NotificationUtils @Inject constructor(private val context: Context,
builder.addAction( builder.addAction(
NotificationCompat.Action( NotificationCompat.Action(
R.drawable.ic_call_answer, R.drawable.ic_call_answer,
// IconCompat.createWithResource(applicationContext, R.drawable.ic_call)
// .setTint(ContextCompat.getColor(applicationContext, R.color.vctr_positive_accent)),
getActionText(R.string.call_notification_answer, R.attr.colorPrimary), getActionText(R.string.call_notification_answer, R.attr.colorPrimary),
answerCallPendingIntent answerCallPendingIntent
) )
@ -360,10 +361,15 @@ class NotificationUtils @Inject constructor(private val context: Context,
.setContentTitle(ensureTitleNotEmpty(title)) .setContentTitle(ensureTitleNotEmpty(title))
.apply { .apply {
setContentText(stringProvider.getString(R.string.call_ring)) setContentText(stringProvider.getString(R.string.call_ring))
if (call.mxCall.isVideoCall) {
setSmallIcon(R.drawable.ic_call_answer_video)
} else {
setSmallIcon(R.drawable.ic_call_answer)
}
} }
.setSmallIcon(R.drawable.incoming_call_notification_transparent)
.setCategory(NotificationCompat.CATEGORY_CALL) .setCategory(NotificationCompat.CATEGORY_CALL)
.setLights(accentColor, 500, 500) .setLights(accentColor, 500, 500)
.setColor(ThemeUtils.getColor(context, android.R.attr.colorPrimary))
.setOngoing(true) .setOngoing(true)
val contentIntent = VectorCallActivity.newIntent( val contentIntent = VectorCallActivity.newIntent(
@ -407,11 +413,13 @@ class NotificationUtils @Inject constructor(private val context: Context,
.apply { .apply {
if (call.mxCall.isVideoCall) { if (call.mxCall.isVideoCall) {
setContentText(stringProvider.getString(R.string.video_call_in_progress)) setContentText(stringProvider.getString(R.string.video_call_in_progress))
setSmallIcon(R.drawable.ic_call_answer_video)
} else { } else {
setContentText(stringProvider.getString(R.string.call_in_progress)) setContentText(stringProvider.getString(R.string.call_in_progress))
setSmallIcon(R.drawable.ic_call_answer)
} }
} }
.setSmallIcon(R.drawable.incoming_call_notification_transparent) .setColor(ThemeUtils.getColor(context, android.R.attr.colorPrimary))
.setCategory(NotificationCompat.CATEGORY_CALL) .setCategory(NotificationCompat.CATEGORY_CALL)
val rejectCallPendingIntent = buildRejectCallPendingIntent(call.callId) val rejectCallPendingIntent = buildRejectCallPendingIntent(call.callId)
@ -450,15 +458,51 @@ class NotificationUtils @Inject constructor(private val context: Context,
/** /**
* Build a temporary (because service will be stopped just after) notification for the CallService, when a call is ended * Build a temporary (because service will be stopped just after) notification for the CallService, when a call is ended
*/ */
fun buildCallEndedNotification(): Notification { fun buildCallEndedNotification(isVideoCall: Boolean): Notification {
return NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID) return NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID)
.setContentTitle(stringProvider.getString(R.string.call_ended)) .setContentTitle(stringProvider.getString(R.string.call_ended))
.apply {
if (isVideoCall) {
setSmallIcon(R.drawable.ic_call_answer_video)
} else {
setSmallIcon(R.drawable.ic_call_answer)
}
}
.setTimeoutAfter(2000) .setTimeoutAfter(2000)
.setSmallIcon(R.drawable.ic_material_call_end_grey) .setColor(ThemeUtils.getColor(context, android.R.attr.colorPrimary))
.setCategory(NotificationCompat.CATEGORY_CALL) .setCategory(NotificationCompat.CATEGORY_CALL)
.build() .build()
} }
/**
* Build notification for the CallService, when a call is missed
*/
fun buildCallMissedNotification(callInformation: CallService.CallInformation): Notification {
val builder = NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID)
.setContentTitle(callInformation.opponentMatrixItem?.getBestName() ?: callInformation.opponentUserId)
.apply {
if (callInformation.isVideoCall) {
setContentText(stringProvider.getQuantityString(R.plurals.missed_video_call, 1, 1))
setSmallIcon(R.drawable.ic_missed_video_call)
} else {
setContentText(stringProvider.getQuantityString(R.plurals.missed_audio_call, 1, 1))
setSmallIcon(R.drawable.ic_missed_voice_call)
}
}
.setShowWhen(true)
.setColor(ThemeUtils.getColor(context, android.R.attr.colorPrimary))
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
val contentPendingIntent = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(HomeActivity.newIntent(context))
.addNextIntent(RoomDetailActivity.newIntent(context, RoomDetailArgs(callInformation.nativeRoomId)))
.getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT)
builder.setContentIntent(contentPendingIntent)
return builder.build()
}
fun buildDownloadFileNotification(uri: Uri, fileName: String, mimeType: String): Notification { fun buildDownloadFileNotification(uri: Uri, fileName: String, mimeType: String): Notification {
return NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID) return NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID)
.setGroup(stringProvider.getString(R.string.app_name)) .setGroup(stringProvider.getString(R.string.app_name))

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="16dp"
android:viewportWidth="24"
android:viewportHeight="16">
<path
android:pathData="M0,3.2273C0,1.6457 1.3432,0.3636 3,0.3636H14C15.6569,0.3636 17,1.6457 17,3.2273V12.7727C17,14.3543 15.6569,15.6364 14,15.6364H3C1.3431,15.6364 0,14.3543 0,12.7727V3.2273ZM19,5.1364L22.3753,2.5589C23.0301,2.0589 24,2.5038 24,3.3042V12.6958C24,13.4962 23.0301,13.9412 22.3753,13.4412L19,10.8637V5.1364ZM5.5288,8.8219C5.5288,9.2423 5.1848,9.5863 4.7644,9.5863C4.344,9.5863 4,9.2423 4,8.8219V5.7644C4,5.344 4.344,5 4.7644,5H7.8219C8.2423,5 8.5863,5.344 8.5863,5.7644C8.5863,6.1848 8.2423,6.5288 7.8219,6.5288H6.5989L9.3125,9.2423L13.0961,5.4586C13.3942,5.1605 13.8758,5.1605 14.1739,5.4586C14.472,5.7567 14.472,6.2383 14.1739,6.5364L9.8475,10.8628C9.5494,11.1609 9.0679,11.1609 8.7697,10.8628L5.5288,7.6218V8.8219Z"
android:fillColor="#737D8C"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,4 @@
<vector android:height="10.666667dp" android:viewportHeight="16"
android:viewportWidth="24" android:width="16dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#737D8C" android:fillType="evenOdd" android:pathData="M0,3.2273C0,1.6457 1.3432,0.3636 3,0.3636H14C15.6569,0.3636 17,1.6457 17,3.2273V12.7727C17,14.3543 15.6569,15.6364 14,15.6364H3C1.3431,15.6364 0,14.3543 0,12.7727V3.2273ZM19,5.1364L22.3753,2.5589C23.0301,2.0589 24,2.5038 24,3.3042V12.6958C24,13.4962 23.0301,13.9412 22.3753,13.4412L19,10.8637V5.1364ZM5.5288,8.8219C5.5288,9.2423 5.1848,9.5863 4.7644,9.5863C4.344,9.5863 4,9.2423 4,8.8219V5.7644C4,5.344 4.344,5 4.7644,5H7.8219C8.2423,5 8.5863,5.344 8.5863,5.7644C8.5863,6.1848 8.2423,6.5288 7.8219,6.5288H6.5989L9.3125,9.2423L13.0961,5.4586C13.3942,5.1605 13.8758,5.1605 14.1739,5.4586C14.472,5.7567 14.472,6.2383 14.1739,6.5364L9.8475,10.8628C9.5494,11.1609 9.0679,11.1609 8.7697,10.8628L5.5288,7.6218V8.8219Z"/>
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,9C6.55,9 7,8.55 7,8V6.43L11.24,10.67C11.63,11.06 12.26,11.06 12.65,10.67L18.31,5.01C18.7,4.62 18.7,3.99 18.31,3.6C17.92,3.21 17.29,3.21 16.9,3.6L11.95,8.55L8.4,5H10C10.55,5 11,4.55 11,4C11,3.45 10.55,3 10,3H6C5.45,3 5,3.45 5,4V8C5,8.55 5.45,9 6,9Z"
android:fillColor="#818A98"/>
<path
android:pathData="M12.0084,13.0065C10.3211,12.9416 6.8514,13.3795 6.0078,13.6013C5.9579,13.6144 5.9004,13.6291 5.8362,13.6455C4.541,13.9761 0.4827,15.0118 0.0442,18.2936C-0.2955,20.8362 1.4058,21.6058 2.2562,21.4886C2.8448,21.4148 4.5301,21.1483 6.0872,20.8689C7.6163,20.5946 7.6155,19.5859 7.615,18.9038C7.615,18.8913 7.615,18.8788 7.615,18.8665L7.615,17.4953C7.615,17.1461 7.9432,16.9442 8.3958,16.8896C9.9982,16.672 11.3359,16.6713 12.0055,16.6713L12.0112,16.6713C12.6807,16.6713 14.0018,16.672 15.6042,16.8896C16.0569,16.9442 16.385,17.1461 16.385,17.4953L16.385,18.8665C16.385,18.8789 16.385,18.8913 16.385,18.9038C16.3845,19.5859 16.3837,20.5946 17.9128,20.869C19.4699,21.1483 21.1552,21.4148 21.7438,21.4886C22.5942,21.6058 24.2955,20.8362 23.9558,18.2936C23.5173,15.0118 19.459,13.9761 18.1638,13.6455C18.0996,13.6291 18.0421,13.6145 17.9922,13.6013C17.1487,13.3795 13.6956,12.9416 12.0084,13.0065Z"
android:fillColor="#818A98"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="16dp" android:viewportHeight="24"
android:viewportWidth="24" android:width="16dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#818A98" android:pathData="M6,9C6.55,9 7,8.55 7,8V6.43L11.24,10.67C11.63,11.06 12.26,11.06 12.65,10.67L18.31,5.01C18.7,4.62 18.7,3.99 18.31,3.6C17.92,3.21 17.29,3.21 16.9,3.6L11.95,8.55L8.4,5H10C10.55,5 11,4.55 11,4C11,3.45 10.55,3 10,3H6C5.45,3 5,3.45 5,4V8C5,8.55 5.45,9 6,9Z"/>
<path android:fillColor="#818A98" android:pathData="M12.0084,13.0065C10.3211,12.9416 6.8514,13.3795 6.0078,13.6013C5.9579,13.6144 5.9004,13.6291 5.8362,13.6455C4.541,13.9761 0.4827,15.0118 0.0442,18.2936C-0.2955,20.8362 1.4058,21.6058 2.2562,21.4886C2.8448,21.4148 4.5301,21.1483 6.0872,20.8689C7.6163,20.5946 7.6155,19.5859 7.615,18.9038C7.615,18.8913 7.615,18.8788 7.615,18.8665L7.615,17.4953C7.615,17.1461 7.9432,16.9442 8.3958,16.8896C9.9982,16.672 11.3359,16.6713 12.0055,16.6713L12.0112,16.6713C12.6807,16.6713 14.0018,16.672 15.6042,16.8896C16.0569,16.9442 16.385,17.1461 16.385,17.4953L16.385,18.8665C16.385,18.8789 16.385,18.8913 16.385,18.9038C16.3845,19.5859 16.3837,20.5946 17.9128,20.869C19.4699,21.1483 21.1552,21.4148 21.7438,21.4886C22.5942,21.6058 24.2955,20.8362 23.9558,18.2936C23.5173,15.0118 19.459,13.9761 18.1638,13.6455C18.0996,13.6291 18.0421,13.6145 17.9922,13.6013C17.1487,13.3795 13.6956,12.9416 12.0084,13.0065Z"/>
</vector>

View File

@ -727,6 +727,14 @@
<string name="call_connected">Call connected</string> <string name="call_connected">Call connected</string>
<string name="call_connecting">Call connecting…</string> <string name="call_connecting">Call connecting…</string>
<string name="call_ended">Call ended</string> <string name="call_ended">Call ended</string>
<plurals name="missed_audio_call">
<item quantity="one">Missed audio call</item>
<item quantity="other">%d missed audio calls</item>
</plurals>
<plurals name="missed_video_call">
<item quantity="one">Missed video call</item>
<item quantity="other">%d missed video calls</item>
</plurals>
<string name="call_ring">Calling…</string> <string name="call_ring">Calling…</string>
<string name="incoming_call">Incoming Call</string> <string name="incoming_call">Incoming Call</string>
<string name="incoming_video_call">Incoming Video Call</string> <string name="incoming_video_call">Incoming Video Call</string>