Merge pull request from vector-im/feature/fga/voip_v1_start

VoIP v1 implementation
This commit is contained in:
Benoit Marty 2021-02-10 11:18:38 +01:00 committed by GitHub
commit 1b210d42ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
202 changed files with 7347 additions and 3206 deletions
CHANGES.md
gradle/wrapper
matrix-sdk-android
tools/check
vector

View file

@ -2,19 +2,19 @@ Changes in Element 1.0.18 (2020-XX-XX)
=================================================== ===================================================
Features ✨: Features ✨:
- - VoIP : support for VoIP V1 protocol, transfer call and dial-pad
Improvements 🙌: Improvements 🙌:
- - VoIP : new tiles in timeline
Bugfix 🐛: Bugfix 🐛:
- - VoIP : fix audio devices output
Translations 🗣: Translations 🗣:
- -
SDK API changes ⚠️: SDK API changes ⚠️:
- -
Build 🧱: Build 🧱:
- -

View file

@ -1,3 +1,4 @@
#Fri Jan 29 18:05:42 CET 2021
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=1433372d903ffba27496f8d5af24265310d2da0d78bf6b4e5138831d4fe066e9 distributionSha256Sum=1433372d903ffba27496f8d5af24265310d2da0d78bf6b4e5138831d4fe066e9

View file

@ -168,12 +168,6 @@ dependencies {
// Phone number https://github.com/google/libphonenumber // Phone number https://github.com/google/libphonenumber
implementation 'com.googlecode.libphonenumber:libphonenumber:8.10.23' implementation 'com.googlecode.libphonenumber:libphonenumber:8.10.23'
// Web RTC
// org.webrtc:google-webrtc is for development purposes only. See http://webrtc.github.io/webrtc-org/native-code/android/
// implementation 'org.webrtc:google-webrtc:1.0.+'
// Use the same WebRTC library than the one used by Jitsi library
implementation('com.facebook.react:react-native-webrtc:1.84.0-jitsi-5112273@aar')
testImplementation 'junit:junit:4.13' testImplementation 'junit:junit:4.13'
testImplementation 'org.robolectric:robolectric:4.3' testImplementation 'org.robolectric:robolectric:4.3'
//testImplementation 'org.robolectric:shadows-support-v4:3.0' //testImplementation 'org.robolectric:shadows-support-v4:3.0'

View file

@ -35,7 +35,11 @@ data class MatrixConfiguration(
* Optional proxy to connect to the matrix servers * Optional proxy to connect to the matrix servers
* You can create one using for instance Proxy(proxyType, InetSocketAddress.createUnresolved(hostname, port) * You can create one using for instance Proxy(proxyType, InetSocketAddress.createUnresolved(hostname, port)
*/ */
val proxy: Proxy? = null val proxy: Proxy? = null,
/**
* True to advertise support for call transfers to other parties on Matrix calls.
*/
val supportsCallTransfer: Boolean = false
) { ) {
/** /**

View file

@ -48,6 +48,7 @@ import org.matrix.android.sdk.api.session.signout.SignOutService
import org.matrix.android.sdk.api.session.sync.FilterService import org.matrix.android.sdk.api.session.sync.FilterService
import org.matrix.android.sdk.api.session.sync.SyncState import org.matrix.android.sdk.api.session.sync.SyncState
import org.matrix.android.sdk.api.session.terms.TermsService import org.matrix.android.sdk.api.session.terms.TermsService
import org.matrix.android.sdk.api.session.thirdparty.ThirdPartyService
import org.matrix.android.sdk.api.session.typing.TypingUsersTracker import org.matrix.android.sdk.api.session.typing.TypingUsersTracker
import org.matrix.android.sdk.api.session.user.UserService import org.matrix.android.sdk.api.session.user.UserService
import org.matrix.android.sdk.api.session.widgets.WidgetService import org.matrix.android.sdk.api.session.widgets.WidgetService
@ -212,6 +213,11 @@ interface Session :
*/ */
fun searchService(): SearchService fun searchService(): SearchService
/**
* Returns the third party service associated with the session
*/
fun thirdPartyService(): ThirdPartyService
/** /**
* Add a listener to the session. * Add a listener to the session.
* @param listener the listener to add. * @param listener the listener to add.

View file

@ -20,8 +20,11 @@ import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent 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.CallRejectContent
import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent
interface CallsListener { interface CallListener {
/** /**
* Called when there is an incoming call within the room. * Called when there is an incoming call within the room.
*/ */
@ -39,5 +42,23 @@ interface CallsListener {
*/ */
fun onCallHangupReceived(callHangupContent: CallHangupContent) fun onCallHangupReceived(callHangupContent: CallHangupContent)
/**
* Called when a called has been rejected
*/
fun onCallRejectReceived(callRejectContent: CallRejectContent)
/**
* Called when an answer has been selected
*/
fun onCallSelectAnswerReceived(callSelectAnswerContent: CallSelectAnswerContent)
/**
* Called when a negotiation is sent
*/
fun onCallNegotiateReceived(callNegotiateContent: CallNegotiateContent)
/**
* Called when the call has been managed by an other session
*/
fun onCallManagedByOtherSession(callId: String) fun onCallManagedByOtherSession(callId: String)
} }

View file

@ -28,9 +28,9 @@ interface CallSignalingService {
*/ */
fun createOutgoingCall(roomId: String, otherUserId: String, isVideoCall: Boolean): MxCall fun createOutgoingCall(roomId: String, otherUserId: String, isVideoCall: Boolean): MxCall
fun addCallListener(listener: CallsListener) fun addCallListener(listener: CallListener)
fun removeCallListener(listener: CallsListener) fun removeCallListener(listener: CallListener)
fun getCallWithId(callId: String): MxCall? fun getCallWithId(callId: String): MxCall?

View file

@ -16,13 +16,16 @@
package org.matrix.android.sdk.api.session.call package org.matrix.android.sdk.api.session.call
import org.webrtc.PeerConnection
sealed class CallState { sealed class CallState {
/** Idle, setting up objects */ /** Idle, setting up objects */
object Idle : CallState() object Idle : CallState()
/**
* CreateOffer. Intermediate state between Idle and Dialing.
*/
object CreateOffer: CallState()
/** Dialing. Outgoing call is signaling the remote peer */ /** Dialing. Outgoing call is signaling the remote peer */
object Dialing : CallState() object Dialing : CallState()
@ -36,8 +39,8 @@ sealed class CallState {
* Connected. Incoming/Outgoing call, ice layer connecting or connected * Connected. Incoming/Outgoing call, ice layer connecting or connected
* Notice that the PeerState failed is not always final, if you switch network, new ice candidtates * Notice that the PeerState failed is not always final, if you switch network, new ice candidtates
* could be exchanged, and the connection could go back to connected * could be exchanged, and the connection could go back to connected
*/ * */
data class Connected(val iceConnectionState: PeerConnection.PeerConnectionState) : CallState() data class Connected(val iceConnectionState: MxPeerConnectionState) : CallState()
/** Terminated. Incoming/Outgoing call, the call is terminated */ /** Terminated. Incoming/Outgoing call, the call is terminated */
object Terminated : CallState() object Terminated : CallState()

View file

@ -16,14 +16,17 @@
package org.matrix.android.sdk.api.session.call package org.matrix.android.sdk.api.session.call
import org.webrtc.IceCandidate import org.matrix.android.sdk.api.session.room.model.call.CallCandidate
import org.webrtc.SessionDescription 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.SdpType
import org.matrix.android.sdk.api.util.Optional
interface MxCallDetail { interface MxCallDetail {
val callId: String val callId: String
val isOutgoing: Boolean val isOutgoing: Boolean
val roomId: String val roomId: String
val otherUserId: String val opponentUserId: String
val isVideoCall: Boolean val isVideoCall: Boolean
} }
@ -32,40 +35,64 @@ interface MxCallDetail {
*/ */
interface MxCall : MxCallDetail { interface MxCall : MxCallDetail {
companion object {
const val VOIP_PROTO_VERSION = 1
}
val ourPartyId: String
var opponentPartyId: Optional<String>?
var opponentVersion: Int
var capabilities: CallCapabilities?
var state: CallState var state: CallState
/** /**
* Pick Up the incoming call * Pick Up the incoming call
* It has no effect on outgoing call * It has no effect on outgoing call
*/ */
fun accept(sdp: SessionDescription) fun accept(sdpString: String)
/**
* SDP negotiation for media pause, hold/resume, ICE restarts and voice/video call up/downgrading
*/
fun negotiate(sdpString: String, type: SdpType)
/**
* This has to be sent by the caller's client once it has chosen an answer.
*/
fun selectAnswer()
/** /**
* Reject an incoming call * Reject an incoming call
* It's an alias to hangUp
*/ */
fun reject() = hangUp() fun reject()
/** /**
* End the call * End the call
*/ */
fun hangUp() fun hangUp(reason: CallHangupContent.Reason? = null)
/** /**
* Start a call * Start a call
* Send offer SDP to the other participant. * Send offer SDP to the other participant.
*/ */
fun offerSdp(sdp: SessionDescription) fun offerSdp(sdpString: String)
/** /**
* Send Ice candidate to the other participant. * Send Call candidate to the other participant.
*/ */
fun sendLocalIceCandidates(candidates: List<IceCandidate>) fun sendLocalCallCandidates(candidates: List<CallCandidate>)
/** /**
* Send removed ICE candidates to the other participant. * Send removed ICE candidates to the other participant.
*/ */
fun sendLocalIceCandidateRemovals(candidates: List<IceCandidate>) fun sendLocalIceCandidateRemovals(candidates: List<CallCandidate>)
/**
* Send a m.call.replaces event to initiate call transfer.
*/
suspend fun transfer(targetUserId: String, targetRoomId: String?)
fun addListener(listener: StateListener) fun addListener(listener: StateListener)
fun removeListener(listener: StateListener) fun removeListener(listener: StateListener)

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2020 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.call;
/**
* This is a copy of https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState
* to avoid having the dependency over WebRtc library on sdk.
*/
public enum MxPeerConnectionState {
NEW,
CONNECTING,
CONNECTED,
DISCONNECTED,
FAILED,
CLOSED
}

View file

@ -68,7 +68,12 @@ object EventType {
const val CALL_INVITE = "m.call.invite" const val CALL_INVITE = "m.call.invite"
const val CALL_CANDIDATES = "m.call.candidates" const val CALL_CANDIDATES = "m.call.candidates"
const val CALL_ANSWER = "m.call.answer" const val CALL_ANSWER = "m.call.answer"
const val CALL_SELECT_ANSWER = "m.call.select_answer"
const val CALL_NEGOTIATE = "m.call.negotiate"
const val CALL_REJECT = "m.call.reject"
const val CALL_HANGUP = "m.call.hangup" const val CALL_HANGUP = "m.call.hangup"
// This type is not processed by the client, just sent to the server
const val CALL_REPLACES = "m.call.replaces"
// Key share events // Key share events
const val ROOM_KEY_REQUEST = "m.room_key_request" const val ROOM_KEY_REQUEST = "m.room_key_request"
@ -98,5 +103,9 @@ object EventType {
|| type == CALL_CANDIDATES || type == CALL_CANDIDATES
|| type == CALL_ANSWER || type == CALL_ANSWER
|| type == CALL_HANGUP || type == CALL_HANGUP
|| type == CALL_SELECT_ANSWER
|| type == CALL_NEGOTIATE
|| type == CALL_REJECT
|| type == CALL_REPLACES
} }
} }

View file

@ -20,7 +20,6 @@ import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsResponse import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsResponse
import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol
import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.Cancelable
/** /**
@ -35,12 +34,6 @@ interface RoomDirectoryService {
publicRoomsParams: PublicRoomsParams, publicRoomsParams: PublicRoomsParams,
callback: MatrixCallback<PublicRoomsResponse>): Cancelable callback: MatrixCallback<PublicRoomsResponse>): Cancelable
/**
* Fetches the overall metadata about protocols supported by the homeserver.
* Includes both the available protocols and all fields required for queries against each protocol.
*/
fun getThirdPartyProtocol(callback: MatrixCallback<Map<String, ThirdPartyProtocol>>): Cancelable
/** /**
* Get the visibility of a room in the directory * Get the visibility of a room in the directory
*/ */

View file

@ -27,16 +27,24 @@ data class CallAnswerContent(
/** /**
* Required. The ID of the call this event relates to. * Required. The ID of the call this event relates to.
*/ */
@Json(name = "call_id") val callId: String, @Json(name = "call_id") override val callId: String,
/**
* Required. ID to let user identify remote echo of their own events
*/
@Json(name = "party_id") override val partyId: String? = null,
/** /**
* Required. The session description object * Required. The session description object
*/ */
@Json(name = "answer") val answer: Answer, @Json(name = "answer") val answer: Answer,
/** /**
* Required. The version of the VoIP specification this messages adheres to. This specification is version 0. * Required. The version of the VoIP specification this messages adheres to.
*/ */
@Json(name = "version") val version: Int = 0 @Json(name = "version") override val version: String?,
) { /**
* Capability advertisement.
*/
@Json(name = "capabilities") val capabilities: CallCapabilities? = null
): CallSignallingContent {
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class Answer( data class Answer(

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2020 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 = true)
data class CallCandidate(
/**
* Required. The SDP media type this candidate is intended for.
*/
@Json(name = "sdpMid") val sdpMid: String? = null,
/**
* Required. The index of the SDP 'm' line this candidate is intended for.
*/
@Json(name = "sdpMLineIndex") val sdpMLineIndex: Int = 0,
/**
* Required. The SDP 'a' line of the candidate.
*/
@Json(name = "candidate") val candidate: String? = null
)

View file

@ -28,30 +28,17 @@ data class CallCandidatesContent(
/** /**
* Required. The ID of the call this event relates to. * Required. The ID of the call this event relates to.
*/ */
@Json(name = "call_id") val callId: String, @Json(name = "call_id") override val callId: String,
/**
* Required. ID to let user identify remote echo of their own events
*/
@Json(name = "party_id") override val partyId: String? = null,
/** /**
* Required. Array of objects describing the candidates. * Required. Array of objects describing the candidates.
*/ */
@Json(name = "candidates") val candidates: List<Candidate> = emptyList(), @Json(name = "candidates") val candidates: List<CallCandidate> = emptyList(),
/** /**
* Required. The version of the VoIP specification this messages adheres to. This specification is version 0. * Required. The version of the VoIP specification this messages adheres to.
*/ */
@Json(name = "version") val version: Int = 0 @Json(name = "version") override val version: String?
) { ): CallSignallingContent
@JsonClass(generateAdapter = true)
data class Candidate(
/**
* Required. The SDP media type this candidate is intended for.
*/
@Json(name = "sdpMid") val sdpMid: String,
/**
* Required. The index of the SDP 'm' line this candidate is intended for.
*/
@Json(name = "sdpMLineIndex") val sdpMLineIndex: Int,
/**
* Required. The SDP 'a' line of the candidate.
*/
@Json(name = "candidate") val candidate: String
)
}

View file

@ -0,0 +1,32 @@
/*
* Copyright 2020 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
import org.matrix.android.sdk.api.extensions.orFalse
@JsonClass(generateAdapter = true)
data class CallCapabilities(
/**
* If set to true, states that the sender of the event supports the m.call.replaces event and therefore supports
* being transferred to another destination
*/
@Json(name = "m.call.transferee") val transferee: Boolean? = null
)
fun CallCapabilities?.supportCallTransfer() = this?.transferee.orFalse()

View file

@ -28,24 +28,41 @@ data class CallHangupContent(
/** /**
* Required. The ID of the call this event relates to. * Required. The ID of the call this event relates to.
*/ */
@Json(name = "call_id") val callId: String, @Json(name = "call_id") override val callId: String,
/** /**
* Required. The version of the VoIP specification this message adheres to. This specification is version 0. * Required. ID to let user identify remote echo of their own events
*/ */
@Json(name = "version") val version: Int = 0, @Json(name = "party_id") override val partyId: String? = null,
/**
* Required. The version of the VoIP specification this message adheres to.
*/
@Json(name = "version") override val version: String?,
/** /**
* Optional error reason for the hangup. This should not be provided when the user naturally ends or rejects the call. * Optional error reason for the hangup. This should not be provided when the user naturally ends or rejects the call.
* When there was an error in the call negotiation, this should be `ice_failed` for when ICE negotiation fails * When there was an error in the call negotiation, this should be `ice_failed` for when ICE negotiation fails
* or `invite_timeout` for when the other party did not answer in time. One of: ["ice_failed", "invite_timeout"] * or `invite_timeout` for when the other party did not answer in time.
* One of: ["ice_failed", "invite_timeout"]
*/ */
@Json(name = "reason") val reason: Reason? = null @Json(name = "reason") val reason: Reason? = null
) { ) : CallSignallingContent {
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
enum class Reason { enum class Reason {
@Json(name = "ice_failed") @Json(name = "ice_failed")
ICE_FAILED, ICE_FAILED,
@Json(name = "ice_timeout")
ICE_TIMEOUT,
@Json(name = "user_hangup")
USER_HANGUP,
@Json(name = "user_media_failed")
USER_MEDIA_FAILED,
@Json(name = "invite_timeout") @Json(name = "invite_timeout")
INVITE_TIMEOUT INVITE_TIMEOUT,
@Json(name = "unknown_error")
UNKWOWN_ERROR
} }
} }

View file

@ -27,22 +27,35 @@ data class CallInviteContent(
/** /**
* Required. A unique identifier for the call. * Required. A unique identifier for the call.
*/ */
@Json(name = "call_id") val callId: String?, @Json(name = "call_id") override val callId: String?,
/**
* Required. ID to let user identify remote echo of their own events
*/
@Json(name = "party_id") override val partyId: String? = null,
/** /**
* Required. The session description object * Required. The session description object
*/ */
@Json(name = "offer") val offer: Offer?, @Json(name = "offer") val offer: Offer?,
/** /**
* Required. The version of the VoIP specification this message adheres to. This specification is version 0. * Required. The version of the VoIP specification this message adheres to.
*/ */
@Json(name = "version") val version: Int? = 0, @Json(name = "version") override val version: String?,
/** /**
* Required. The time in milliseconds that the invite is valid for. * Required. The time in milliseconds that the invite is valid for.
* Once the invite age exceeds this value, clients should discard it. * Once the invite age exceeds this value, clients should discard it.
* They should also no longer show the call as awaiting an answer in the UI. * They should also no longer show the call as awaiting an answer in the UI.
*/ */
@Json(name = "lifetime") val lifetime: Int? @Json(name = "lifetime") val lifetime: Int?,
) { /**
* The field should be added for all invites where the target is a specific user
*/
@Json(name = "invitee") val invitee: String? = null,
/**
* Capability advertisement.
*/
@Json(name = "capabilities") val capabilities: CallCapabilities? = null
): CallSignallingContent {
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class Offer( data class Offer(
/** /**

View file

@ -0,0 +1,62 @@
/*
* Copyright 2020 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
/**
* This introduces SDP negotiation semantics for media pause, hold/resume, ICE restarts and voice/video call up/downgrading.
*/
@JsonClass(generateAdapter = true)
data class CallNegotiateContent(
/**
* Required. The ID of the call this event relates to.
*/
@Json(name = "call_id") override val callId: String,
/**
* Required. ID to let user identify remote echo of their own events
*/
@Json(name = "party_id") override val partyId: String? = null,
/**
* Required. The time in milliseconds that the negotiation is valid for. Once exceeded the sender
* of the negotiate event should consider the negotiation failed (timed out) and the recipient should ignore it.
**/
@Json(name = "lifetime") val lifetime: Int?,
/**
* Required. The session description object
*/
@Json(name = "description") val description: Description? = null,
/**
* Required. The version of the VoIP specification this message adheres to.
*/
@Json(name = "version") override val version: String?
): CallSignallingContent {
@JsonClass(generateAdapter = true)
data class Description(
/**
* Required. The type of session description.
*/
@Json(name = "type") val type: SdpType?,
/**
* Required. The SDP text of the session description.
*/
@Json(name = "sdp") val sdp: String?
)
}

View file

@ -0,0 +1,40 @@
/*
* Copyright 2020 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
/**
* Sent by either party to signal their termination of the call. This can be sent either once
* the call has been established or before to abort the call.
*/
@JsonClass(generateAdapter = true)
data class CallRejectContent(
/**
* Required. The ID of the call this event relates to.
*/
@Json(name = "call_id") override val callId: String,
/**
* Required. ID to let user identify remote echo of their own events
*/
@Json(name = "party_id") override val partyId: String? = null,
/**
* Required. The version of the VoIP specification this message adheres to.
*/
@Json(name = "version") override val version: String?
) : CallSignallingContent

View file

@ -0,0 +1,82 @@
/*
* Copyright 2020 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
/**
* This event is sent to signal the intent of a participant in a call to replace the call with another,
* such that the other participant ends up in a call with a new user.
*/
@JsonClass(generateAdapter = true)
data class CallReplacesContent(
/**
* Required. The ID of the call this event relates to.
*/
@Json(name = "call_id") override val callId: String,
/**
* Required. ID to let user identify remote echo of their own events
*/
@Json(name = "party_id") override val partyId: String? = null,
/**
* An identifier for the call replacement itself, generated by the transferor.
*/
@Json(name = "replacement_id") val replacementId: String? = null,
/**
* Optional. If specified, the transferee client waits for an invite to this room and joins it
* (possibly waiting for user confirmation) and then continues the transfer in this room.
* If absent, the transferee contacts the Matrix User ID given in the target_user field in a room of its choosing.
*/
@Json(name = "target_room") val targerRoomId: String? = null,
/**
* An object giving information about the transfer target
*/
@Json(name = "target_user") val targetUser: TargetUser? = null,
/**
* If specified, gives the call ID for the transferee's client to use when placing the replacement call.
* Mutually exclusive with await_call
*/
@Json(name = "create_call") val createCall: String? = null,
/**
* If specified, gives the call ID that the transferee's client should wait for.
* Mutually exclusive with create_call.
*/
@Json(name = "await_call") val awaitCall: String? = null,
/**
* Required. The version of the VoIP specification this messages adheres to.
*/
@Json(name = "version") override val version: String?
): CallSignallingContent {
@JsonClass(generateAdapter = true)
data class TargetUser(
/**
* Required. The matrix user ID of the transfer target
*/
@Json(name = "id") val id: String,
/**
* Optional. The display name of the transfer target.
*/
@Json(name = "display_name") val displayName: String?,
/**
* Optional. The avatar URL of the transfer target.
*/
@Json(name = "avatar_url") val avatarUrl: String?
)
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2020 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
/**
* This event is sent by the callee when they wish to answer the call.
*/
@JsonClass(generateAdapter = true)
data class CallSelectAnswerContent(
/**
* Required. The ID of the call this event relates to.
*/
@Json(name = "call_id") override val callId: String,
/**
* Required. ID to let user identify remote echo of their own events
*/
@Json(name = "party_id") override val partyId: String? = null,
/**
* Required. Indicates the answer user has chosen.
*/
@Json(name = "selected_party_id") val selectedPartyId: String? = null,
/**
* Required. The version of the VoIP specification this message adheres to.
*/
@Json(name = "version") override val version: String?
): CallSignallingContent

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2020 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
interface CallSignallingContent {
/**
* Required. A unique identifier for the call.
*/
val callId: String?
/**
* Required. ID to let user identify remote echo of their own events
*/
val partyId: String?
/**
* Required. The version of the VoIP specification this message adheres to. This specification is version 0.
*/
val version: String?
}

View file

@ -25,5 +25,5 @@ enum class SdpType {
OFFER, OFFER,
@Json(name = "answer") @Json(name = "answer")
ANSWER ANSWER;
} }

View file

@ -20,11 +20,15 @@ import org.matrix.android.sdk.api.session.events.model.EventType
object RoomSummaryConstants { object RoomSummaryConstants {
/**
*
*/
val PREVIEWABLE_TYPES = listOf( val PREVIEWABLE_TYPES = listOf(
// TODO filter message type (KEY_VERIFICATION_READY, etc.) // TODO filter message type (KEY_VERIFICATION_READY, etc.)
EventType.MESSAGE, EventType.MESSAGE,
EventType.CALL_INVITE, EventType.CALL_INVITE,
EventType.CALL_HANGUP, EventType.CALL_HANGUP,
EventType.CALL_REJECT,
EventType.CALL_ANSWER, EventType.CALL_ANSWER,
EventType.ENCRYPTED, EventType.ENCRYPTED,
EventType.STICKER, EventType.STICKER,

View file

@ -52,6 +52,8 @@ data class TimelineEvent(
} }
} }
val roomId = root.roomId ?: ""
val metadata = HashMap<String, Any>() val metadata = HashMap<String, Any>()
/** /**

View file

@ -0,0 +1,39 @@
/*
* 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.thirdparty
import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol
import org.matrix.android.sdk.api.session.thirdparty.model.ThirdPartyUser
/**
* See https://matrix.org/docs/spec/client_server/r0.4.0.html#get-matrix-client-r0-thirdparty-protocols
*/
interface ThirdPartyService {
/**
* Fetches the overall metadata about protocols supported by the homeserver.
* Includes both the available protocols and all fields required for queries against each protocol.
*/
suspend fun getThirdPartyProtocols(): Map<String, ThirdPartyProtocol>
/**
* Retrieve a Matrix User ID linked to a user on the third party service, given a set of user parameters.
* @param protocol Required. The name of the protocol.
* @param fields One or more custom fields that are passed to the AS to help identify the user.
*/
suspend fun getThirdPartyUser(protocol: String, fields: Map<String, String> = emptyMap()): List<ThirdPartyUser>
}

View file

@ -0,0 +1,37 @@
/*
* 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.thirdparty.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.util.JsonDict
@JsonClass(generateAdapter = true)
data class ThirdPartyUser(
/*
Required. A Matrix User ID represting a third party user.
*/
@Json(name = "userid") val userId: String,
/*
Required. The protocol ID that the third party location is a part of.
*/
@Json(name = "protocol") val protocol: String,
/*
Required. Information used to identify this third party location.
*/
@Json(name = "fields") val fields: JsonDict
)

View file

@ -71,7 +71,6 @@ internal class EventInsertLiveObserver @Inject constructor(@SessionDatabase real
return@forEach return@forEach
} }
val domainEvent = event.asDomain() val domainEvent = event.asDomain()
// decryptIfNeeded(domainEvent)
processors.filter { processors.filter {
it.shouldProcess(eventId, domainEvent.getClearType(), eventInsert.insertType) it.shouldProcess(eventId, domainEvent.getClearType(), eventInsert.insertType)
}.forEach { }.forEach {
@ -83,6 +82,7 @@ internal class EventInsertLiveObserver @Inject constructor(@SessionDatabase real
.findAll() .findAll()
.deleteAllFromRealm() .deleteAllFromRealm()
} }
processors.forEach { it.onPostProcess() }
} }
} }

View file

@ -49,6 +49,7 @@ import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageServi
import org.matrix.android.sdk.api.session.signout.SignOutService import org.matrix.android.sdk.api.session.signout.SignOutService
import org.matrix.android.sdk.api.session.sync.FilterService import org.matrix.android.sdk.api.session.sync.FilterService
import org.matrix.android.sdk.api.session.terms.TermsService import org.matrix.android.sdk.api.session.terms.TermsService
import org.matrix.android.sdk.api.session.thirdparty.ThirdPartyService
import org.matrix.android.sdk.api.session.typing.TypingUsersTracker import org.matrix.android.sdk.api.session.typing.TypingUsersTracker
import org.matrix.android.sdk.api.session.user.UserService import org.matrix.android.sdk.api.session.user.UserService
import org.matrix.android.sdk.api.session.widgets.WidgetService import org.matrix.android.sdk.api.session.widgets.WidgetService
@ -114,6 +115,7 @@ internal class DefaultSession @Inject constructor(
private val accountService: Lazy<AccountService>, private val accountService: Lazy<AccountService>,
private val defaultIdentityService: DefaultIdentityService, private val defaultIdentityService: DefaultIdentityService,
private val integrationManagerService: IntegrationManagerService, private val integrationManagerService: IntegrationManagerService,
private val thirdPartyService: Lazy<ThirdPartyService>,
private val callSignalingService: Lazy<CallSignalingService>, private val callSignalingService: Lazy<CallSignalingService>,
@UnauthenticatedWithCertificate @UnauthenticatedWithCertificate
private val unauthenticatedWithCertificateOkHttpClient: Lazy<OkHttpClient>, private val unauthenticatedWithCertificateOkHttpClient: Lazy<OkHttpClient>,
@ -258,6 +260,8 @@ internal class DefaultSession @Inject constructor(
override fun searchService(): SearchService = searchService.get() override fun searchService(): SearchService = searchService.get()
override fun thirdPartyService(): ThirdPartyService = thirdPartyService.get()
override fun getOkHttpClient(): OkHttpClient { override fun getOkHttpClient(): OkHttpClient {
return unauthenticatedWithCertificateOkHttpClient.get() return unauthenticatedWithCertificateOkHttpClient.get()
} }

View file

@ -25,4 +25,12 @@ internal interface EventInsertLiveProcessor {
fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean
suspend fun process(realm: Realm, event: Event) suspend fun process(realm: Realm, event: Event)
/**
* Called after transaction.
* Maybe you prefer to process the events outside of the realm transaction.
*/
suspend fun onPostProcess() {
// Noop by default
}
} }

View file

@ -56,6 +56,7 @@ import org.matrix.android.sdk.internal.session.sync.SyncTask
import org.matrix.android.sdk.internal.session.sync.SyncTokenStore import org.matrix.android.sdk.internal.session.sync.SyncTokenStore
import org.matrix.android.sdk.internal.session.sync.job.SyncWorker import org.matrix.android.sdk.internal.session.sync.job.SyncWorker
import org.matrix.android.sdk.internal.session.terms.TermsModule import org.matrix.android.sdk.internal.session.terms.TermsModule
import org.matrix.android.sdk.internal.session.thirdparty.ThirdPartyModule
import org.matrix.android.sdk.internal.session.user.UserModule import org.matrix.android.sdk.internal.session.user.UserModule
import org.matrix.android.sdk.internal.session.user.accountdata.AccountDataModule import org.matrix.android.sdk.internal.session.user.accountdata.AccountDataModule
import org.matrix.android.sdk.internal.session.widgets.WidgetModule import org.matrix.android.sdk.internal.session.widgets.WidgetModule
@ -87,7 +88,8 @@ import org.matrix.android.sdk.internal.util.MatrixCoroutineDispatchers
ProfileModule::class, ProfileModule::class,
AccountModule::class, AccountModule::class,
CallModule::class, CallModule::class,
SearchModule::class SearchModule::class,
ThirdPartyModule::class
] ]
) )
@SessionScope @SessionScope

View file

@ -16,28 +16,30 @@
package org.matrix.android.sdk.internal.session.call package org.matrix.android.sdk.internal.session.call
import io.realm.Realm
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.EventInsertType
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.EventInsertLiveProcessor import org.matrix.android.sdk.internal.session.EventInsertLiveProcessor
import io.realm.Realm
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
internal class CallEventProcessor @Inject constructor( internal class CallEventProcessor @Inject constructor(private val callSignalingHandler: CallSignalingHandler)
@UserId private val userId: String, : EventInsertLiveProcessor {
private val callService: DefaultCallSignalingService
) : EventInsertLiveProcessor {
private val allowedTypes = listOf( private val allowedTypes = listOf(
EventType.CALL_ANSWER, EventType.CALL_ANSWER,
EventType.CALL_SELECT_ANSWER,
EventType.CALL_REJECT,
EventType.CALL_NEGOTIATE,
EventType.CALL_CANDIDATES, EventType.CALL_CANDIDATES,
EventType.CALL_INVITE, EventType.CALL_INVITE,
EventType.CALL_HANGUP, EventType.CALL_HANGUP,
EventType.ENCRYPTED EventType.ENCRYPTED
) )
private val eventsToPostProcess = mutableListOf<Event>()
override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean { override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean {
if (insertType != EventInsertType.INCREMENTAL_SYNC) { if (insertType != EventInsertType.INCREMENTAL_SYNC) {
return false return false
@ -46,10 +48,17 @@ internal class CallEventProcessor @Inject constructor(
} }
override suspend fun process(realm: Realm, event: Event) { override suspend fun process(realm: Realm, event: Event) {
update(realm, event) eventsToPostProcess.add(event)
} }
private fun update(realm: Realm, event: Event) { override suspend fun onPostProcess() {
eventsToPostProcess.forEach {
dispatchToCallSignalingHandlerIfNeeded(it)
}
eventsToPostProcess.clear()
}
private fun dispatchToCallSignalingHandlerIfNeeded(event: Event) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
// TODO might check if an invite is not closed (hangup/answsered) in the same event batch? // TODO might check if an invite is not closed (hangup/answsered) in the same event batch?
event.roomId ?: return Unit.also { event.roomId ?: return Unit.also {
@ -60,10 +69,6 @@ internal class CallEventProcessor @Inject constructor(
// To old to ring? // To old to ring?
return return
} }
event.ageLocalTs callSignalingHandler.onCallEvent(event)
if (EventType.isCallEvent(event.getClearType())) {
callService.onCallEvent(event)
}
Timber.v("$realm : $userId")
} }
} }

View file

@ -0,0 +1,74 @@
/*
* Copyright (c) 2020 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.internal.session.call
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.call.CallListener
import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent
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.CallInviteContent
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
/**
* Dispatch each method safely to all listeners.
*/
internal class CallListenersDispatcher(private val listeners: Set<CallListener>) : CallListener {
override fun onCallInviteReceived(mxCall: MxCall, callInviteContent: CallInviteContent) = dispatch {
it.onCallInviteReceived(mxCall, callInviteContent)
}
override fun onCallIceCandidateReceived(mxCall: MxCall, iceCandidatesContent: CallCandidatesContent) = dispatch {
it.onCallIceCandidateReceived(mxCall, iceCandidatesContent)
}
override fun onCallAnswerReceived(callAnswerContent: CallAnswerContent) = dispatch {
it.onCallAnswerReceived(callAnswerContent)
}
override fun onCallHangupReceived(callHangupContent: CallHangupContent) = dispatch {
it.onCallHangupReceived(callHangupContent)
}
override fun onCallRejectReceived(callRejectContent: CallRejectContent) = dispatch {
it.onCallRejectReceived(callRejectContent)
}
override fun onCallManagedByOtherSession(callId: String) = dispatch {
it.onCallManagedByOtherSession(callId)
}
override fun onCallSelectAnswerReceived(callSelectAnswerContent: CallSelectAnswerContent) = dispatch {
it.onCallSelectAnswerReceived(callSelectAnswerContent)
}
override fun onCallNegotiateReceived(callNegotiateContent: CallNegotiateContent) = dispatch {
it.onCallNegotiateReceived(callNegotiateContent)
}
private fun dispatch(lambda: (CallListener) -> Unit) {
listeners.toList().forEach {
tryOrNull {
lambda(it)
}
}
}
}

View file

@ -0,0 +1,218 @@
/*
* Copyright (c) 2020 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.internal.session.call
import org.matrix.android.sdk.api.session.call.CallListener
import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent
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.CallInviteContent
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.CallSignallingContent
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.SessionScope
import timber.log.Timber
import java.math.BigDecimal
import javax.inject.Inject
@SessionScope
internal class CallSignalingHandler @Inject constructor(private val activeCallHandler: ActiveCallHandler,
private val mxCallFactory: MxCallFactory,
@UserId private val userId: String) {
private val callListeners = mutableSetOf<CallListener>()
private val callListenersDispatcher = CallListenersDispatcher(callListeners)
fun addCallListener(listener: CallListener) {
callListeners.add(listener)
}
fun removeCallListener(listener: CallListener) {
callListeners.remove(listener)
}
fun onCallEvent(event: Event) {
when (event.getClearType()) {
EventType.CALL_ANSWER -> {
handleCallAnswerEvent(event)
}
EventType.CALL_INVITE -> {
handleCallInviteEvent(event)
}
EventType.CALL_HANGUP -> {
handleCallHangupEvent(event)
}
EventType.CALL_REJECT -> {
handleCallRejectEvent(event)
}
EventType.CALL_CANDIDATES -> {
handleCallCandidatesEvent(event)
}
EventType.CALL_SELECT_ANSWER -> {
handleCallSelectAnswerEvent(event)
}
EventType.CALL_NEGOTIATE -> {
handleCallNegotiateEvent(event)
}
}
}
private fun handleCallNegotiateEvent(event: Event) {
val content = event.getClearContent().toModel<CallNegotiateContent>() ?: return
val call = content.getCall() ?: return
if (call.ourPartyId == content.partyId) {
// Ignore remote echo
return
}
callListenersDispatcher.onCallNegotiateReceived(content)
}
private fun handleCallSelectAnswerEvent(event: Event) {
val content = event.getClearContent().toModel<CallSelectAnswerContent>() ?: return
val call = content.getCall() ?: return
if (call.ourPartyId == content.partyId) {
// Ignore remote echo
return
}
if (call.isOutgoing) {
Timber.v("Got selectAnswer for an outbound call: ignoring")
return
}
val selectedPartyId = content.selectedPartyId
if (selectedPartyId == null) {
Timber.w("Got nonsensical select_answer with null selected_party_id: ignoring")
return
}
callListenersDispatcher.onCallSelectAnswerReceived(content)
}
private fun handleCallCandidatesEvent(event: Event) {
val content = event.getClearContent().toModel<CallCandidatesContent>() ?: return
val call = content.getCall() ?: return
if (call.ourPartyId == content.partyId) {
// Ignore remote echo
return
}
if (call.opponentPartyId != null && !call.partyIdsMatches(content)) {
Timber.v("Ignoring candidates from party ID ${content.partyId} we have chosen party ID ${call.opponentPartyId}")
return
}
callListenersDispatcher.onCallIceCandidateReceived(call, content)
}
private fun handleCallRejectEvent(event: Event) {
val content = event.getClearContent().toModel<CallRejectContent>() ?: return
val call = content.getCall() ?: return
if (call.ourPartyId == content.partyId) {
// Ignore remote echo
return
}
activeCallHandler.removeCall(content.callId)
if (event.senderId == userId) {
// discard current call, it's rejected by another of my session
callListenersDispatcher.onCallManagedByOtherSession(content.callId)
return
}
// No need to check party_id for reject because if we'd received either
// an answer or reject, we wouldn't be in state InviteSent
if (call.state != CallState.Dialing) {
return
}
callListenersDispatcher.onCallRejectReceived(content)
}
private fun handleCallHangupEvent(event: Event) {
val content = event.getClearContent().toModel<CallHangupContent>() ?: return
val call = content.getCall() ?: return
// party ID must match (our chosen partner hanging up the call) or be undefined (we haven't chosen
// a partner yet but we're treating the hangup as a reject as per VoIP v0)
if (call.opponentPartyId != null && !call.partyIdsMatches(content)) {
Timber.v("Ignoring hangup from party ID ${content.partyId} we have chosen party ID ${call.opponentPartyId}")
return
}
if (call.state != CallState.Terminated) {
activeCallHandler.removeCall(content.callId)
callListenersDispatcher.onCallHangupReceived(content)
}
}
private fun handleCallInviteEvent(event: Event) {
if (event.senderId == userId) {
// ignore invites you send
return
}
if (event.roomId == null || event.senderId == null) {
return
}
val content = event.getClearContent().toModel<CallInviteContent>() ?: return
val incomingCall = mxCallFactory.createIncomingCall(
roomId = event.roomId,
opponentUserId = event.senderId,
content = content
) ?: return
activeCallHandler.addCall(incomingCall)
callListenersDispatcher.onCallInviteReceived(incomingCall, content)
}
private fun handleCallAnswerEvent(event: Event) {
val content = event.getClearContent().toModel<CallAnswerContent>() ?: return
val call = content.getCall() ?: return
if (call.ourPartyId == content.partyId) {
// Ignore remote echo
return
}
if (event.senderId == userId) {
// discard current call, it's answered by another of my session
activeCallHandler.removeCall(call.callId)
callListenersDispatcher.onCallManagedByOtherSession(content.callId)
} else {
if (call.opponentPartyId != null) {
Timber.v("Ignoring answer from party ID ${content.partyId} we already have an answer from ${call.opponentPartyId}")
return
}
call.apply {
opponentPartyId = Optional.from(content.partyId)
opponentVersion = content.version?.let { BigDecimal(it).intValueExact() } ?: MxCall.VOIP_PROTO_VERSION
capabilities = content.capabilities ?: CallCapabilities()
}
callListenersDispatcher.onCallAnswerReceived(content)
}
}
private fun MxCall.partyIdsMatches(contentSignallingContent: CallSignallingContent): Boolean {
return opponentPartyId?.getOrNull() == contentSignallingContent.partyId
}
private fun CallSignallingContent.getCall(): MxCall? {
val currentCall = callId?.let {
activeCallHandler.getCallWithId(it)
}
if (currentCall == null) {
Timber.v("Call with id $callId is null")
}
return currentCall
}
}

View file

@ -16,106 +16,46 @@
package org.matrix.android.sdk.internal.session.call package org.matrix.android.sdk.internal.session.call
import android.os.SystemClock import kotlinx.coroutines.Dispatchers
import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.call.CallListener
import org.matrix.android.sdk.api.session.call.CallSignalingService import org.matrix.android.sdk.api.session.call.CallSignalingService
import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.CallsListener
import org.matrix.android.sdk.api.session.call.MxCall import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.call.TurnServerResponse import org.matrix.android.sdk.api.session.call.TurnServerResponse
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent
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.CallInviteContent
import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.api.util.NoOpCancellable
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.SessionScope import org.matrix.android.sdk.internal.session.SessionScope
import org.matrix.android.sdk.internal.session.call.model.MxCallImpl
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
import org.matrix.android.sdk.internal.task.TaskExecutor import org.matrix.android.sdk.internal.task.TaskExecutor
import org.matrix.android.sdk.internal.task.configureWith import org.matrix.android.sdk.internal.task.launchToCallback
import timber.log.Timber import timber.log.Timber
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@SessionScope @SessionScope
internal class DefaultCallSignalingService @Inject constructor( internal class DefaultCallSignalingService @Inject constructor(
@UserId private val callSignalingHandler: CallSignalingHandler,
private val userId: String, private val mxCallFactory: MxCallFactory,
private val activeCallHandler: ActiveCallHandler, private val activeCallHandler: ActiveCallHandler,
private val localEchoEventFactory: LocalEchoEventFactory,
private val eventSenderProcessor: EventSenderProcessor,
private val taskExecutor: TaskExecutor, private val taskExecutor: TaskExecutor,
private val turnServerTask: GetTurnServerTask private val turnServerDataSource: TurnServerDataSource
) : CallSignalingService { ) : CallSignalingService {
private val callListeners = mutableSetOf<CallsListener>()
private val cachedTurnServerResponse = object {
// Keep one minute safe to avoid considering the data is valid and then actually it is not when effectively using it.
private val MIN_TTL = 60
private val now = { SystemClock.elapsedRealtime() / 1000 }
private var expiresAt: Long = 0
var data: TurnServerResponse? = null
get() = if (expiresAt > now()) field else null
set(value) {
expiresAt = now() + (value?.ttl ?: 0) - MIN_TTL
field = value
}
}
override fun getTurnServer(callback: MatrixCallback<TurnServerResponse>): Cancelable { override fun getTurnServer(callback: MatrixCallback<TurnServerResponse>): Cancelable {
if (cachedTurnServerResponse.data != null) { return taskExecutor.executorScope.launchToCallback(Dispatchers.Default, callback) {
cachedTurnServerResponse.data?.let { callback.onSuccess(it) } turnServerDataSource.getTurnServer()
return NoOpCancellable
} }
return turnServerTask
.configureWith(GetTurnServerTask.Params) {
this.callback = object : MatrixCallback<TurnServerResponse> {
override fun onSuccess(data: TurnServerResponse) {
cachedTurnServerResponse.data = data
callback.onSuccess(data)
}
override fun onFailure(failure: Throwable) {
callback.onFailure(failure)
}
}
}
.executeBy(taskExecutor)
} }
override fun createOutgoingCall(roomId: String, otherUserId: String, isVideoCall: Boolean): MxCall { override fun createOutgoingCall(roomId: String, otherUserId: String, isVideoCall: Boolean): MxCall {
val call = MxCallImpl( return mxCallFactory.createOutgoingCall(roomId, otherUserId, isVideoCall).also {
callId = UUID.randomUUID().toString(), activeCallHandler.addCall(it)
isOutgoing = true,
roomId = roomId,
userId = userId,
otherUserId = otherUserId,
isVideoCall = isVideoCall,
localEchoEventFactory = localEchoEventFactory,
eventSenderProcessor = eventSenderProcessor
)
activeCallHandler.addCall(call).also {
return call
} }
} }
override fun addCallListener(listener: CallsListener) { override fun addCallListener(listener: CallListener) {
callListeners.add(listener) callSignalingHandler.addCallListener(listener)
} }
override fun removeCallListener(listener: CallsListener) { override fun removeCallListener(listener: CallListener) {
callListeners.remove(listener) callSignalingHandler.removeCallListener(listener)
} }
override fun getCallWithId(callId: String): MxCall? { override fun getCallWithId(callId: String): MxCall? {
@ -127,129 +67,6 @@ internal class DefaultCallSignalingService @Inject constructor(
return activeCallHandler.getActiveCallsLiveData().value?.isNotEmpty() == true return activeCallHandler.getActiveCallsLiveData().value?.isNotEmpty() == true
} }
internal fun onCallEvent(event: Event) {
when (event.getClearType()) {
EventType.CALL_ANSWER -> {
event.getClearContent().toModel<CallAnswerContent>()?.let {
if (event.senderId == userId) {
// ok it's an answer from me.. is it remote echo or other session
val knownCall = getCallWithId(it.callId)
if (knownCall == null) {
Timber.d("## VOIP onCallEvent ${event.getClearType()} id ${it.callId} send by me")
} else if (!knownCall.isOutgoing) {
// incoming call
// if it was anwsered by this session, the call state would be in Answering(or connected) state
if (knownCall.state == CallState.LocalRinging) {
// discard current call, it's answered by another of my session
onCallManageByOtherSession(it.callId)
}
}
return
}
onCallAnswer(it)
}
}
EventType.CALL_INVITE -> {
if (event.senderId == userId) {
// Always ignore local echos of invite
return
}
event.getClearContent().toModel<CallInviteContent>()?.let { content ->
val incomingCall = MxCallImpl(
callId = content.callId ?: return@let,
isOutgoing = false,
roomId = event.roomId ?: return@let,
userId = userId,
otherUserId = event.senderId ?: return@let,
isVideoCall = content.isVideo(),
localEchoEventFactory = localEchoEventFactory,
eventSenderProcessor = eventSenderProcessor
)
activeCallHandler.addCall(incomingCall)
onCallInvite(incomingCall, content)
}
}
EventType.CALL_HANGUP -> {
event.getClearContent().toModel<CallHangupContent>()?.let { content ->
if (event.senderId == userId) {
// ok it's an answer from me.. is it remote echo or other session
val knownCall = getCallWithId(content.callId)
if (knownCall == null) {
Timber.d("## VOIP onCallEvent ${event.getClearType()} id ${content.callId} send by me")
} else if (!knownCall.isOutgoing) {
// incoming call
if (knownCall.state == CallState.LocalRinging) {
// discard current call, it's answered by another of my session
onCallManageByOtherSession(content.callId)
}
}
return
}
activeCallHandler.removeCall(content.callId)
onCallHangup(content)
}
}
EventType.CALL_CANDIDATES -> {
if (event.senderId == userId) {
// Always ignore local echos of invite
return
}
event.getClearContent().toModel<CallCandidatesContent>()?.let { content ->
activeCallHandler.getCallWithId(content.callId)?.let {
onCallIceCandidate(it, content)
}
}
}
}
}
private fun onCallHangup(hangup: CallHangupContent) {
callListeners.toList().forEach {
tryOrNull {
it.onCallHangupReceived(hangup)
}
}
}
private fun onCallAnswer(answer: CallAnswerContent) {
callListeners.toList().forEach {
tryOrNull {
it.onCallAnswerReceived(answer)
}
}
}
private fun onCallManageByOtherSession(callId: String) {
callListeners.toList().forEach {
tryOrNull {
it.onCallManagedByOtherSession(callId)
}
}
}
private fun onCallInvite(incomingCall: MxCall, invite: CallInviteContent) {
// Ignore the invitation from current user
if (incomingCall.otherUserId == userId) return
callListeners.toList().forEach {
tryOrNull {
it.onCallInviteReceived(incomingCall, invite)
}
}
}
private fun onCallIceCandidate(incomingCall: MxCall, candidates: CallCandidatesContent) {
callListeners.toList().forEach {
tryOrNull {
it.onCallIceCandidateReceived(incomingCall, candidates)
}
}
}
companion object { companion object {
const val CALL_TIMEOUT_MS = 120_000 const val CALL_TIMEOUT_MS = 120_000
} }

View file

@ -0,0 +1,79 @@
/*
* Copyright (c) 2020 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.internal.session.call
import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities
import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.di.DeviceId
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.call.model.MxCallImpl
import org.matrix.android.sdk.internal.session.profile.GetProfileInfoTask
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import java.math.BigDecimal
import java.util.UUID
import javax.inject.Inject
internal class MxCallFactory @Inject constructor(
@DeviceId private val deviceId: String?,
private val localEchoEventFactory: LocalEchoEventFactory,
private val eventSenderProcessor: EventSenderProcessor,
private val matrixConfiguration: MatrixConfiguration,
private val getProfileInfoTask: GetProfileInfoTask,
@UserId private val userId: String
) {
fun createIncomingCall(roomId: String, opponentUserId: String, content: CallInviteContent): MxCall? {
content.callId ?: return null
return MxCallImpl(
callId = content.callId,
isOutgoing = false,
roomId = roomId,
userId = userId,
ourPartyId = deviceId ?: "",
opponentUserId = opponentUserId,
isVideoCall = content.isVideo(),
localEchoEventFactory = localEchoEventFactory,
eventSenderProcessor = eventSenderProcessor,
matrixConfiguration = matrixConfiguration,
getProfileInfoTask = getProfileInfoTask
).apply {
opponentPartyId = Optional.from(content.partyId)
opponentVersion = content.version?.let { BigDecimal(it).intValueExact() } ?: MxCall.VOIP_PROTO_VERSION
capabilities = content.capabilities ?: CallCapabilities()
}
}
fun createOutgoingCall(roomId: String, opponentUserId: String, isVideoCall: Boolean): MxCall {
return MxCallImpl(
callId = UUID.randomUUID().toString(),
isOutgoing = true,
roomId = roomId,
userId = userId,
ourPartyId = deviceId ?: "",
opponentUserId = opponentUserId,
isVideoCall = isVideoCall,
localEchoEventFactory = localEchoEventFactory,
eventSenderProcessor = eventSenderProcessor,
matrixConfiguration = matrixConfiguration,
getProfileInfoTask = getProfileInfoTask
)
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2020 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.internal.session.call
import android.os.SystemClock
import org.matrix.android.sdk.api.session.call.TurnServerResponse
import javax.inject.Inject
internal class TurnServerDataSource @Inject constructor(private val turnServerTask: GetTurnServerTask) {
private val cachedTurnServerResponse = object {
// Keep one minute safe to avoid considering the data is valid and then actually it is not when effectively using it.
private val MIN_TTL = 60
private val now = { SystemClock.elapsedRealtime() / 1000 }
private var expiresAt: Long = 0
var data: TurnServerResponse? = null
get() = if (expiresAt > now()) field else null
set(value) {
expiresAt = now() + (value?.ttl ?: 0) - MIN_TTL
field = value
}
}
suspend fun getTurnServer(): TurnServerResponse {
return cachedTurnServerResponse.data ?: turnServerTask.execute(GetTurnServerTask.Params).also {
cachedTurnServerResponse.data = it
}
}
}

View file

@ -16,6 +16,7 @@
package org.matrix.android.sdk.internal.session.call.model package org.matrix.android.sdk.internal.session.call.model
import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCall import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.Content
@ -24,28 +25,44 @@ import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.LocalEcho import org.matrix.android.sdk.api.session.events.model.LocalEcho
import org.matrix.android.sdk.api.session.events.model.UnsignedData import org.matrix.android.sdk.api.session.events.model.UnsignedData
import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.profile.ProfileService
import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.CallCandidate
import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent
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.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.CallRejectContent
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.SdpType
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
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor import org.matrix.android.sdk.internal.session.profile.GetProfileInfoTask
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
import org.webrtc.IceCandidate import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import org.webrtc.SessionDescription
import timber.log.Timber import timber.log.Timber
import java.util.UUID
internal class MxCallImpl( internal class MxCallImpl(
override val callId: String, override val callId: String,
override val isOutgoing: Boolean, override val isOutgoing: Boolean,
override val roomId: String, override val roomId: String,
private val userId: String, private val userId: String,
override val otherUserId: String, override val opponentUserId: String,
override val isVideoCall: Boolean, override val isVideoCall: Boolean,
override val ourPartyId: String,
private val localEchoEventFactory: LocalEchoEventFactory, private val localEchoEventFactory: LocalEchoEventFactory,
private val eventSenderProcessor: EventSenderProcessor private val eventSenderProcessor: EventSenderProcessor,
private val matrixConfiguration: MatrixConfiguration,
private val getProfileInfoTask: GetProfileInfoTask
) : MxCall { ) : MxCall {
override var opponentPartyId: Optional<String>? = null
override var opponentVersion: Int = MxCall.VOIP_PROTO_VERSION
override var capabilities: CallCapabilities? = null
override var state: CallState = CallState.Idle override var state: CallState = CallState.Idle
set(value) { set(value) {
field = value field = value
@ -81,60 +98,135 @@ internal class MxCallImpl(
} }
} }
override fun offerSdp(sdp: SessionDescription) { override fun offerSdp(sdpString: String) {
if (!isOutgoing) return if (!isOutgoing) return
Timber.v("## VOIP offerSdp $callId") Timber.v("## VOIP offerSdp $callId")
state = CallState.Dialing state = CallState.Dialing
CallInviteContent( CallInviteContent(
callId = callId, callId = callId,
partyId = ourPartyId,
lifetime = DefaultCallSignalingService.CALL_TIMEOUT_MS, lifetime = DefaultCallSignalingService.CALL_TIMEOUT_MS,
offer = CallInviteContent.Offer(sdp = sdp.description) offer = CallInviteContent.Offer(sdp = sdpString),
version = MxCall.VOIP_PROTO_VERSION.toString(),
capabilities = buildCapabilities()
) )
.let { createEventAndLocalEcho(type = EventType.CALL_INVITE, roomId = roomId, content = it.toContent()) } .let { createEventAndLocalEcho(type = EventType.CALL_INVITE, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) } .also { eventSenderProcessor.postEvent(it) }
} }
override fun sendLocalIceCandidates(candidates: List<IceCandidate>) { override fun sendLocalCallCandidates(candidates: List<CallCandidate>) {
Timber.v("Send local call canditates $callId: $candidates")
CallCandidatesContent( CallCandidatesContent(
callId = callId, callId = callId,
candidates = candidates.map { partyId = ourPartyId,
CallCandidatesContent.Candidate( candidates = candidates,
sdpMid = it.sdpMid, version = MxCall.VOIP_PROTO_VERSION.toString()
sdpMLineIndex = it.sdpMLineIndex,
candidate = it.sdp
)
}
) )
.let { createEventAndLocalEcho(type = EventType.CALL_CANDIDATES, roomId = roomId, content = it.toContent()) } .let { createEventAndLocalEcho(type = EventType.CALL_CANDIDATES, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) } .also { eventSenderProcessor.postEvent(it) }
} }
override fun sendLocalIceCandidateRemovals(candidates: List<IceCandidate>) { override fun sendLocalIceCandidateRemovals(candidates: List<CallCandidate>) {
// For now we don't support this flow // For now we don't support this flow
} }
override fun hangUp() { override fun reject() {
if (opponentVersion < 1) {
Timber.v("Opponent version is less than 1 ($opponentVersion): sending hangup instead of reject")
hangUp()
return
}
Timber.v("## VOIP reject $callId")
CallRejectContent(
callId = callId,
partyId = ourPartyId,
version = MxCall.VOIP_PROTO_VERSION.toString()
)
.let { createEventAndLocalEcho(type = EventType.CALL_REJECT, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) }
state = CallState.Terminated
}
override fun hangUp(reason: CallHangupContent.Reason?) {
Timber.v("## VOIP hangup $callId") Timber.v("## VOIP hangup $callId")
CallHangupContent( CallHangupContent(
callId = callId callId = callId,
partyId = ourPartyId,
reason = reason ?: CallHangupContent.Reason.USER_HANGUP,
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.Terminated
} }
override fun accept(sdp: SessionDescription) { override fun accept(sdpString: String) {
Timber.v("## VOIP accept $callId") Timber.v("## VOIP accept $callId")
if (isOutgoing) return if (isOutgoing) return
state = CallState.Answering state = CallState.Answering
CallAnswerContent( CallAnswerContent(
callId = callId, callId = callId,
answer = CallAnswerContent.Answer(sdp = sdp.description) partyId = ourPartyId,
answer = CallAnswerContent.Answer(sdp = sdpString),
version = MxCall.VOIP_PROTO_VERSION.toString(),
capabilities = buildCapabilities()
) )
.let { createEventAndLocalEcho(type = EventType.CALL_ANSWER, roomId = roomId, content = it.toContent()) } .let { createEventAndLocalEcho(type = EventType.CALL_ANSWER, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) } .also { eventSenderProcessor.postEvent(it) }
} }
override fun negotiate(sdpString: String, type: SdpType) {
Timber.v("## VOIP negotiate $callId")
CallNegotiateContent(
callId = callId,
partyId = ourPartyId,
lifetime = DefaultCallSignalingService.CALL_TIMEOUT_MS,
description = CallNegotiateContent.Description(sdp = sdpString, type = type),
version = MxCall.VOIP_PROTO_VERSION.toString()
)
.let { createEventAndLocalEcho(type = EventType.CALL_NEGOTIATE, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) }
}
override fun selectAnswer() {
Timber.v("## VOIP select answer $callId")
if (isOutgoing) return
state = CallState.Answering
CallSelectAnswerContent(
callId = callId,
partyId = ourPartyId,
selectedPartyId = opponentPartyId?.getOrNull(),
version = MxCall.VOIP_PROTO_VERSION.toString()
)
.let { createEventAndLocalEcho(type = EventType.CALL_SELECT_ANSWER, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) }
}
override suspend fun transfer(targetUserId: String, targetRoomId: String?) {
val profileInfoParams = GetProfileInfoTask.Params(targetUserId)
val profileInfo = try {
getProfileInfoTask.execute(profileInfoParams)
} catch (failure: Throwable) {
Timber.v("Fail fetching profile info of $targetUserId while transferring call")
null
}
CallReplacesContent(
callId = callId,
partyId = ourPartyId,
replacementId = UUID.randomUUID().toString(),
version = MxCall.VOIP_PROTO_VERSION.toString(),
targetUser = CallReplacesContent.TargetUser(
id = targetUserId,
displayName = profileInfo?.get(ProfileService.DISPLAY_NAME_KEY) as? String,
avatarUrl = profileInfo?.get(ProfileService.AVATAR_URL_KEY) as? String
),
targerRoomId = targetRoomId,
createCall = UUID.randomUUID().toString()
)
.let { createEventAndLocalEcho(type = EventType.CALL_REPLACES, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) }
}
private fun createEventAndLocalEcho(localId: String = LocalEcho.createLocalEchoId(), type: String, roomId: String, content: Content): Event { private fun createEventAndLocalEcho(localId: String = LocalEcho.createLocalEchoId(), type: String, roomId: String, content: Content): Event {
return Event( return Event(
roomId = roomId, roomId = roomId,
@ -147,4 +239,12 @@ internal class MxCallImpl(
) )
.also { localEchoEventFactory.createLocalEcho(it) } .also { localEchoEventFactory.createLocalEcho(it) }
} }
private fun buildCapabilities(): CallCapabilities? {
return if (matrixConfiguration.supportsCallTransfer) {
CallCapabilities(true)
} else {
null
}
}
} }

View file

@ -21,11 +21,9 @@ import org.matrix.android.sdk.api.session.room.RoomDirectoryService
import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsResponse import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsResponse
import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol
import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask
import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask
import org.matrix.android.sdk.internal.session.room.directory.GetThirdPartyProtocolsTask
import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVisibilityTask
import org.matrix.android.sdk.internal.task.TaskExecutor import org.matrix.android.sdk.internal.task.TaskExecutor
import org.matrix.android.sdk.internal.task.configureWith import org.matrix.android.sdk.internal.task.configureWith
@ -33,7 +31,6 @@ import javax.inject.Inject
internal class DefaultRoomDirectoryService @Inject constructor( internal class DefaultRoomDirectoryService @Inject constructor(
private val getPublicRoomTask: GetPublicRoomTask, private val getPublicRoomTask: GetPublicRoomTask,
private val getThirdPartyProtocolsTask: GetThirdPartyProtocolsTask,
private val getRoomDirectoryVisibilityTask: GetRoomDirectoryVisibilityTask, private val getRoomDirectoryVisibilityTask: GetRoomDirectoryVisibilityTask,
private val setRoomDirectoryVisibilityTask: SetRoomDirectoryVisibilityTask, private val setRoomDirectoryVisibilityTask: SetRoomDirectoryVisibilityTask,
private val taskExecutor: TaskExecutor) : RoomDirectoryService { private val taskExecutor: TaskExecutor) : RoomDirectoryService {
@ -48,14 +45,6 @@ internal class DefaultRoomDirectoryService @Inject constructor(
.executeBy(taskExecutor) .executeBy(taskExecutor)
} }
override fun getThirdPartyProtocol(callback: MatrixCallback<Map<String, ThirdPartyProtocol>>): Cancelable {
return getThirdPartyProtocolsTask
.configureWith {
this.callback = callback
}
.executeBy(taskExecutor)
}
override suspend fun getRoomDirectoryVisibility(roomId: String): RoomDirectoryVisibility { override suspend fun getRoomDirectoryVisibility(roomId: String): RoomDirectoryVisibility {
return getRoomDirectoryVisibilityTask.execute(GetRoomDirectoryVisibilityTask.Params(roomId)) return getRoomDirectoryVisibilityTask.execute(GetRoomDirectoryVisibilityTask.Params(roomId))
} }

View file

@ -20,7 +20,6 @@ import org.matrix.android.sdk.api.session.events.model.Content
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsResponse import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsResponse
import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol
import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.network.NetworkConstants import org.matrix.android.sdk.internal.network.NetworkConstants
import org.matrix.android.sdk.internal.session.room.alias.GetAliasesResponse import org.matrix.android.sdk.internal.session.room.alias.GetAliasesResponse
@ -50,14 +49,6 @@ import retrofit2.http.Query
internal interface RoomAPI { internal interface RoomAPI {
/**
* Get the third party server protocols.
*
* Ref: https://matrix.org/docs/spec/client_server/r0.4.0.html#get-matrix-client-r0-thirdparty-protocols
*/
@GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "thirdparty/protocols")
fun thirdPartyProtocols(): Call<Map<String, ThirdPartyProtocol>>
/** /**
* Lists the public rooms on the server, with optional filter. * Lists the public rooms on the server, with optional filter.
* This API returns paginated responses. The rooms are ordered by the number of joined members, with the largest rooms first. * This API returns paginated responses. The rooms are ordered by the number of joined members, with the largest rooms first.

View file

@ -39,11 +39,9 @@ import org.matrix.android.sdk.internal.session.room.create.CreateRoomTask
import org.matrix.android.sdk.internal.session.room.create.DefaultCreateRoomTask import org.matrix.android.sdk.internal.session.room.create.DefaultCreateRoomTask
import org.matrix.android.sdk.internal.session.room.directory.DefaultGetPublicRoomTask import org.matrix.android.sdk.internal.session.room.directory.DefaultGetPublicRoomTask
import org.matrix.android.sdk.internal.session.room.directory.DefaultGetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.DefaultGetRoomDirectoryVisibilityTask
import org.matrix.android.sdk.internal.session.room.directory.DefaultGetThirdPartyProtocolsTask
import org.matrix.android.sdk.internal.session.room.directory.DefaultSetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.DefaultSetRoomDirectoryVisibilityTask
import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask
import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask
import org.matrix.android.sdk.internal.session.room.directory.GetThirdPartyProtocolsTask
import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVisibilityTask
import org.matrix.android.sdk.internal.session.room.membership.DefaultLoadRoomMembersTask import org.matrix.android.sdk.internal.session.room.membership.DefaultLoadRoomMembersTask
import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask
@ -153,9 +151,6 @@ internal abstract class RoomModule {
@Binds @Binds
abstract fun bindSetRoomDirectoryVisibilityTask(task: DefaultSetRoomDirectoryVisibilityTask): SetRoomDirectoryVisibilityTask abstract fun bindSetRoomDirectoryVisibilityTask(task: DefaultSetRoomDirectoryVisibilityTask): SetRoomDirectoryVisibilityTask
@Binds
abstract fun bindGetThirdPartyProtocolsTask(task: DefaultGetThirdPartyProtocolsTask): GetThirdPartyProtocolsTask
@Binds @Binds
abstract fun bindInviteTask(task: DefaultInviteTask): InviteTask abstract fun bindInviteTask(task: DefaultInviteTask): InviteTask

View file

@ -49,8 +49,10 @@ internal class QueueMemento @Inject constructor(context: Context,
} }
fun unTrack(task: QueuedTask) { fun unTrack(task: QueuedTask) {
managedTaskInfos.remove(task) synchronized(managedTaskInfos) {
persist() managedTaskInfos.remove(task)
persist()
}
} }
private fun persist() { private fun persist() {
@ -64,19 +66,17 @@ internal class QueueMemento @Inject constructor(context: Context,
} }
private fun toTaskInfo(task: QueuedTask, order: Int): TaskInfo? { private fun toTaskInfo(task: QueuedTask, order: Int): TaskInfo? {
synchronized(managedTaskInfos) { return when (task) {
return when (task) { is SendEventQueuedTask -> SendEventTaskInfo(
is SendEventQueuedTask -> SendEventTaskInfo( localEchoId = task.event.eventId ?: "",
localEchoId = task.event.eventId ?: "", encrypt = task.encrypt,
encrypt = task.encrypt, order = order
order = order )
) is RedactQueuedTask -> RedactEventTaskInfo(
is RedactQueuedTask -> RedactEventTaskInfo( redactionLocalEcho = task.redactionLocalEchoId,
redactionLocalEcho = task.redactionLocalEchoId, order = order
order = order )
) else -> null
else -> null
}
} }
} }
@ -90,7 +90,7 @@ internal class QueueMemento @Inject constructor(context: Context,
?.forEach { info -> ?.forEach { info ->
try { try {
when (info) { when (info) {
is SendEventTaskInfo -> { is SendEventTaskInfo -> {
localEchoRepository.getUpToDateEcho(info.localEchoId)?.let { localEchoRepository.getUpToDateEcho(info.localEchoId)?.let {
if (it.sendState.isSending() && it.eventId != null && it.roomId != null) { if (it.sendState.isSending() && it.eventId != null && it.roomId != null) {
localEchoRepository.updateSendState(it.eventId, it.roomId, SendState.UNSENT) localEchoRepository.updateSendState(it.eventId, it.roomId, SendState.UNSENT)

View file

@ -0,0 +1,39 @@
/*
* 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.internal.session.thirdparty
import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol
import org.matrix.android.sdk.api.session.thirdparty.ThirdPartyService
import org.matrix.android.sdk.api.session.thirdparty.model.ThirdPartyUser
import javax.inject.Inject
internal class DefaultThirdPartyService @Inject constructor(private val getThirdPartyProtocolTask: GetThirdPartyProtocolsTask,
private val getThirdPartyUserTask: GetThirdPartyUserTask)
: ThirdPartyService {
override suspend fun getThirdPartyProtocols(): Map<String, ThirdPartyProtocol> {
return getThirdPartyProtocolTask.execute(Unit)
}
override suspend fun getThirdPartyUser(protocol: String, fields: Map<String, String>): List<ThirdPartyUser> {
val taskParams = GetThirdPartyUserTask.Params(
protocol = protocol,
fields = fields
)
return getThirdPartyUserTask.execute(taskParams)
}
}

View file

@ -14,25 +14,24 @@
* limitations under the License. * limitations under the License.
*/ */
package org.matrix.android.sdk.internal.session.room.directory package org.matrix.android.sdk.internal.session.thirdparty
import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.session.room.RoomAPI
import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.task.Task
import javax.inject.Inject import javax.inject.Inject
internal interface GetThirdPartyProtocolsTask : Task<Unit, Map<String, ThirdPartyProtocol>> internal interface GetThirdPartyProtocolsTask : Task<Unit, Map<String, ThirdPartyProtocol>>
internal class DefaultGetThirdPartyProtocolsTask @Inject constructor( internal class DefaultGetThirdPartyProtocolsTask @Inject constructor(
private val roomAPI: RoomAPI, private val thirdPartyAPI: ThirdPartyAPI,
private val globalErrorReceiver: GlobalErrorReceiver private val globalErrorReceiver: GlobalErrorReceiver
) : GetThirdPartyProtocolsTask { ) : GetThirdPartyProtocolsTask {
override suspend fun execute(params: Unit): Map<String, ThirdPartyProtocol> { override suspend fun execute(params: Unit): Map<String, ThirdPartyProtocol> {
return executeRequest(globalErrorReceiver) { return executeRequest(globalErrorReceiver) {
apiCall = roomAPI.thirdPartyProtocols() apiCall = thirdPartyAPI.thirdPartyProtocols()
} }
} }
} }

View file

@ -0,0 +1,43 @@
/*
* Copyright 2020 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.internal.session.thirdparty
import org.matrix.android.sdk.api.session.thirdparty.model.ThirdPartyUser
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.task.Task
import javax.inject.Inject
internal interface GetThirdPartyUserTask : Task<GetThirdPartyUserTask.Params, List<ThirdPartyUser>> {
data class Params(
val protocol: String,
val fields: Map<String, String> = emptyMap()
)
}
internal class DefaultGetThirdPartyUserTask @Inject constructor(
private val thirdPartyAPI: ThirdPartyAPI,
private val globalErrorReceiver: GlobalErrorReceiver
) : GetThirdPartyUserTask {
override suspend fun execute(params: GetThirdPartyUserTask.Params): List<ThirdPartyUser> {
return executeRequest(globalErrorReceiver) {
apiCall = thirdPartyAPI.getThirdPartyUser(params.protocol, params.fields)
}
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2020 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.internal.session.thirdparty
import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol
import org.matrix.android.sdk.api.session.thirdparty.model.ThirdPartyUser
import org.matrix.android.sdk.internal.network.NetworkConstants
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.QueryMap
internal interface ThirdPartyAPI {
/**
* Get the third party server protocols.
*
* Ref: https://matrix.org/docs/spec/client_server/r0.6.1.html#get-matrix-client-r0-thirdparty-protocols
*/
@GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "thirdparty/protocols")
fun thirdPartyProtocols(): Call<Map<String, ThirdPartyProtocol>>
/**
* Retrieve a Matrix User ID linked to a user on the third party service, given a set of user parameters.
*
* Ref: https://matrix.org/docs/spec/client_server/r0.6.1#get-matrix-client-r0-thirdparty-user-protocol
*/
@GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "thirdparty/protocols/user/{protocol}")
fun getThirdPartyUser(@Path("protocol") protocol: String, @QueryMap params: Map<String, String>?): Call<List<ThirdPartyUser>>
}

View file

@ -0,0 +1,47 @@
/*
* Copyright 2020 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.internal.session.thirdparty
import dagger.Binds
import dagger.Module
import dagger.Provides
import org.matrix.android.sdk.api.session.thirdparty.ThirdPartyService
import org.matrix.android.sdk.internal.session.SessionScope
import retrofit2.Retrofit
@Module
internal abstract class ThirdPartyModule {
@Module
companion object {
@Provides
@JvmStatic
@SessionScope
fun providesThirdPartyAPI(retrofit: Retrofit): ThirdPartyAPI {
return retrofit.create(ThirdPartyAPI::class.java)
}
}
@Binds
abstract fun bindThirdPartyService(service: DefaultThirdPartyService): ThirdPartyService
@Binds
abstract fun bindGetThirdPartyProtocolsTask(task: DefaultGetThirdPartyProtocolsTask): GetThirdPartyProtocolsTask
@Binds
abstract fun bindGetThirdPartyUserTask(task: DefaultGetThirdPartyUserTask): GetThirdPartyUserTask
}

View file

@ -161,7 +161,7 @@ Formatter\.formatShortFileSize===1
# android\.text\.TextUtils # android\.text\.TextUtils
### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If the enum is not used as a Json class, change the value in file forbidden_strings_in_code.txt ### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If the enum is not used as a Json class, change the value in file forbidden_strings_in_code.txt
enum class===85 enum class===88
### Do not import temporary legacy classes ### Do not import temporary legacy classes
import org.matrix.android.sdk.internal.legacy.riot===3 import org.matrix.android.sdk.internal.legacy.riot===3

View file

@ -114,6 +114,9 @@ android {
targetSdkVersion 30 targetSdkVersion 30
multiDexEnabled true multiDexEnabled true
renderscriptTargetApi 24
renderscriptSupportModeEnabled true
// `develop` branch will have version code from timestamp, to ensure each build from CI has a incremented versionCode. // `develop` branch will have version code from timestamp, to ensure each build from CI has a incremented versionCode.
// Other branches (master, features, etc.) will have version code based on application version. // Other branches (master, features, etc.) will have version code based on application version.
versionCode project.getVersionCode() versionCode project.getVersionCode()
@ -232,7 +235,7 @@ android {
productFlavors { productFlavors {
gplay { gplay {
dimension "store" dimension "store"
isDefault = true
versionName "${versionMajor}.${versionMinor}.${versionPatch}${getGplayVersionSuffix()}" versionName "${versionMajor}.${versionMinor}.${versionPatch}${getGplayVersionSuffix()}"
resValue "bool", "isGplay", "true" resValue "bool", "isGplay", "true"
@ -319,6 +322,7 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation "androidx.sharetarget:sharetarget:1.0.0" implementation "androidx.sharetarget:sharetarget:1.0.0"
implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.core:core-ktx:1.3.2'
implementation "androidx.media:media:1.2.1"
implementation "org.threeten:threetenbp:1.4.0:no-tzdb" implementation "org.threeten:threetenbp:1.4.0:no-tzdb"
implementation "com.gabrielittner.threetenbp:lazythreetenbp:0.7.0" implementation "com.gabrielittner.threetenbp:lazythreetenbp:0.7.0"
@ -373,6 +377,7 @@ dependencies {
implementation 'me.saket:better-link-movement-method:2.2.0' implementation 'me.saket:better-link-movement-method:2.2.0'
implementation 'com.google.android:flexbox:1.1.1' implementation 'com.google.android:flexbox:1.1.1'
implementation "androidx.autofill:autofill:$autofill_version" implementation "androidx.autofill:autofill:$autofill_version"
implementation 'jp.wasabeef:glide-transformations:4.3.0'
implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12' implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12'
// Custom Tab // Custom Tab
@ -441,6 +446,8 @@ dependencies {
implementation 'com.vanniktech:emoji-material:0.7.0' implementation 'com.vanniktech:emoji-material:0.7.0'
implementation 'com.vanniktech:emoji-google:0.7.0' implementation 'com.vanniktech:emoji-google:0.7.0'
implementation 'im.dlg:android-dialer:1.2.5'
// TESTS // TESTS
testImplementation 'junit:junit:4.13' testImplementation 'junit:junit:4.13'
testImplementation "org.amshove.kluent:kluent-android:$kluent_version" testImplementation "org.amshove.kluent:kluent-android:$kluent_version"

View file

@ -230,7 +230,8 @@
<activity <activity
android:name=".features.attachments.preview.AttachmentsPreviewActivity" android:name=".features.attachments.preview.AttachmentsPreviewActivity"
android:theme="@style/AppTheme.AttachmentsPreview" /> android:theme="@style/AppTheme.AttachmentsPreview" />
<activity android:name=".features.call.VectorCallActivity" /> <activity android:name=".features.call.VectorCallActivity"
android:excludeFromRecents="true"/>
<activity <activity
android:name=".features.call.conference.VectorJitsiActivity" android:name=".features.call.conference.VectorJitsiActivity"
android:configChanges="orientation|screenSize" /> android:configChanges="orientation|screenSize" />
@ -240,6 +241,7 @@
<activity android:name=".features.pin.PinActivity" /> <activity android:name=".features.pin.PinActivity" />
<activity android:name=".features.home.room.detail.search.SearchActivity" /> <activity android:name=".features.home.room.detail.search.SearchActivity" />
<activity android:name=".features.usercode.UserCodeActivity" /> <activity android:name=".features.usercode.UserCodeActivity" />
<activity android:name=".features.call.transfer.CallTransferActivity" />
<!-- Single instance is very important for the custom scheme callback--> <!-- Single instance is very important for the custom scheme callback-->
<activity android:name=".features.auth.ReAuthActivity" <activity android:name=".features.auth.ReAuthActivity"

View file

@ -385,6 +385,11 @@ SOFTWARE.
<br/> <br/>
Copyright 2016 JetRadar Copyright 2016 JetRadar
</li> </li>
<li>
<b>dialogs / android-dialer</b>
<br/>
Copyright (c) 2017-present, dialog LLC <info@dlg.im>
</li>
</ul> </ul>
<pre> <pre>
Apache License Apache License

View file

@ -44,7 +44,7 @@ import im.vector.app.core.di.HasVectorInjector
import im.vector.app.core.di.VectorComponent import im.vector.app.core.di.VectorComponent
import im.vector.app.core.extensions.configureAndStart import im.vector.app.core.extensions.configureAndStart
import im.vector.app.core.rx.RxConfig import im.vector.app.core.rx.RxConfig
import im.vector.app.features.call.WebRtcPeerConnectionManager import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.configuration.VectorConfiguration import im.vector.app.features.configuration.VectorConfiguration
import im.vector.app.features.disclaimer.doNotShowDisclaimerDialog import im.vector.app.features.disclaimer.doNotShowDisclaimerDialog
import im.vector.app.features.lifecycle.VectorActivityLifecycleCallbacks import im.vector.app.features.lifecycle.VectorActivityLifecycleCallbacks
@ -92,7 +92,7 @@ class VectorApplication :
@Inject lateinit var rxConfig: RxConfig @Inject lateinit var rxConfig: RxConfig
@Inject lateinit var popupAlertManager: PopupAlertManager @Inject lateinit var popupAlertManager: PopupAlertManager
@Inject lateinit var pinLocker: PinLocker @Inject lateinit var pinLocker: PinLocker
@Inject lateinit var webRtcPeerConnectionManager: WebRtcPeerConnectionManager @Inject lateinit var callManager: WebRtcCallManager
lateinit var vectorComponent: VectorComponent lateinit var vectorComponent: VectorComponent
@ -177,7 +177,7 @@ class VectorApplication :
}) })
ProcessLifecycleOwner.get().lifecycle.addObserver(appStateHandler) ProcessLifecycleOwner.get().lifecycle.addObserver(appStateHandler)
ProcessLifecycleOwner.get().lifecycle.addObserver(pinLocker) ProcessLifecycleOwner.get().lifecycle.addObserver(pinLocker)
ProcessLifecycleOwner.get().lifecycle.addObserver(webRtcPeerConnectionManager) ProcessLifecycleOwner.get().lifecycle.addObserver(callManager)
// This should be done as early as possible // This should be done as early as possible
// initKnownEmojiHashSet(appContext) // initKnownEmojiHashSet(appContext)

View file

@ -18,7 +18,7 @@ package im.vector.app.core.di
import arrow.core.Option import arrow.core.Option
import im.vector.app.ActiveSessionDataSource import im.vector.app.ActiveSessionDataSource
import im.vector.app.features.call.WebRtcPeerConnectionManager import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.crypto.keysrequest.KeyRequestHandler import im.vector.app.features.crypto.keysrequest.KeyRequestHandler
import im.vector.app.features.crypto.verification.IncomingVerificationRequestHandler import im.vector.app.features.crypto.verification.IncomingVerificationRequestHandler
import im.vector.app.features.notifications.PushRuleTriggerListener import im.vector.app.features.notifications.PushRuleTriggerListener
@ -35,7 +35,7 @@ class ActiveSessionHolder @Inject constructor(private val authenticationService:
private val sessionObservableStore: ActiveSessionDataSource, private val sessionObservableStore: ActiveSessionDataSource,
private val keyRequestHandler: KeyRequestHandler, private val keyRequestHandler: KeyRequestHandler,
private val incomingVerificationRequestHandler: IncomingVerificationRequestHandler, private val incomingVerificationRequestHandler: IncomingVerificationRequestHandler,
private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager, private val callManager: WebRtcCallManager,
private val pushRuleTriggerListener: PushRuleTriggerListener, private val pushRuleTriggerListener: PushRuleTriggerListener,
private val sessionListener: SessionListener, private val sessionListener: SessionListener,
private val imageManager: ImageManager private val imageManager: ImageManager
@ -52,7 +52,7 @@ class ActiveSessionHolder @Inject constructor(private val authenticationService:
incomingVerificationRequestHandler.start(session) incomingVerificationRequestHandler.start(session)
session.addListener(sessionListener) session.addListener(sessionListener)
pushRuleTriggerListener.startWithSession(session) pushRuleTriggerListener.startWithSession(session)
session.callSignalingService().addCallListener(webRtcPeerConnectionManager) session.callSignalingService().addCallListener(callManager)
imageManager.onSessionStarted(session) imageManager.onSessionStarted(session)
} }
@ -60,7 +60,7 @@ class ActiveSessionHolder @Inject constructor(private val authenticationService:
// Do some cleanup first // Do some cleanup first
getSafeActiveSession()?.let { getSafeActiveSession()?.let {
Timber.w("clearActiveSession of ${it.myUserId}") Timber.w("clearActiveSession of ${it.myUserId}")
it.callSignalingService().removeCallListener(webRtcPeerConnectionManager) it.callSignalingService().removeCallListener(callManager)
it.removeListener(sessionListener) it.removeListener(sessionListener)
} }

View file

@ -29,6 +29,7 @@ import im.vector.app.features.auth.ReAuthActivity
import im.vector.app.features.call.CallControlsBottomSheet import im.vector.app.features.call.CallControlsBottomSheet
import im.vector.app.features.call.VectorCallActivity import im.vector.app.features.call.VectorCallActivity
import im.vector.app.features.call.conference.VectorJitsiActivity import im.vector.app.features.call.conference.VectorJitsiActivity
import im.vector.app.features.call.transfer.CallTransferActivity
import im.vector.app.features.createdirect.CreateDirectRoomActivity import im.vector.app.features.createdirect.CreateDirectRoomActivity
import im.vector.app.features.crypto.keysbackup.settings.KeysBackupManageActivity import im.vector.app.features.crypto.keysbackup.settings.KeysBackupManageActivity
import im.vector.app.features.crypto.quads.SharedSecureStorageActivity import im.vector.app.features.crypto.quads.SharedSecureStorageActivity
@ -146,6 +147,7 @@ interface ScreenComponent {
fun inject(activity: VectorJitsiActivity) fun inject(activity: VectorJitsiActivity)
fun inject(activity: SearchActivity) fun inject(activity: SearchActivity)
fun inject(activity: UserCodeActivity) fun inject(activity: UserCodeActivity)
fun inject(activity: CallTransferActivity)
fun inject(activity: ReAuthActivity) fun inject(activity: ReAuthActivity)
/* ========================================================================================== /* ==========================================================================================

View file

@ -29,7 +29,7 @@ import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.pushers.PushersManager import im.vector.app.core.pushers.PushersManager
import im.vector.app.core.utils.AssetReader import im.vector.app.core.utils.AssetReader
import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.call.WebRtcPeerConnectionManager import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.configuration.VectorConfiguration import im.vector.app.features.configuration.VectorConfiguration
import im.vector.app.features.crypto.keysrequest.KeyRequestHandler import im.vector.app.features.crypto.keysrequest.KeyRequestHandler
import im.vector.app.features.crypto.verification.IncomingVerificationRequestHandler import im.vector.app.features.crypto.verification.IncomingVerificationRequestHandler
@ -38,6 +38,7 @@ import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.HomeRoomListDataSource import im.vector.app.features.home.HomeRoomListDataSource
import im.vector.app.features.home.room.detail.RoomDetailPendingActionStore import im.vector.app.features.home.room.detail.RoomDetailPendingActionStore
import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
import im.vector.app.features.html.EventHtmlRenderer import im.vector.app.features.html.EventHtmlRenderer
import im.vector.app.features.html.VectorHtmlCompressor import im.vector.app.features.html.VectorHtmlCompressor
import im.vector.app.features.login.ReAuthHelper import im.vector.app.features.login.ReAuthHelper
@ -156,7 +157,9 @@ interface VectorComponent {
fun pinLocker(): PinLocker fun pinLocker(): PinLocker
fun webRtcPeerConnectionManager(): WebRtcPeerConnectionManager fun webRtcCallManager(): WebRtcCallManager
fun roomSummaryHolder(): RoomSummariesHolder
@Component.Factory @Component.Factory
interface Factory { interface Factory {

View file

@ -22,7 +22,7 @@ import dagger.Binds
import dagger.Module import dagger.Module
import dagger.multibindings.IntoMap import dagger.multibindings.IntoMap
import im.vector.app.core.platform.ConfigurationViewModel import im.vector.app.core.platform.ConfigurationViewModel
import im.vector.app.features.call.SharedActiveCallViewModel import im.vector.app.features.call.SharedKnownCallsViewModel
import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreFromKeyViewModel import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreFromKeyViewModel
import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreFromPassphraseViewModel import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreFromPassphraseViewModel
import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreSharedViewModel import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreSharedViewModel
@ -85,8 +85,8 @@ interface ViewModelModule {
@Binds @Binds
@IntoMap @IntoMap
@ViewModelKey(SharedActiveCallViewModel::class) @ViewModelKey(SharedKnownCallsViewModel::class)
fun bindSharedActiveCallViewModel(viewModel: SharedActiveCallViewModel): ViewModel fun bindSharedActiveCallViewModel(viewModel: SharedKnownCallsViewModel): ViewModel
@Binds @Binds
@IntoMap @IntoMap

View file

@ -18,6 +18,7 @@ package im.vector.app.core.error
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.features.call.dialpad.DialPadLookup
import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.failure.MatrixError
import org.matrix.android.sdk.api.failure.isInvalidPassword import org.matrix.android.sdk.api.failure.isInvalidPassword
@ -112,7 +113,9 @@ class DefaultErrorFormatter @Inject constructor(
throwable.localizedMessage throwable.localizedMessage
} }
} }
else -> throwable.localizedMessage is DialPadLookup.Failure ->
stringProvider.getString(R.string.call_dial_pad_lookup_error)
else -> throwable.localizedMessage
} }
?: stringProvider.getString(R.string.unknown_error) ?: stringProvider.getString(R.string.unknown_error)
} }

View file

@ -17,10 +17,18 @@
package im.vector.app.core.extensions package im.vector.app.core.extensions
// Create a new Set including the provided element if not already present, or removing the element if already present // Create a new Set including the provided element if not already present, or removing the element if already present
fun <T> Set<T>.toggle(element: T): Set<T> { fun <T> Set<T>.toggle(element: T, singleElement: Boolean = false): Set<T> {
return if (contains(element)) { return if (contains(element)) {
minus(element) if (singleElement) {
emptySet()
} else {
minus(element)
}
} else { } else {
plus(element) if (singleElement) {
setOf(element)
} else {
plus(element)
}
} }
} }

View file

@ -18,11 +18,19 @@ package im.vector.app.core.extensions
import android.text.Spannable import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
import android.text.TextPaint
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.text.style.UnderlineSpan import android.text.style.UnderlineSpan
import android.view.View
import android.widget.TextView import android.widget.TextView
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import im.vector.app.R import im.vector.app.R
@ -48,11 +56,13 @@ fun TextView.setTextOrHide(newText: CharSequence?, hideWhenBlank: Boolean = true
* @param coloredTextRes the resource id of the colored part of the text * @param coloredTextRes the resource id of the colored part of the text
* @param colorAttribute attribute of the color. Default to colorAccent * @param colorAttribute attribute of the color. Default to colorAccent
* @param underline true to also underline the text. Default to false * @param underline true to also underline the text. Default to false
* @param onClick attributes to handle click on the colored part if needed
*/ */
fun TextView.setTextWithColoredPart(@StringRes fullTextRes: Int, fun TextView.setTextWithColoredPart(@StringRes fullTextRes: Int,
@StringRes coloredTextRes: Int, @StringRes coloredTextRes: Int,
@AttrRes colorAttribute: Int = R.attr.colorAccent, @AttrRes colorAttribute: Int = R.attr.colorAccent,
underline: Boolean = false) { underline: Boolean = false,
onClick: (() -> Unit)?) {
val coloredPart = resources.getString(coloredTextRes) val coloredPart = resources.getString(coloredTextRes)
// Insert colored part into the full text // Insert colored part into the full text
val fullText = resources.getString(fullTextRes, coloredPart) val fullText = resources.getString(fullTextRes, coloredPart)
@ -65,12 +75,38 @@ fun TextView.setTextWithColoredPart(@StringRes fullTextRes: Int,
text = SpannableString(fullText) text = SpannableString(fullText)
.apply { .apply {
setSpan(foregroundSpan, index, index + coloredPart.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) setSpan(foregroundSpan, index, index + coloredPart.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
if (onClick != null) {
val clickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
onClick()
}
override fun updateDrawState(ds: TextPaint) {
ds.color = color
ds.isUnderlineText = !underline
}
}
setSpan(clickableSpan, index, index + coloredPart.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
movementMethod = LinkMovementMethod.getInstance()
}
if (underline) { if (underline) {
setSpan(UnderlineSpan(), index, index + coloredPart.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) setSpan(UnderlineSpan(), index, index + coloredPart.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
} }
} }
} }
fun TextView.setLeftDrawable(@DrawableRes iconRes: Int, @ColorRes tintColor: Int? = null) {
val icon = if (tintColor != null) {
val tint = ContextCompat.getColor(context, tintColor)
ContextCompat.getDrawable(context, iconRes)?.also {
DrawableCompat.setTint(it.mutate(), tint)
}
} else {
ContextCompat.getDrawable(context, iconRes)
}
setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null)
}
/** /**
* Set long click listener to copy the current text of the TextView to the clipboard and show a Snackbar * Set long click listener to copy the current text of the TextView to the clipboard and show a Snackbar
*/ */

View file

@ -45,6 +45,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.bumptech.glide.util.Util import com.bumptech.glide.util.Util
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.jakewharton.rxbinding3.view.clicks
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.di.ActiveSessionHolder import im.vector.app.core.di.ActiveSessionHolder
@ -87,6 +88,7 @@ import io.reactivex.disposables.Disposable
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.failure.GlobalError import org.matrix.android.sdk.api.failure.GlobalError
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.TimeUnit
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
abstract class VectorBaseActivity<VB: ViewBinding> : AppCompatActivity(), HasScreenInjector { abstract class VectorBaseActivity<VB: ViewBinding> : AppCompatActivity(), HasScreenInjector {
@ -116,6 +118,18 @@ abstract class VectorBaseActivity<VB: ViewBinding> : AppCompatActivity(), HasScr
.disposeOnDestroy() .disposeOnDestroy()
} }
/* ==========================================================================================
* Views
* ========================================================================================== */
protected fun View.debouncedClicks(onClicked: () -> Unit) {
clicks()
.throttleFirst(300, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { onClicked() }
.disposeOnDestroy()
}
/* ========================================================================================== /* ==========================================================================================
* DATA * DATA
* ========================================================================================== */ * ========================================================================================== */

View file

@ -16,33 +16,76 @@
package im.vector.app.core.services package im.vector.app.core.services
import android.app.NotificationChannel
import android.content.Context import android.content.Context
import android.media.Ringtone
import android.media.RingtoneManager
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioManager import android.media.AudioManager
import android.media.MediaPlayer import android.media.MediaPlayer
import android.media.Ringtone
import android.media.RingtoneManager
import android.os.Build import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import im.vector.app.R import im.vector.app.R
import im.vector.app.features.notifications.NotificationUtils
import org.matrix.android.sdk.api.extensions.orFalse
import timber.log.Timber import timber.log.Timber
class CallRingPlayerIncoming( class CallRingPlayerIncoming(
context: Context context: Context,
private val notificationUtils: NotificationUtils
) { ) {
private val applicationContext = context.applicationContext private val applicationContext = context.applicationContext
private var r: Ringtone? = null private var ringtone: Ringtone? = null
private var vibrator: Vibrator? = null
fun start() { private val VIBRATE_PATTERN = longArrayOf(0, 400, 600)
val notification = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
r = RingtoneManager.getRingtone(applicationContext, notification) fun start(fromBg: Boolean) {
Timber.v("## VOIP Starting ringing incomming") val audioManager = applicationContext.getSystemService<AudioManager>()
r?.play() val incomingCallChannel = notificationUtils.getChannelForIncomingCall(fromBg)
val ringerMode = audioManager?.ringerMode
if (ringerMode == AudioManager.RINGER_MODE_NORMAL) {
playRingtoneIfNeeded(incomingCallChannel)
} else if (ringerMode == AudioManager.RINGER_MODE_VIBRATE) {
vibrateIfNeeded(incomingCallChannel)
}
}
private fun playRingtoneIfNeeded(incomingCallChannel: NotificationChannel?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && incomingCallChannel?.sound != null) {
Timber.v("Ringtone already configured by notification channel")
return
}
val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
ringtone = RingtoneManager.getRingtone(applicationContext, ringtoneUri)
Timber.v("Play ringtone for incoming call")
ringtone?.play()
}
private fun vibrateIfNeeded(incomingCallChannel: NotificationChannel?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && incomingCallChannel?.shouldVibrate().orFalse()) {
Timber.v("## Vibration already configured by notification channel")
return
}
vibrator = applicationContext.getSystemService()
Timber.v("Vibrate for incoming call")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val vibrationEffect = VibrationEffect.createWaveform(VIBRATE_PATTERN, 0)
vibrator?.vibrate(vibrationEffect)
} else {
@Suppress("DEPRECATION")
vibrator?.vibrate(VIBRATE_PATTERN, 0)
}
} }
fun stop() { fun stop() {
r?.stop() ringtone?.stop()
ringtone = null
vibrator?.cancel()
vibrator = null
} }
} }
@ -55,12 +98,12 @@ class CallRingPlayerOutgoing(
private var player: MediaPlayer? = null private var player: MediaPlayer? = null
fun start() { fun start() {
val audioManager = applicationContext.getSystemService<AudioManager>()!! val audioManager: AudioManager? = applicationContext.getSystemService()
player?.release() player?.release()
player = createPlayer() player = createPlayer()
// Check if sound is enabled // Check if sound is enabled
val ringerMode = audioManager.ringerMode val ringerMode = audioManager?.ringerMode
if (player != null && ringerMode == AudioManager.RINGER_MODE_NORMAL) { if (player != null && ringerMode == AudioManager.RINGER_MODE_NORMAL) {
try { try {
if (player?.isPlaying == false) { if (player?.isPlaying == false) {
@ -89,14 +132,14 @@ class CallRingPlayerOutgoing(
mediaPlayer.setOnErrorListener(MediaPlayerErrorListener()) mediaPlayer.setOnErrorListener(MediaPlayerErrorListener())
mediaPlayer.isLooping = true mediaPlayer.isLooping = true
if (Build.VERSION.SDK_INT <= 21) { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
@Suppress("DEPRECATION")
mediaPlayer.setAudioStreamType(AudioManager.STREAM_RING)
} else {
mediaPlayer.setAudioAttributes(AudioAttributes.Builder() mediaPlayer.setAudioAttributes(AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.build()) .build())
} else {
@Suppress("DEPRECATION")
mediaPlayer.setAudioStreamType(AudioManager.STREAM_RING)
} }
return mediaPlayer return mediaPlayer
} catch (failure: Throwable) { } catch (failure: Throwable) {

View file

@ -22,30 +22,41 @@ import android.content.Intent
import android.os.Binder import android.os.Binder
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent import android.view.KeyEvent
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.media.session.MediaButtonReceiver import androidx.media.session.MediaButtonReceiver
import com.airbnb.mvrx.MvRx
import im.vector.app.core.extensions.vectorComponent import im.vector.app.core.extensions.vectorComponent
import im.vector.app.features.call.WebRtcPeerConnectionManager import im.vector.app.features.call.CallArgs
import im.vector.app.features.call.VectorCallActivity
import im.vector.app.features.call.telecom.CallConnection import im.vector.app.features.call.telecom.CallConnection
import im.vector.app.features.call.webrtc.WebRtcCall
import im.vector.app.features.call.webrtc.WebRtcCallManager
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.PopupAlertManager
import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.api.util.toMatrixItem
import timber.log.Timber import timber.log.Timber
/** /**
* Foreground service to manage calls * Foreground service to manage calls
*/ */
class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListener, BluetoothHeadsetReceiver.EventListener { class CallService : VectorService() {
private val connections = mutableMapOf<String, CallConnection>() private val connections = mutableMapOf<String, CallConnection>()
private val knownCalls = mutableSetOf<String>()
private lateinit var notificationManager: NotificationManagerCompat
private lateinit var notificationUtils: NotificationUtils private lateinit var notificationUtils: NotificationUtils
private lateinit var webRtcPeerConnectionManager: WebRtcPeerConnectionManager private lateinit var callManager: WebRtcCallManager
private lateinit var avatarRenderer: AvatarRenderer
private lateinit var alertManager: PopupAlertManager
private var callRingPlayerIncoming: CallRingPlayerIncoming? = null private var callRingPlayerIncoming: CallRingPlayerIncoming? = null
private var callRingPlayerOutgoing: CallRingPlayerOutgoing? = null private var callRingPlayerOutgoing: CallRingPlayerOutgoing? = null
private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null
private var bluetoothHeadsetStateReceiver: BluetoothHeadsetReceiver? = null
// A media button receiver receives and helps translate hardware media playback buttons, // A media button receiver receives and helps translate hardware media playback buttons,
// such as those found on wired and wireless headsets, into the appropriate callbacks in your app // such as those found on wired and wireless headsets, into the appropriate callbacks in your app
private var mediaSession: MediaSessionCompat? = null private var mediaSession: MediaSessionCompat? = null
@ -53,7 +64,7 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean { override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean {
val keyEvent = mediaButtonEvent?.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT) ?: return false val keyEvent = mediaButtonEvent?.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT) ?: return false
if (keyEvent.keyCode == KeyEvent.KEYCODE_HEADSETHOOK) { if (keyEvent.keyCode == KeyEvent.KEYCODE_HEADSETHOOK) {
webRtcPeerConnectionManager.headSetButtonTapped() callManager.headSetButtonTapped()
return true return true
} }
return false return false
@ -62,22 +73,19 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
notificationUtils = vectorComponent().notificationUtils() notificationUtils = vectorComponent().notificationUtils()
webRtcPeerConnectionManager = vectorComponent().webRtcPeerConnectionManager() callManager = vectorComponent().webRtcCallManager()
callRingPlayerIncoming = CallRingPlayerIncoming(applicationContext) avatarRenderer = vectorComponent().avatarRenderer()
alertManager = vectorComponent().alertManager()
callRingPlayerIncoming = CallRingPlayerIncoming(applicationContext, notificationUtils)
callRingPlayerOutgoing = CallRingPlayerOutgoing(applicationContext) callRingPlayerOutgoing = CallRingPlayerOutgoing(applicationContext)
wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(this, this)
bluetoothHeadsetStateReceiver = BluetoothHeadsetReceiver.createAndRegister(this, this)
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
callRingPlayerIncoming?.stop() callRingPlayerIncoming?.stop()
callRingPlayerOutgoing?.stop() callRingPlayerOutgoing?.stop()
wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(this, it) }
wiredHeadsetStateReceiver = null
bluetoothHeadsetStateReceiver?.let { BluetoothHeadsetReceiver.unRegister(this, it) }
bluetoothHeadsetStateReceiver = null
mediaSession?.release() mediaSession?.release()
mediaSession = null mediaSession = null
} }
@ -89,21 +97,17 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
setCallback(mediaSessionButtonCallback) setCallback(mediaSessionButtonCallback)
} }
} }
if (intent == null) {
// Service started again by the system.
// TODO What do we do here?
return START_STICKY
}
mediaSession?.let { mediaSession?.let {
// This ensures that the correct callbacks to MediaSessionCompat.Callback // This ensures that the correct callbacks to MediaSessionCompat.Callback
// will be triggered based on the incoming KeyEvent. // will be triggered based on the incoming KeyEvent.
MediaButtonReceiver.handleIntent(it, intent) MediaButtonReceiver.handleIntent(it, intent)
} }
when (intent.action) { when (intent?.action) {
ACTION_INCOMING_RINGING_CALL -> { ACTION_INCOMING_RINGING_CALL -> {
mediaSession?.isActive = true mediaSession?.isActive = true
callRingPlayerIncoming?.start() val fromBg = intent.getBooleanExtra(EXTRA_IS_IN_BG, false)
callRingPlayerIncoming?.start(fromBg)
displayIncomingCallNotification(intent) displayIncomingCallNotification(intent)
} }
ACTION_OUTGOING_RINGING_CALL -> { ACTION_OUTGOING_RINGING_CALL -> {
@ -111,33 +115,28 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
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_NO_ACTIVE_CALL -> hideCallNotifications() 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_ONGOING_CALL_BG -> { ACTION_CALL_TERMINATED -> {
// there is an ongoing call but call activity is in background handleCallTerminated(intent)
displayCallOnGoingInBackground(intent)
} }
else -> { else -> {
// Should not happen handleUnexpectedState(null)
callRingPlayerIncoming?.stop()
callRingPlayerOutgoing?.stop()
myStopSelf()
} }
} }
// We want the system to restore the service if killed // We want the system to restore the service if killed
return START_STICKY return START_REDELIVER_INTENT
} }
// ================================================================================ // ================================================================================
@ -147,64 +146,90 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
/** /**
* Display a permanent notification when there is an incoming call. * Display a permanent notification when there is an incoming call.
* *
* @param session the session
* @param isVideo true if this is a video call, false for voice call
* @param room the room
* @param callId the callId
*/ */
private fun displayIncomingCallNotification(intent: Intent) { private fun displayIncomingCallNotification(intent: Intent) {
Timber.v("## VOIP displayIncomingCallNotification $intent") Timber.v("## VOIP displayIncomingCallNotification $intent")
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
// the incoming call in progress is already displayed val call = callManager.getCallById(callId) ?: return Unit.also {
// if (!TextUtils.isEmpty(mIncomingCallId)) { handleUnexpectedState(callId)
// Timber.v("displayIncomingCallNotification : the incoming call in progress is already displayed") }
// } else if (!TextUtils.isEmpty(mCallIdInProgress)) { val isVideoCall = call.mxCall.isVideoCall
// Timber.v("displayIncomingCallNotification : a 'call in progress' notification is displayed") val fromBg = intent.getBooleanExtra(EXTRA_IS_IN_BG, false)
// } else val opponentMatrixItem = getOpponentMatrixItem(call)
// // if (null == webRtcPeerConnectionManager.currentCall)
// {
val callId = intent.getStringExtra(EXTRA_CALL_ID)
Timber.v("displayIncomingCallNotification : display the dedicated notification") Timber.v("displayIncomingCallNotification : display the dedicated notification")
val incomingCallAlert = IncomingCallAlert(callId,
shouldBeDisplayedIn = { activity ->
if (activity is VectorCallActivity) {
activity.intent.getParcelableExtra<CallArgs>(MvRx.KEY_ARG)?.callId != call.callId
} else true
}
).apply {
viewBinder = IncomingCallAlert.ViewBinder(
matrixItem = opponentMatrixItem,
avatarRenderer = avatarRenderer,
isVideoCall = isVideoCall,
onAccept = { showCallScreen(call, VectorCallActivity.INCOMING_ACCEPT) },
onReject = { call.endCall() }
)
dismissedAction = Runnable { call.endCall() }
contentAction = Runnable { showCallScreen(call, VectorCallActivity.INCOMING_RINGING) }
}
alertManager.postVectorAlert(incomingCallAlert)
val notification = notificationUtils.buildIncomingCallNotification( val notification = notificationUtils.buildIncomingCallNotification(
intent.getBooleanExtra(EXTRA_IS_VIDEO, false), mxCall = call.mxCall,
intent.getStringExtra(EXTRA_ROOM_NAME) ?: "", title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId,
intent.getStringExtra(EXTRA_ROOM_ID) ?: "", fromBg = fromBg
callId ?: "") )
startForeground(NOTIFICATION_ID, notification) if (knownCalls.isEmpty()) {
startForeground(callId.hashCode(), notification)
} else {
notificationManager.notify(callId.hashCode(), notification)
}
knownCalls.add(callId)
}
// mIncomingCallId = callId private fun handleCallTerminated(intent: Intent) {
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
alertManager.cancelAlert(callId)
if (!knownCalls.remove(callId)) {
Timber.v("Call terminated for unknown call $callId$")
handleUnexpectedState(callId)
return
}
val notification = notificationUtils.buildCallEndedNotification()
notificationManager.notify(callId.hashCode(), notification)
if (knownCalls.isEmpty()) {
mediaSession?.isActive = false
myStopSelf()
}
}
// turn the screen on for 3 seconds private fun showCallScreen(call: WebRtcCall, mode: String) {
// if (Matrix.getInstance(VectorApp.getInstance())!!.pushManager.isScreenTurnedOn) { val intent = VectorCallActivity.newIntent(
// try { context = this,
// val pm = getSystemService<PowerManager>()!! mxCall = call.mxCall,
// val wl = pm.newWakeLock( mode = mode
// WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or PowerManager.ACQUIRE_CAUSES_WAKEUP, )
// CallService::class.java.simpleName) startActivity(intent)
// wl.acquire(3000)
// wl.release()
// } catch (re: RuntimeException) {
// Timber.e(re, "displayIncomingCallNotification : failed to turn screen on ")
// }
//
// }
// }
// else {
// Timber.i("displayIncomingCallNotification : do not display the incoming call notification because there is a pending call")
// }
} }
private fun displayOutgoingRingingCallNotification(intent: Intent) { private fun displayOutgoingRingingCallNotification(intent: Intent) {
val callId = intent.getStringExtra(EXTRA_CALL_ID) val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
val call = callManager.getCallById(callId) ?: return Unit.also {
handleUnexpectedState(callId)
}
val opponentMatrixItem = getOpponentMatrixItem(call)
Timber.v("displayOutgoingCallNotification : display the dedicated notification") Timber.v("displayOutgoingCallNotification : display the dedicated notification")
val notification = notificationUtils.buildOutgoingRingingCallNotification( val notification = notificationUtils.buildOutgoingRingingCallNotification(
intent.getBooleanExtra(EXTRA_IS_VIDEO, false), mxCall = call.mxCall,
intent.getStringExtra(EXTRA_ROOM_NAME) ?: "", title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId
intent.getStringExtra(EXTRA_ROOM_ID) ?: "", )
callId ?: "") if (knownCalls.isEmpty()) {
startForeground(NOTIFICATION_ID, notification) startForeground(callId.hashCode(), notification)
} else {
notificationManager.notify(callId.hashCode(), notification)
}
knownCalls.add(callId)
} }
/** /**
@ -213,125 +238,78 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
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) ?: ""
val call = callManager.getCallById(callId) ?: return Unit.also {
handleUnexpectedState(callId)
}
val opponentMatrixItem = getOpponentMatrixItem(call)
alertManager.cancelAlert(callId)
val notification = notificationUtils.buildPendingCallNotification( val notification = notificationUtils.buildPendingCallNotification(
intent.getBooleanExtra(EXTRA_IS_VIDEO, false), mxCall = call.mxCall,
intent.getStringExtra(EXTRA_ROOM_NAME) ?: "", title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId
intent.getStringExtra(EXTRA_ROOM_ID) ?: "", )
intent.getStringExtra(EXTRA_MATRIX_ID) ?: "", if (knownCalls.isEmpty()) {
callId) startForeground(callId.hashCode(), notification)
} else {
startForeground(NOTIFICATION_ID, notification) notificationManager.notify(callId.hashCode(), notification)
}
// mCallIdInProgress = callId knownCalls.add(callId)
} }
/** private fun handleUnexpectedState(callId: String?) {
* Display a call in progress notification. Timber.v("Fallback to clear everything")
*/ callRingPlayerIncoming?.stop()
private fun displayCallOnGoingInBackground(intent: Intent) { callRingPlayerOutgoing?.stop()
Timber.v("## VOIP displayCallInProgressNotification") if (callId != null) {
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: "" notificationManager.cancel(callId.hashCode())
}
val notification = notificationUtils.buildPendingCallNotification(
isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, false),
roomName = intent.getStringExtra(EXTRA_ROOM_NAME) ?: "",
roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: "",
matrixId = intent.getStringExtra(EXTRA_MATRIX_ID) ?: "",
callId = callId,
fromBg = true)
startForeground(NOTIFICATION_ID, notification)
// mCallIdInProgress = callId
}
/**
* Hide the permanent call notifications
*/
private fun hideCallNotifications() {
val notification = notificationUtils.buildCallEndedNotification() val notification = notificationUtils.buildCallEndedNotification()
startForeground(DEFAULT_NOTIFICATION_ID, notification)
mediaSession?.isActive = false if (knownCalls.isEmpty()) {
// It's mandatory to startForeground to avoid crash mediaSession?.isActive = false
startForeground(NOTIFICATION_ID, notification) myStopSelf()
}
myStopSelf()
} }
fun addConnection(callConnection: CallConnection) { fun addConnection(callConnection: CallConnection) {
connections[callConnection.callId] = callConnection connections[callConnection.callId] = callConnection
} }
private fun getOpponentMatrixItem(call: WebRtcCall): MatrixItem? {
return vectorComponent().currentSession().getUser(call.mxCall.opponentUserId)?.toMatrixItem()
}
companion object { companion object {
private const val NOTIFICATION_ID = 6480 private const val DEFAULT_NOTIFICATION_ID = 6480
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"
private const val ACTION_CALL_CONNECTING = "im.vector.app.core.services.CallService.ACTION_CALL_CONNECTING" private const val ACTION_CALL_CONNECTING = "im.vector.app.core.services.CallService.ACTION_CALL_CONNECTING"
private const val ACTION_ONGOING_CALL = "im.vector.app.core.services.CallService.ACTION_ONGOING_CALL" private const val ACTION_ONGOING_CALL = "im.vector.app.core.services.CallService.ACTION_ONGOING_CALL"
private const val ACTION_ONGOING_CALL_BG = "im.vector.app.core.services.CallService.ACTION_ONGOING_CALL_BG" private const val ACTION_CALL_TERMINATED = "im.vector.app.core.services.CallService.ACTION_CALL_TERMINATED"
private const val ACTION_NO_ACTIVE_CALL = "im.vector.app.core.services.CallService.NO_ACTIVE_CALL" private const val ACTION_NO_ACTIVE_CALL = "im.vector.app.core.services.CallService.NO_ACTIVE_CALL"
// private const val ACTION_ACTIVITY_VISIBLE = "im.vector.app.core.services.CallService.ACTION_ACTIVITY_VISIBLE" // private const val ACTION_ACTIVITY_VISIBLE = "im.vector.app.core.services.CallService.ACTION_ACTIVITY_VISIBLE"
// private const val ACTION_STOP_RINGING = "im.vector.app.core.services.CallService.ACTION_STOP_RINGING" // private const val ACTION_STOP_RINGING = "im.vector.app.core.services.CallService.ACTION_STOP_RINGING"
private const val EXTRA_IS_VIDEO = "EXTRA_IS_VIDEO"
private const val EXTRA_ROOM_NAME = "EXTRA_ROOM_NAME"
private const val EXTRA_ROOM_ID = "EXTRA_ROOM_ID"
private const val EXTRA_MATRIX_ID = "EXTRA_MATRIX_ID"
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"
fun onIncomingCallRinging(context: Context, fun onIncomingCallRinging(context: Context,
isVideo: Boolean, callId: String,
roomName: String, isInBackground: Boolean) {
roomId: String,
matrixId: String,
callId: String) {
val intent = Intent(context, CallService::class.java) val intent = Intent(context, CallService::class.java)
.apply { .apply {
action = ACTION_INCOMING_RINGING_CALL action = ACTION_INCOMING_RINGING_CALL
putExtra(EXTRA_IS_VIDEO, isVideo)
putExtra(EXTRA_ROOM_NAME, roomName)
putExtra(EXTRA_ROOM_ID, roomId)
putExtra(EXTRA_MATRIX_ID, matrixId)
putExtra(EXTRA_CALL_ID, callId) putExtra(EXTRA_CALL_ID, callId)
putExtra(EXTRA_IS_IN_BG, isInBackground)
} }
ContextCompat.startForegroundService(context, intent)
}
fun onOnGoingCallBackground(context: Context,
isVideo: Boolean,
roomName: String,
roomId: String,
matrixId: String,
callId: String) {
val intent = Intent(context, CallService::class.java)
.apply {
action = ACTION_ONGOING_CALL_BG
putExtra(EXTRA_IS_VIDEO, isVideo)
putExtra(EXTRA_ROOM_NAME, roomName)
putExtra(EXTRA_ROOM_ID, roomId)
putExtra(EXTRA_MATRIX_ID, matrixId)
putExtra(EXTRA_CALL_ID, callId)
}
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }
fun onOutgoingCallRinging(context: Context, fun onOutgoingCallRinging(context: Context,
isVideo: Boolean,
roomName: String,
roomId: String,
matrixId: String,
callId: String) { callId: String) {
val intent = Intent(context, CallService::class.java) val intent = Intent(context, CallService::class.java)
.apply { .apply {
action = ACTION_OUTGOING_RINGING_CALL action = ACTION_OUTGOING_RINGING_CALL
putExtra(EXTRA_IS_VIDEO, isVideo)
putExtra(EXTRA_ROOM_NAME, roomName)
putExtra(EXTRA_ROOM_ID, roomId)
putExtra(EXTRA_MATRIX_ID, matrixId)
putExtra(EXTRA_CALL_ID, callId) putExtra(EXTRA_CALL_ID, callId)
} }
@ -339,30 +317,22 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
} }
fun onPendingCall(context: Context, fun onPendingCall(context: Context,
isVideo: Boolean,
roomName: String,
roomId: String,
matrixId: String,
callId: String) { callId: String) {
val intent = Intent(context, CallService::class.java) val intent = Intent(context, CallService::class.java)
.apply { .apply {
action = ACTION_ONGOING_CALL action = ACTION_ONGOING_CALL
putExtra(EXTRA_IS_VIDEO, isVideo)
putExtra(EXTRA_ROOM_NAME, roomName)
putExtra(EXTRA_ROOM_ID, roomId)
putExtra(EXTRA_MATRIX_ID, matrixId)
putExtra(EXTRA_CALL_ID, callId) putExtra(EXTRA_CALL_ID, callId)
} }
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }
fun onNoActiveCall(context: Context) { fun onCallTerminated(context: Context, callId: String) {
val intent = Intent(context, CallService::class.java) val intent = Intent(context, CallService::class.java)
.apply { .apply {
action = ACTION_NO_ACTIVE_CALL action = ACTION_CALL_TERMINATED
putExtra(EXTRA_CALL_ID, callId)
} }
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }
} }
@ -372,14 +342,4 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
return this@CallService return this@CallService
} }
} }
override fun onHeadsetEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) {
Timber.v("## VOIP: onHeadsetEvent $event")
webRtcPeerConnectionManager.onWiredDeviceEvent(event)
}
override fun onBTHeadsetEvent(event: BluetoothHeadsetReceiver.BTHeadsetPlugEvent) {
Timber.v("## VOIP: onBTHeadsetEvent $event")
webRtcPeerConnectionManager.onWirelessDeviceEvent(event)
}
} }

View file

@ -41,13 +41,13 @@ class BottomSheetActionButton @JvmOverloads constructor(
var title: String? = null var title: String? = null
set(value) { set(value) {
field = value field = value
views.itemVerificationActionTitle.setTextOrHide(value) views.bottomSheetActionTitle.setTextOrHide(value)
} }
var subTitle: String? = null var subTitle: String? = null
set(value) { set(value) {
field = value field = value
views.itemVerificationActionSubTitle.setTextOrHide(value) views.bottomSheetActionSubTitle.setTextOrHide(value)
} }
var forceStartPadding: Boolean? = null var forceStartPadding: Boolean? = null
@ -55,9 +55,9 @@ class BottomSheetActionButton @JvmOverloads constructor(
field = value field = value
if (leftIcon == null) { if (leftIcon == null) {
if (forceStartPadding == true) { if (forceStartPadding == true) {
views.itemVerificationLeftIcon.isInvisible = true views.bottomSheetActionLeftIcon.isInvisible = true
} else { } else {
views.itemVerificationLeftIcon.isGone = true views.bottomSheetActionLeftIcon.isGone = true
} }
} }
} }
@ -67,33 +67,33 @@ class BottomSheetActionButton @JvmOverloads constructor(
field = value field = value
if (value == null) { if (value == null) {
if (forceStartPadding == true) { if (forceStartPadding == true) {
views.itemVerificationLeftIcon.isInvisible = true views.bottomSheetActionLeftIcon.isInvisible = true
} else { } else {
views.itemVerificationLeftIcon.isGone = true views.bottomSheetActionLeftIcon.isGone = true
} }
views.itemVerificationLeftIcon.setImageDrawable(null) views.bottomSheetActionLeftIcon.setImageDrawable(null)
} else { } else {
views.itemVerificationLeftIcon.isVisible = true views.bottomSheetActionLeftIcon.isVisible = true
views.itemVerificationLeftIcon.setImageDrawable(value) views.bottomSheetActionLeftIcon.setImageDrawable(value)
} }
} }
var rightIcon: Drawable? = null var rightIcon: Drawable? = null
set(value) { set(value) {
field = value field = value
views.itemVerificationActionIcon.setImageDrawable(value) views.bottomSheetActionIcon.setImageDrawable(value)
} }
var tint: Int? = null var tint: Int? = null
set(value) { set(value) {
field = value field = value
views.itemVerificationLeftIcon.imageTintList = value?.let { ColorStateList.valueOf(value) } views.bottomSheetActionLeftIcon.imageTintList = value?.let { ColorStateList.valueOf(value) }
} }
var titleTextColor: Int? = null var titleTextColor: Int? = null
set(value) { set(value) {
field = value field = value
value?.let { views.itemVerificationActionTitle.setTextColor(it) } value?.let { views.bottomSheetActionTitle.setTextColor(it) }
} }
init { init {

View file

@ -20,9 +20,12 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.RelativeLayout import android.widget.RelativeLayout
import im.vector.app.R import im.vector.app.R
import im.vector.app.databinding.ViewCurrentCallsBinding
import im.vector.app.features.call.webrtc.WebRtcCall
import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.themes.ThemeUtils
import org.matrix.android.sdk.api.session.call.CallState
class ActiveCallView @JvmOverloads constructor( class CurrentCallsView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = 0 defStyleAttr: Int = 0
@ -32,15 +35,32 @@ class ActiveCallView @JvmOverloads constructor(
fun onTapToReturnToCall() fun onTapToReturnToCall()
} }
val views: ViewCurrentCallsBinding
var callback: Callback? = null var callback: Callback? = null
init { init {
setupView() inflate(context, R.layout.view_current_calls, this)
} views = ViewCurrentCallsBinding.bind(this)
private fun setupView() {
inflate(context, R.layout.view_active_call_view, this)
setBackgroundColor(ThemeUtils.getColor(context, R.attr.colorPrimary)) setBackgroundColor(ThemeUtils.getColor(context, R.attr.colorPrimary))
setOnClickListener { callback?.onTapToReturnToCall() } setOnClickListener { callback?.onTapToReturnToCall() }
} }
fun render(calls: List<WebRtcCall>, formattedDuration: String) {
val connectedCalls = calls.filter {
it.mxCall.state is CallState.Connected
}
val heldCalls = connectedCalls.filter {
it.isLocalOnHold || it.remoteOnHold
}
if (connectedCalls.isEmpty()) return
views.currentCallsInfo.text = if (connectedCalls.size == heldCalls.size) {
resources.getQuantityString(R.plurals.call_only_paused, heldCalls.size, heldCalls.size)
} else {
if (heldCalls.isEmpty()) {
resources.getString(R.string.call_only_active, formattedDuration)
} else {
resources.getQuantityString(R.plurals.call_one_active_and_other_paused, heldCalls.size, formattedDuration, heldCalls.size)
}
}
}
} }

View file

@ -16,42 +16,56 @@
package im.vector.app.core.ui.views package im.vector.app.core.ui.views
import android.view.View
import androidx.cardview.widget.CardView import androidx.cardview.widget.CardView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import im.vector.app.core.utils.DebouncedClickListener import im.vector.app.core.utils.DebouncedClickListener
import im.vector.app.features.call.WebRtcPeerConnectionManager
import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.EglUtils import im.vector.app.features.call.utils.EglUtils
import org.matrix.android.sdk.api.session.call.MxCall import im.vector.app.features.call.webrtc.WebRtcCall
import org.webrtc.RendererCommon import org.webrtc.RendererCommon
import org.webrtc.SurfaceViewRenderer import org.webrtc.SurfaceViewRenderer
class ActiveCallViewHolder { class KnownCallsViewHolder {
private var activeCallPiP: SurfaceViewRenderer? = null private var activeCallPiP: SurfaceViewRenderer? = null
private var activeCallView: ActiveCallView? = null private var currentCallsView: CurrentCallsView? = null
private var pipWrapper: CardView? = null private var pipWrapper: CardView? = null
private var currentCall: WebRtcCall? = null
private var calls: List<WebRtcCall> = emptyList()
private var activeCallPipInitialized = false private var activeCallPipInitialized = false
fun updateCall(activeCall: MxCall?, webRtcPeerConnectionManager: WebRtcPeerConnectionManager) { private val tickListener = object : WebRtcCall.Listener {
val hasActiveCall = activeCall?.state is CallState.Connected override fun onTick(formattedDuration: String) {
currentCallsView?.render(calls, formattedDuration)
}
}
fun updateCall(currentCall: WebRtcCall?, calls: List<WebRtcCall>) {
activeCallPiP?.let {
this.currentCall?.detachRenderers(listOf(it))
}
this.currentCall?.removeListener(tickListener)
this.currentCall = currentCall
this.currentCall?.addListener(tickListener)
this.calls = calls
val hasActiveCall = currentCall?.mxCall?.state is CallState.Connected
if (hasActiveCall) { if (hasActiveCall) {
val isVideoCall = activeCall?.isVideoCall == true val isVideoCall = currentCall?.mxCall?.isVideoCall == true
if (isVideoCall) initIfNeeded() if (isVideoCall) initIfNeeded()
activeCallView?.isVisible = !isVideoCall currentCallsView?.isVisible = !isVideoCall
currentCallsView?.render(calls, currentCall?.formattedDuration() ?: "")
pipWrapper?.isVisible = isVideoCall pipWrapper?.isVisible = isVideoCall
activeCallPiP?.isVisible = isVideoCall activeCallPiP?.isVisible = isVideoCall
activeCallPiP?.let { activeCallPiP?.let {
webRtcPeerConnectionManager.attachViewRenderers(null, it, null) currentCall?.attachViewRenderers(null, it, null)
} }
} else { } else {
activeCallView?.isVisible = false currentCallsView?.isVisible = false
activeCallPiP?.isVisible = false activeCallPiP?.isVisible = false
pipWrapper?.isVisible = false pipWrapper?.isVisible = false
activeCallPiP?.let { activeCallPiP?.let {
webRtcPeerConnectionManager.detachRenderers(listOf(it)) currentCall?.detachRenderers(listOf(it))
} }
} }
} }
@ -69,30 +83,31 @@ class ActiveCallViewHolder {
} }
} }
fun bind(activeCallPiP: SurfaceViewRenderer, activeCallView: ActiveCallView, pipWrapper: CardView, interactionListener: ActiveCallView.Callback) { fun bind(activeCallPiP: SurfaceViewRenderer, activeCallView: CurrentCallsView, pipWrapper: CardView, interactionListener: CurrentCallsView.Callback) {
this.activeCallPiP = activeCallPiP this.activeCallPiP = activeCallPiP
this.activeCallView = activeCallView this.currentCallsView = activeCallView
this.pipWrapper = pipWrapper this.pipWrapper = pipWrapper
this.currentCallsView?.callback = interactionListener
this.activeCallView?.callback = interactionListener
pipWrapper.setOnClickListener( pipWrapper.setOnClickListener(
DebouncedClickListener(View.OnClickListener { _ -> DebouncedClickListener({ _ ->
interactionListener.onTapToReturnToCall() interactionListener.onTapToReturnToCall()
}) })
) )
this.currentCall?.addListener(tickListener)
} }
fun unBind(webRtcPeerConnectionManager: WebRtcPeerConnectionManager) { fun unBind() {
activeCallPiP?.let { activeCallPiP?.let {
webRtcPeerConnectionManager.detachRenderers(listOf(it)) currentCall?.detachRenderers(listOf(it))
} }
if (activeCallPipInitialized) { if (activeCallPipInitialized) {
activeCallPiP?.release() activeCallPiP?.release()
} }
this.activeCallView?.callback = null this.currentCallsView?.callback = null
this.currentCall?.removeListener(tickListener)
pipWrapper?.setOnClickListener(null) pipWrapper?.setOnClickListener(null)
activeCallPiP = null activeCallPiP = null
activeCallView = null currentCallsView = null
pipWrapper = null pipWrapper = null
} }
} }

View file

@ -0,0 +1,57 @@
/*
* Copyright 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.app.core.utils
import io.reactivex.Observable
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
class CountUpTimer(private val intervalInMs: Long) {
private val elapsedTime: AtomicLong = AtomicLong()
private val resumed: AtomicBoolean = AtomicBoolean(false)
private val disposable = Observable.interval(intervalInMs, TimeUnit.MILLISECONDS)
.filter { _ -> resumed.get() }
.doOnNext { _ -> elapsedTime.addAndGet(intervalInMs) }
.subscribe {
tickListener?.onTick(elapsedTime.get())
}
var tickListener: TickListener? = null
fun elapsedTime(): Long {
return elapsedTime.get()
}
fun pause() {
resumed.set(false)
}
fun resume() {
resumed.set(true)
}
fun stop() {
disposable.dispose()
}
interface TickListener {
fun onTick(milliseconds: Long)
}
}

View file

@ -1,318 +0,0 @@
/*
* 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.app.features.call
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioManager
import androidx.core.content.getSystemService
import im.vector.app.core.services.WiredHeadsetStateReceiver
import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCall
import timber.log.Timber
import java.util.concurrent.Executors
class CallAudioManager(
val applicationContext: Context,
val configChange: (() -> Unit)?
) {
enum class SoundDevice {
PHONE,
SPEAKER,
HEADSET,
WIRELESS_HEADSET
}
// if all calls to audio manager not in the same thread it's not working well.
private val executor = Executors.newSingleThreadExecutor()
private var audioManager: AudioManager? = null
private var savedIsSpeakerPhoneOn = false
private var savedIsMicrophoneMute = false
private var savedAudioMode = AudioManager.MODE_NORMAL
private var connectedBlueToothHeadset: BluetoothProfile? = null
private var wantsBluetoothConnection = false
private var bluetoothAdapter: BluetoothAdapter? = null
init {
executor.execute {
audioManager = applicationContext.getSystemService()
}
val bm = applicationContext.getSystemService<BluetoothManager>()
val adapter = bm?.adapter
Timber.d("## VOIP Bluetooth adapter $adapter")
bluetoothAdapter = adapter
adapter?.getProfileProxy(applicationContext, object : BluetoothProfile.ServiceListener {
override fun onServiceDisconnected(profile: Int) {
Timber.d("## VOIP onServiceDisconnected $profile")
if (profile == BluetoothProfile.HEADSET) {
connectedBlueToothHeadset = null
configChange?.invoke()
}
}
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) {
Timber.d("## VOIP onServiceConnected $profile , proxy:$proxy")
if (profile == BluetoothProfile.HEADSET) {
connectedBlueToothHeadset = proxy
configChange?.invoke()
}
}
}, BluetoothProfile.HEADSET)
}
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
// Called on the listener to notify if the audio focus for this listener has been changed.
// The |focusChange| value indicates whether the focus was gained, whether the focus was lost,
// and whether that loss is transient, or whether the new focus holder will hold it for an
// unknown amount of time.
Timber.v("## VOIP: Audio focus change $focusChange")
}
fun startForCall(mxCall: MxCall) {
Timber.v("## VOIP: AudioManager startForCall ${mxCall.callId}")
}
private fun setupAudioManager(mxCall: MxCall) {
Timber.v("## VOIP: AudioManager setupAudioManager ${mxCall.callId}")
val audioManager = audioManager ?: return
savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn
savedIsMicrophoneMute = audioManager.isMicrophoneMute
savedAudioMode = audioManager.mode
// Request audio playout focus (without ducking) and install listener for changes in focus.
// Remove the deprecation forces us to use 2 different method depending on API level
@Suppress("DEPRECATION") val result = audioManager.requestAudioFocus(audioFocusChangeListener,
AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Timber.d("## VOIP Audio focus request granted for VOICE_CALL streams")
} else {
Timber.d("## VOIP Audio focus request failed")
}
// Start by setting MODE_IN_COMMUNICATION as default audio mode. It is
// required to be in this mode when playout and/or recording starts for
// best possible VoIP performance.
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
// Always disable microphone mute during a WebRTC call.
setMicrophoneMute(false)
adjustCurrentSoundDevice(mxCall)
}
private fun adjustCurrentSoundDevice(mxCall: MxCall) {
val audioManager = audioManager ?: return
executor.execute {
if (mxCall.state == CallState.LocalRinging && !isHeadsetOn()) {
// Always use speaker if incoming call is in ringing state and a headset is not connected
Timber.v("##VOIP: AudioManager default to SPEAKER (it is ringing)")
setCurrentSoundDevice(SoundDevice.SPEAKER)
} else if (mxCall.isVideoCall && !isHeadsetOn()) {
// If there are no headset, start video output in speaker
// (you can't watch the video and have the phone close to your ear)
Timber.v("##VOIP: AudioManager default to speaker ")
setCurrentSoundDevice(SoundDevice.SPEAKER)
} else {
// if a wired headset is plugged, sound will be directed to it
// (can't really force earpiece when headset is plugged)
if (isBluetoothHeadsetConnected(audioManager)) {
Timber.v("##VOIP: AudioManager default to WIRELESS_HEADSET ")
setCurrentSoundDevice(SoundDevice.WIRELESS_HEADSET)
// try now in case already connected?
audioManager.isBluetoothScoOn = true
} else {
Timber.v("##VOIP: AudioManager default to PHONE/HEADSET ")
setCurrentSoundDevice(if (isWiredHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE)
}
}
}
}
fun onCallConnected(mxCall: MxCall) {
Timber.v("##VOIP: AudioManager call answered, adjusting current sound device")
setupAudioManager(mxCall)
}
fun getAvailableSoundDevices(): List<SoundDevice> {
return ArrayList<SoundDevice>().apply {
if (isBluetoothHeadsetOn()) add(SoundDevice.WIRELESS_HEADSET)
add(if (isWiredHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE)
add(SoundDevice.SPEAKER)
}
}
fun stop() {
Timber.v("## VOIP: AudioManager stopCall")
executor.execute {
// Restore previously stored audio states.
setSpeakerphoneOn(savedIsSpeakerPhoneOn)
setMicrophoneMute(savedIsMicrophoneMute)
audioManager?.mode = savedAudioMode
connectedBlueToothHeadset?.let {
if (audioManager != null && isBluetoothHeadsetConnected(audioManager!!)) {
audioManager?.stopBluetoothSco()
audioManager?.isBluetoothScoOn = false
audioManager?.isSpeakerphoneOn = false
}
bluetoothAdapter?.closeProfileProxy(BluetoothProfile.HEADSET, it)
}
audioManager?.mode = AudioManager.MODE_NORMAL
@Suppress("DEPRECATION")
audioManager?.abandonAudioFocus(audioFocusChangeListener)
}
}
fun getCurrentSoundDevice(): SoundDevice {
val audioManager = audioManager ?: return SoundDevice.PHONE
if (audioManager.isSpeakerphoneOn) {
return SoundDevice.SPEAKER
} else {
if (isBluetoothHeadsetConnected(audioManager)) return SoundDevice.WIRELESS_HEADSET
return if (isHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE
}
}
private fun isBluetoothHeadsetConnected(audioManager: AudioManager) =
isBluetoothHeadsetOn()
&& !connectedBlueToothHeadset?.connectedDevices.isNullOrEmpty()
&& (wantsBluetoothConnection || audioManager.isBluetoothScoOn)
fun setCurrentSoundDevice(device: SoundDevice) {
executor.execute {
Timber.v("## VOIP setCurrentSoundDevice $device")
when (device) {
SoundDevice.HEADSET,
SoundDevice.PHONE -> {
wantsBluetoothConnection = false
if (isBluetoothHeadsetOn()) {
audioManager?.stopBluetoothSco()
audioManager?.isBluetoothScoOn = false
}
setSpeakerphoneOn(false)
}
SoundDevice.SPEAKER -> {
setSpeakerphoneOn(true)
wantsBluetoothConnection = false
audioManager?.stopBluetoothSco()
audioManager?.isBluetoothScoOn = false
}
SoundDevice.WIRELESS_HEADSET -> {
setSpeakerphoneOn(false)
// I cannot directly do it, i have to start then wait that it's connected
// to route to bt
audioManager?.startBluetoothSco()
wantsBluetoothConnection = true
}
}
configChange?.invoke()
}
}
fun bluetoothStateChange(plugged: Boolean) {
executor.execute {
if (plugged && wantsBluetoothConnection) {
audioManager?.isBluetoothScoOn = true
} else if (!plugged && !wantsBluetoothConnection) {
audioManager?.stopBluetoothSco()
}
configChange?.invoke()
}
}
fun wiredStateChange(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) {
executor.execute {
// if it's plugged and speaker is on we should route to headset
if (event.plugged && getCurrentSoundDevice() == SoundDevice.SPEAKER) {
setCurrentSoundDevice(CallAudioManager.SoundDevice.HEADSET)
} else if (!event.plugged) {
// if it's unplugged ? always route to speaker?
// this is questionable?
if (!wantsBluetoothConnection) {
setCurrentSoundDevice(SoundDevice.SPEAKER)
}
}
configChange?.invoke()
}
}
private fun isHeadsetOn(): Boolean {
return isWiredHeadsetOn() || (audioManager?.let { isBluetoothHeadsetConnected(it) } ?: false)
}
private fun isWiredHeadsetOn(): Boolean {
@Suppress("DEPRECATION")
return audioManager?.isWiredHeadsetOn ?: false
}
private fun isBluetoothHeadsetOn(): Boolean {
Timber.v("## VOIP: AudioManager isBluetoothHeadsetOn")
try {
if (connectedBlueToothHeadset == null) return false.also {
Timber.v("## VOIP: AudioManager no connected bluetooth headset")
}
if (audioManager?.isBluetoothScoAvailableOffCall == false) return false.also {
Timber.v("## VOIP: AudioManager isBluetoothScoAvailableOffCall false")
}
return true
} catch (failure: Throwable) {
Timber.e("## VOIP: AudioManager isBluetoothHeadsetOn failure ${failure.localizedMessage}")
return false
}
}
/** Sets the speaker phone mode. */
private fun setSpeakerphoneOn(on: Boolean) {
Timber.v("## VOIP: AudioManager setSpeakerphoneOn $on")
val wasOn = audioManager?.isSpeakerphoneOn ?: false
if (wasOn == on) {
return
}
audioManager?.isSpeakerphoneOn = on
}
/** Sets the microphone mute state. */
private fun setMicrophoneMute(on: Boolean) {
Timber.v("## VOIP: AudioManager setMicrophoneMute $on")
val wasMuted = audioManager?.isMicrophoneMute ?: false
if (wasMuted == on) {
return
}
audioManager?.isMicrophoneMute = on
}
/** true if the device has a telephony radio with data
* communication support. */
private fun isThisPhone(): Boolean {
return applicationContext.packageManager.hasSystemFeature(
PackageManager.FEATURE_TELEPHONY)
}
}

View file

@ -27,6 +27,7 @@ import com.airbnb.mvrx.activityViewModel
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.app.databinding.BottomSheetCallControlsBinding import im.vector.app.databinding.BottomSheetCallControlsBinding
import im.vector.app.features.call.audio.CallAudioManager
import me.gujun.android.span.span import me.gujun.android.span.span
@ -44,20 +45,34 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetC
renderState(it) renderState(it)
} }
views.callControlsSoundDevice.views.itemVerificationClickableZone.debouncedClicks { views.callControlsSoundDevice.views.bottomSheetActionClickableZone.debouncedClicks {
callViewModel.handle(VectorCallViewActions.SwitchSoundDevice) callViewModel.handle(VectorCallViewActions.SwitchSoundDevice)
} }
views.callControlsSwitchCamera.views.itemVerificationClickableZone.debouncedClicks { views.callControlsSwitchCamera.views.bottomSheetActionClickableZone.debouncedClicks {
callViewModel.handle(VectorCallViewActions.ToggleCamera) callViewModel.handle(VectorCallViewActions.ToggleCamera)
dismiss() dismiss()
} }
views.callControlsToggleSDHD.views.itemVerificationClickableZone.debouncedClicks { views.callControlsToggleSDHD.views.bottomSheetActionClickableZone.debouncedClicks {
callViewModel.handle(VectorCallViewActions.ToggleHDSD) callViewModel.handle(VectorCallViewActions.ToggleHDSD)
dismiss() dismiss()
} }
views.callControlsToggleHoldResume.views.bottomSheetActionClickableZone.debouncedClicks {
callViewModel.handle(VectorCallViewActions.ToggleHoldResume)
dismiss()
}
views.callControlsOpenDialPad.views.bottomSheetActionClickableZone.debouncedClicks {
callViewModel.handle(VectorCallViewActions.OpenDialPad)
}
views.callControlsTransfer.views.bottomSheetActionClickableZone.debouncedClicks {
callViewModel.handle(VectorCallViewActions.InitiateCallTransfer)
dismiss()
}
callViewModel.observeViewEvents { callViewModel.observeViewEvents {
when (it) { when (it) {
is VectorCallViewEvents.ShowSoundDeviceChooser -> { is VectorCallViewEvents.ShowSoundDeviceChooser -> {
@ -69,22 +84,22 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetC
} }
} }
private fun showSoundDeviceChooser(available: List<CallAudioManager.SoundDevice>, current: CallAudioManager.SoundDevice) { private fun showSoundDeviceChooser(available: Set<CallAudioManager.Device>, current: CallAudioManager.Device) {
val soundDevices = available.map { val soundDevices = available.map {
when (it) { when (it) {
CallAudioManager.SoundDevice.WIRELESS_HEADSET -> span { CallAudioManager.Device.WIRELESS_HEADSET -> span {
text = getString(R.string.sound_device_wireless_headset) text = getString(R.string.sound_device_wireless_headset)
textStyle = if (current == it) "bold" else "normal" textStyle = if (current == it) "bold" else "normal"
} }
CallAudioManager.SoundDevice.PHONE -> span { CallAudioManager.Device.PHONE -> span {
text = getString(R.string.sound_device_phone) text = getString(R.string.sound_device_phone)
textStyle = if (current == it) "bold" else "normal" textStyle = if (current == it) "bold" else "normal"
} }
CallAudioManager.SoundDevice.SPEAKER -> span { CallAudioManager.Device.SPEAKER -> span {
text = getString(R.string.sound_device_speaker) text = getString(R.string.sound_device_speaker)
textStyle = if (current == it) "bold" else "normal" textStyle = if (current == it) "bold" else "normal"
} }
CallAudioManager.SoundDevice.HEADSET -> span { CallAudioManager.Device.HEADSET -> span {
text = getString(R.string.sound_device_headset) text = getString(R.string.sound_device_headset)
textStyle = if (current == it) "bold" else "normal" textStyle = if (current == it) "bold" else "normal"
} }
@ -95,17 +110,17 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetC
d.cancel() d.cancel()
when (soundDevices[n].toString()) { when (soundDevices[n].toString()) {
// TODO Make an adapter and handle multiple Bluetooth headsets. Also do not use translations. // TODO Make an adapter and handle multiple Bluetooth headsets. Also do not use translations.
getString(R.string.sound_device_phone) -> { getString(R.string.sound_device_phone) -> {
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.PHONE)) callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.Device.PHONE))
} }
getString(R.string.sound_device_speaker) -> { getString(R.string.sound_device_speaker) -> {
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.SPEAKER)) callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.Device.SPEAKER))
} }
getString(R.string.sound_device_headset) -> { getString(R.string.sound_device_headset) -> {
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.HEADSET)) callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.Device.HEADSET))
} }
getString(R.string.sound_device_wireless_headset) -> { getString(R.string.sound_device_wireless_headset) -> {
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.WIRELESS_HEADSET)) callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.Device.WIRELESS_HEADSET))
} }
} }
} }
@ -115,11 +130,11 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetC
private fun renderState(state: VectorCallViewState) { private fun renderState(state: VectorCallViewState) {
views.callControlsSoundDevice.title = getString(R.string.call_select_sound_device) views.callControlsSoundDevice.title = getString(R.string.call_select_sound_device)
views.callControlsSoundDevice.subTitle = when (state.soundDevice) { views.callControlsSoundDevice.subTitle = when (state.device) {
CallAudioManager.SoundDevice.PHONE -> getString(R.string.sound_device_phone) CallAudioManager.Device.PHONE -> getString(R.string.sound_device_phone)
CallAudioManager.SoundDevice.SPEAKER -> getString(R.string.sound_device_speaker) CallAudioManager.Device.SPEAKER -> getString(R.string.sound_device_speaker)
CallAudioManager.SoundDevice.HEADSET -> getString(R.string.sound_device_headset) CallAudioManager.Device.HEADSET -> getString(R.string.sound_device_headset)
CallAudioManager.SoundDevice.WIRELESS_HEADSET -> getString(R.string.sound_device_wireless_headset) CallAudioManager.Device.WIRELESS_HEADSET -> getString(R.string.sound_device_wireless_headset)
} }
views.callControlsSwitchCamera.isVisible = state.isVideoCall && state.canSwitchCamera views.callControlsSwitchCamera.isVisible = state.isVideoCall && state.canSwitchCamera
@ -139,5 +154,15 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetC
} else { } else {
views.callControlsToggleSDHD.isVisible = false views.callControlsToggleSDHD.isVisible = false
} }
if (state.isRemoteOnHold) {
views.callControlsToggleHoldResume.title = getString(R.string.call_resume_action)
views.callControlsToggleHoldResume.subTitle = null
views.callControlsToggleHoldResume.leftIcon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_call_resume_action)
} else {
views.callControlsToggleHoldResume.title = getString(R.string.call_hold_action)
views.callControlsToggleHoldResume.subTitle = null
views.callControlsToggleHoldResume.leftIcon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_call_hold_action)
}
views.callControlsTransfer.isVisible = state.canOpponentBeTransferred
} }
} }

View file

@ -22,9 +22,8 @@ import android.widget.FrameLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import im.vector.app.R import im.vector.app.R
import im.vector.app.databinding.ViewCallControlsBinding import im.vector.app.databinding.ViewCallControlsBinding
import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.CallState
import org.webrtc.PeerConnection import org.matrix.android.sdk.api.session.call.MxPeerConnectionState
class CallControlsView @JvmOverloads constructor( class CallControlsView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
@ -36,16 +35,15 @@ class CallControlsView @JvmOverloads constructor(
init { init {
inflate(context, R.layout.view_call_controls, this) inflate(context, R.layout.view_call_controls, this)
// layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
views = ViewCallControlsBinding.bind(this) views = ViewCallControlsBinding.bind(this)
views.ringingControlAccept.setOnClickListener { acceptIncomingCall() } views.ringingControlAccept.setOnClickListener { acceptIncomingCall() }
views.ringingControlDecline.setOnClickListener { declineIncomingCall() } views.ringingControlDecline.setOnClickListener { declineIncomingCall() }
views.ivEndCall.setOnClickListener { endOngoingCall() } views.endCallIcon.setOnClickListener { endOngoingCall() }
views.muteIcon.setOnClickListener { toggleMute() } views.muteIcon.setOnClickListener { toggleMute() }
views.videoToggleIcon.setOnClickListener { toggleVideo() } views.videoToggleIcon.setOnClickListener { toggleVideo() }
views.ivLeftMiniControl.setOnClickListener { returnToChat() } views.openChatIcon.setOnClickListener { returnToChat() }
views.ivMore.setOnClickListener { moreControlOption() } views.moreIcon.setOnClickListener { moreControlOption() }
} }
private fun acceptIncomingCall() { private fun acceptIncomingCall() {
@ -109,7 +107,7 @@ class CallControlsView @JvmOverloads constructor(
views.connectedControls.isVisible = false views.connectedControls.isVisible = false
} }
is CallState.Connected -> { is CallState.Connected -> {
if (callState.iceConnectionState == PeerConnection.PeerConnectionState.CONNECTED) { if (callState.iceConnectionState == MxPeerConnectionState.CONNECTED) {
views.ringingControls.isVisible = false views.ringingControls.isVisible = false
views.connectedControls.isVisible = true views.connectedControls.isVisible = true
views.videoToggleIcon.isVisible = state.isVideoCall views.videoToggleIcon.isVisible = state.isVideoCall

View file

@ -0,0 +1,46 @@
/*
* 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.features.call
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.app.databinding.BottomSheetCallDialerChoiceBinding
class DialerChoiceBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetCallDialerChoiceBinding>() {
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetCallDialerChoiceBinding {
return BottomSheetCallDialerChoiceBinding.inflate(inflater, container, false)
}
var onDialPadClicked: (() -> Unit)? = null
var onVoiceCallClicked: (() -> Unit)? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.dialerChoiceDialPad.views.bottomSheetActionClickableZone.debouncedClicks {
onDialPadClicked?.invoke()
dismiss()
}
views.dialerChoiceVoiceCall.views.bottomSheetActionClickableZone.debouncedClicks {
onVoiceCallClicked?.invoke()
dismiss()
}
}
}

View file

@ -1,57 +0,0 @@
/*
* 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.app.features.call
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.matrix.android.sdk.api.session.call.MxCall
import javax.inject.Inject
class SharedActiveCallViewModel @Inject constructor(
private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager
) : ViewModel() {
val activeCall: MutableLiveData<MxCall?> = MutableLiveData()
val callStateListener = object : MxCall.StateListener {
override fun onStateUpdate(call: MxCall) {
if (activeCall.value?.callId == call.callId) {
activeCall.postValue(call)
}
}
}
private val listener = object : WebRtcPeerConnectionManager.CurrentCallListener {
override fun onCurrentCallChange(call: MxCall?) {
activeCall.value?.removeListener(callStateListener)
activeCall.postValue(call)
call?.addListener(callStateListener)
}
}
init {
activeCall.postValue(webRtcPeerConnectionManager.currentCall?.mxCall)
webRtcPeerConnectionManager.addCurrentCallListener(listener)
}
override fun onCleared() {
activeCall.value?.removeListener(callStateListener)
webRtcPeerConnectionManager.removeCurrentCallListener(listener)
super.onCleared()
}
}

View file

@ -0,0 +1,73 @@
/*
* 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.app.features.call
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import im.vector.app.features.call.webrtc.WebRtcCall
import im.vector.app.features.call.webrtc.WebRtcCallManager
import org.matrix.android.sdk.api.session.call.MxCall
import javax.inject.Inject
class SharedKnownCallsViewModel @Inject constructor(
private val callManager: WebRtcCallManager
) : ViewModel() {
val liveKnownCalls: MutableLiveData<List<WebRtcCall>> = MutableLiveData()
val callListener = object : WebRtcCall.Listener {
override fun onStateUpdate(call: MxCall) {
// post it-self
liveKnownCalls.postValue(liveKnownCalls.value)
}
override fun onHoldUnhold() {
super.onHoldUnhold()
// post it-self
liveKnownCalls.postValue(liveKnownCalls.value)
}
}
private val currentCallListener = object : WebRtcCallManager.CurrentCallListener {
override fun onCurrentCallChange(call: WebRtcCall?) {
val knownCalls = callManager.getCalls()
liveKnownCalls.postValue(knownCalls)
knownCalls.forEach {
it.removeListener(callListener)
it.addListener(callListener)
}
}
}
init {
val knownCalls = callManager.getCalls()
liveKnownCalls.postValue(knownCalls)
callManager.addCurrentCallListener(currentCallListener)
knownCalls.forEach {
it.addListener(callListener)
}
}
override fun onCleared() {
callManager.getCalls().forEach {
it.removeListener(callListener)
}
callManager.removeCurrentCallListener(currentCallListener)
super.onCleared()
}
}

View file

@ -20,54 +20,52 @@ import android.app.KeyguardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP
import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.view.View import android.view.View
import android.view.Window
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.view.WindowManager import android.view.WindowManager
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.MvRx import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.viewModel import com.airbnb.mvrx.viewModel
import com.jakewharton.rxbinding3.view.clicks import com.airbnb.mvrx.withState
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.di.ScreenComponent import im.vector.app.core.di.ScreenComponent
import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.core.services.CallService
import im.vector.app.core.utils.PERMISSIONS_FOR_AUDIO_IP_CALL import im.vector.app.core.utils.PERMISSIONS_FOR_AUDIO_IP_CALL
import im.vector.app.core.utils.PERMISSIONS_FOR_VIDEO_IP_CALL import im.vector.app.core.utils.PERMISSIONS_FOR_VIDEO_IP_CALL
import im.vector.app.core.utils.allGranted import im.vector.app.core.utils.allGranted
import im.vector.app.core.utils.checkPermissions import im.vector.app.core.utils.checkPermissions
import im.vector.app.databinding.ActivityCallBinding import im.vector.app.databinding.ActivityCallBinding
import im.vector.app.features.call.dialpad.CallDialPadBottomSheet
import im.vector.app.features.call.dialpad.DialPadFragment
import im.vector.app.features.call.utils.EglUtils
import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.home.room.detail.RoomDetailActivity
import im.vector.app.features.home.room.detail.RoomDetailArgs import im.vector.app.features.home.room.detail.RoomDetailArgs
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.EglUtils
import org.matrix.android.sdk.api.session.call.MxCallDetail import org.matrix.android.sdk.api.session.call.MxCallDetail
import org.matrix.android.sdk.api.session.call.MxPeerConnectionState
import org.matrix.android.sdk.api.session.call.TurnServerResponse import org.matrix.android.sdk.api.session.call.TurnServerResponse
import org.webrtc.EglBase import org.webrtc.EglBase
import org.webrtc.PeerConnection
import org.webrtc.RendererCommon import org.webrtc.RendererCommon
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@Parcelize @Parcelize
data class CallArgs( data class CallArgs(
val roomId: String, val roomId: String,
val callId: String?, val callId: String,
val participantUserId: String, val participantUserId: String,
val isIncomingCall: Boolean, val isIncomingCall: Boolean,
val isVideoCall: Boolean val isVideoCall: Boolean
@ -87,101 +85,36 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
private val callViewModel: VectorCallViewModel by viewModel() private val callViewModel: VectorCallViewModel by viewModel()
private lateinit var callArgs: CallArgs private lateinit var callArgs: CallArgs
@Inject lateinit var peerConnectionManager: WebRtcPeerConnectionManager @Inject lateinit var callManager: WebRtcCallManager
@Inject lateinit var viewModelFactory: VectorCallViewModel.Factory @Inject lateinit var viewModelFactory: VectorCallViewModel.Factory
private var rootEglBase: EglBase? = null private val dialPadCallback = object : DialPadFragment.Callback {
override fun onDigitAppended(digit: String) {
callViewModel.handle(VectorCallViewActions.SendDtmfDigit(digit))
}
}
var systemUiVisibility = false private var rootEglBase: EglBase? = null
var surfaceRenderersAreInitialized = false var surfaceRenderersAreInitialized = false
override fun doBeforeSetContentView() { override fun doBeforeSetContentView() {
// Set window styles for fullscreen-window size. Needs to be done before adding content.
requestWindowFeature(Window.FEATURE_NO_TITLE)
hideSystemUI()
setContentView(R.layout.activity_call) setContentView(R.layout.activity_call)
} }
@Suppress("DEPRECATION")
private fun hideSystemUI() {
systemUiVisibility = false
// Enables regular immersive mode.
// For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE.
// Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// New API instead of SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN and SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
window.setDecorFitsSystemWindows(false)
// New API instead of SYSTEM_UI_FLAG_HIDE_NAVIGATION
window.decorView.windowInsetsController?.hide(WindowInsets.Type.navigationBars())
// New API instead of SYSTEM_UI_FLAG_IMMERSIVE
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE
// New API instead of FLAG_TRANSLUCENT_STATUS
window.statusBarColor = ContextCompat.getColor(this, R.color.half_transparent_status_bar)
// New API instead of FLAG_TRANSLUCENT_NAVIGATION
window.navigationBarColor = ContextCompat.getColor(this, R.color.half_transparent_status_bar)
} else {
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE
// Set the content to appear under the system bars so that the
// content doesn't resize when the system bars hide and show.
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
// Hide the nav bar and status bar
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN)
}
}
// Shows the system bars by removing all the flags
// except for the ones that make the content appear under the system bars.
@Suppress("DEPRECATION")
private fun showSystemUI() {
systemUiVisibility = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// New API instead of SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN and SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
window.setDecorFitsSystemWindows(false)
} else {
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)
}
}
private fun toggleUiSystemVisibility() {
if (systemUiVisibility) {
hideSystemUI()
} else {
showSystemUI()
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
// Rehide when bottom sheet is dismissed
if (hasFocus) {
hideSystemUI()
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) @Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)
// This will need to be refined window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
ViewCompat.setOnApplyWindowInsetsListener(views.constraintLayout) { v, insets ->
v.updatePadding(bottom = if (systemUiVisibility) insets.systemWindowInsetBottom else 0)
insets
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.BLACK
super.onCreate(savedInstanceState)
if (intent.hasExtra(MvRx.KEY_ARG)) { if (intent.hasExtra(MvRx.KEY_ARG)) {
callArgs = intent.getParcelableExtra(MvRx.KEY_ARG)!! callArgs = intent.getParcelableExtra(MvRx.KEY_ARG)!!
} else { } else {
Timber.e("## VOIP missing callArgs for VectorCall Activity") Timber.e("## VOIP missing callArgs for VectorCall Activity")
CallService.onNoActiveCall(this)
finish() finish()
} }
@ -189,13 +122,9 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
if (intent.getStringExtra(EXTRA_MODE) == INCOMING_RINGING) { if (intent.getStringExtra(EXTRA_MODE) == INCOMING_RINGING) {
turnScreenOnAndKeyguardOff() turnScreenOnAndKeyguardOff()
} }
if (savedInstanceState != null) {
views.constraintLayout.clicks() (supportFragmentManager.findFragmentByTag(FRAGMENT_DIAL_PAD_TAG) as? CallDialPadBottomSheet)?.callback = dialPadCallback
.throttleFirst(300, TimeUnit.MILLISECONDS) }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { toggleUiSystemVisibility() }
.disposeOnDestroy()
configureCallViews() configureCallViews()
callViewModel.subscribe(this) { callViewModel.subscribe(this) {
@ -222,7 +151,7 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
} }
override fun onDestroy() { override fun onDestroy() {
peerConnectionManager.detachRenderers(listOf(views.pipRenderer, views.fullscreenRenderer)) callManager.getCallById(callArgs.callId)?.detachRenderers(listOf(views.pipRenderer, views.fullscreenRenderer))
if (surfaceRenderersAreInitialized) { if (surfaceRenderersAreInitialized) {
views.pipRenderer.release() views.pipRenderer.release()
views.fullscreenRenderer.release() views.fullscreenRenderer.release()
@ -234,8 +163,6 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
private fun renderState(state: VectorCallViewState) { private fun renderState(state: VectorCallViewState) {
Timber.v("## VOIP renderState call $state") Timber.v("## VOIP renderState call $state")
if (state.callState is Fail) { if (state.callState is Fail) {
// be sure to clear notification
CallService.onNoActiveCall(this)
finish() finish()
return return
} }
@ -243,9 +170,13 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
views.callControlsView.updateForState(state) views.callControlsView.updateForState(state)
val callState = state.callState.invoke() val callState = state.callState.invoke()
views.callConnectingProgress.isVisible = false views.callConnectingProgress.isVisible = false
views.callActionText.setOnClickListener(null)
views.callActionText.isVisible = false
views.smallIsHeldIcon.isVisible = false
when (callState) { when (callState) {
is CallState.Idle, is CallState.Idle,
is CallState.Dialing -> { is CallState.CreateOffer,
is CallState.Dialing -> {
views.callVideoGroup.isInvisible = true views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true views.callInfoGroup.isVisible = true
views.callStatusText.setText(R.string.call_ring) views.callStatusText.setText(R.string.call_ring)
@ -259,24 +190,42 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
configureCallInfo(state) configureCallInfo(state)
} }
is CallState.Answering -> { is CallState.Answering -> {
views.callVideoGroup.isInvisible = true views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true views.callInfoGroup.isVisible = true
views.callStatusText.setText(R.string.call_connecting) views.callStatusText.setText(R.string.call_connecting)
views.callConnectingProgress.isVisible = true views.callConnectingProgress.isVisible = true
configureCallInfo(state) configureCallInfo(state)
} }
is CallState.Connected -> { is CallState.Connected -> {
if (callState.iceConnectionState == PeerConnection.PeerConnectionState.CONNECTED) { if (callState.iceConnectionState == MxPeerConnectionState.CONNECTED) {
if (callArgs.isVideoCall) { if (state.isLocalOnHold || state.isRemoteOnHold) {
views.callVideoGroup.isVisible = true views.smallIsHeldIcon.isVisible = true
views.callInfoGroup.isVisible = false
views.pipRenderer.isVisible = !state.isVideoCaptureInError
} else {
views.callVideoGroup.isInvisible = true views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true views.callInfoGroup.isVisible = true
configureCallInfo(state, blurAvatar = true)
if (state.isRemoteOnHold) {
views.callActionText.setText(R.string.call_resume_action)
views.callActionText.isVisible = true
views.callActionText.setOnClickListener { callViewModel.handle(VectorCallViewActions.ToggleHoldResume) }
views.callStatusText.setText(R.string.call_held_by_you)
} else {
views.callActionText.isInvisible = true
state.callInfo.otherUserItem?.let {
views.callStatusText.text = getString(R.string.call_held_by_user, it.getBestName())
}
}
} else {
views.callStatusText.text = state.formattedDuration
configureCallInfo(state) configureCallInfo(state)
views.callStatusText.text = null if (callArgs.isVideoCall) {
views.callVideoGroup.isVisible = true
views.callInfoGroup.isVisible = false
views.pipRenderer.isVisible = !state.isVideoCaptureInError && state.otherKnownCallInfo == null
} else {
views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true
}
} }
} else { } else {
// This state is not final, if you change network, new candidates will be sent // This state is not final, if you change network, new candidates will be sent
@ -286,27 +235,52 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
views.callStatusText.setText(R.string.call_connecting) views.callStatusText.setText(R.string.call_connecting)
views.callConnectingProgress.isVisible = true views.callConnectingProgress.isVisible = true
} }
// ensure all attached?
peerConnectionManager.attachViewRenderers(views.pipRenderer, views.fullscreenRenderer, null)
} }
is CallState.Terminated -> { is CallState.Terminated -> {
finish() finish()
} }
null -> { null -> {
} }
} }
} }
private fun configureCallInfo(state: VectorCallViewState) { private fun configureCallInfo(state: VectorCallViewState, blurAvatar: Boolean = false) {
state.otherUserMatrixItem.invoke()?.let { state.callInfo.otherUserItem?.let {
avatarRenderer.render(it, views.otherMemberAvatar) val colorFilter = ContextCompat.getColor(this, R.color.bg_call_screen)
avatarRenderer.renderBlur(it, views.bgCallView, sampling = 20, rounded = false, colorFilter = colorFilter)
views.participantNameText.text = it.getBestName() views.participantNameText.text = it.getBestName()
views.callTypeText.setText(if (state.isVideoCall) R.string.action_video_call else R.string.action_voice_call) if (blurAvatar) {
avatarRenderer.renderBlur(it, views.otherMemberAvatar, sampling = 2, rounded = true, colorFilter = colorFilter)
} else {
avatarRenderer.render(it, views.otherMemberAvatar)
}
}
if (state.otherKnownCallInfo?.otherUserItem == null) {
views.otherKnownCallLayout.isVisible = false
} else {
val otherCall = callManager.getCallById(state.otherKnownCallInfo.callId)
val colorFilter = ContextCompat.getColor(this, R.color.bg_call_screen)
avatarRenderer.renderBlur(
matrixItem = state.otherKnownCallInfo.otherUserItem,
imageView = views.otherKnownCallAvatarView,
sampling = 20,
rounded = false,
colorFilter = colorFilter
)
views.otherKnownCallLayout.isVisible = true
views.otherSmallIsHeldIcon.isVisible = otherCall?.let { it.isLocalOnHold || it.remoteOnHold }.orFalse()
} }
} }
private fun configureCallViews() { private fun configureCallViews() {
views.callControlsView.interactionListener = this views.callControlsView.interactionListener = this
views.otherKnownCallAvatarView.setOnClickListener {
withState(callViewModel) {
val otherCall = callManager.getCallById(it.otherKnownCallInfo?.callId ?: "") ?: return@withState
startActivity(newIntent(this, otherCall.mxCall, null))
finish()
}
}
} }
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
@ -331,17 +305,14 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
// Init Full Screen renderer // Init Full Screen renderer
views.fullscreenRenderer.init(rootEglBase!!.eglBaseContext, null) views.fullscreenRenderer.init(rootEglBase!!.eglBaseContext, null)
views.fullscreenRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT) views.fullscreenRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
views.pipRenderer.setZOrderMediaOverlay(true) views.pipRenderer.setZOrderMediaOverlay(true)
views.pipRenderer.setEnableHardwareScaler(true /* enabled */) views.pipRenderer.setEnableHardwareScaler(true /* enabled */)
views.fullscreenRenderer.setEnableHardwareScaler(true /* enabled */) views.fullscreenRenderer.setEnableHardwareScaler(true /* enabled */)
peerConnectionManager.attachViewRenderers( callManager.getCallById(callArgs.callId)?.attachViewRenderers(views.pipRenderer, views.fullscreenRenderer,
views.pipRenderer, intent.getStringExtra(EXTRA_MODE)?.takeIf { isFirstCreation() })
views.fullscreenRenderer,
intent.getStringExtra(EXTRA_MODE)?.takeIf { isFirstCreation() }
)
views.pipRenderer.setOnClickListener { views.pipRenderer.setOnClickListener {
callViewModel.handle(VectorCallViewActions.ToggleCamera) callViewModel.handle(VectorCallViewActions.ToggleCamera)
@ -352,14 +323,21 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
private fun handleViewEvents(event: VectorCallViewEvents?) { private fun handleViewEvents(event: VectorCallViewEvents?) {
Timber.v("## VOIP handleViewEvents $event") Timber.v("## VOIP handleViewEvents $event")
when (event) { when (event) {
VectorCallViewEvents.DismissNoCall -> { VectorCallViewEvents.DismissNoCall -> {
CallService.onNoActiveCall(this)
finish() finish()
} }
is VectorCallViewEvents.ConnectionTimeout -> { is VectorCallViewEvents.ConnectionTimeout -> {
onErrorTimoutConnect(event.turn) onErrorTimoutConnect(event.turn)
} }
null -> { is VectorCallViewEvents.ShowDialPad -> {
CallDialPadBottomSheet.newInstance(false).apply {
callback = dialPadCallback
}.show(supportFragmentManager, FRAGMENT_DIAL_PAD_TAG)
}
is VectorCallViewEvents.ShowCallTransferScreen -> {
navigator.openCallTransfer(this, callArgs.callId)
}
null -> {
} }
} }
} }
@ -381,22 +359,23 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
private const val CAPTURE_PERMISSION_REQUEST_CODE = 1 private const val CAPTURE_PERMISSION_REQUEST_CODE = 1
private const val EXTRA_MODE = "EXTRA_MODE" private const val EXTRA_MODE = "EXTRA_MODE"
private const val FRAGMENT_DIAL_PAD_TAG = "FRAGMENT_DIAL_PAD_TAG"
const val OUTGOING_CREATED = "OUTGOING_CREATED" const val OUTGOING_CREATED = "OUTGOING_CREATED"
const val INCOMING_RINGING = "INCOMING_RINGING" const val INCOMING_RINGING = "INCOMING_RINGING"
const val INCOMING_ACCEPT = "INCOMING_ACCEPT" const val INCOMING_ACCEPT = "INCOMING_ACCEPT"
fun newIntent(context: Context, mxCall: MxCallDetail): Intent { fun newIntent(context: Context, mxCall: MxCallDetail, mode: String?): Intent {
return Intent(context, VectorCallActivity::class.java).apply { return Intent(context, VectorCallActivity::class.java).apply {
// what could be the best flags? // what could be the best flags?
flags = Intent.FLAG_ACTIVITY_NEW_TASK flags = Intent.FLAG_ACTIVITY_NEW_TASK
putExtra(MvRx.KEY_ARG, CallArgs(mxCall.roomId, mxCall.callId, mxCall.otherUserId, !mxCall.isOutgoing, mxCall.isVideoCall)) putExtra(MvRx.KEY_ARG, CallArgs(mxCall.roomId, mxCall.callId, mxCall.opponentUserId, !mxCall.isOutgoing, mxCall.isVideoCall))
putExtra(EXTRA_MODE, OUTGOING_CREATED) putExtra(EXTRA_MODE, mode)
} }
} }
fun newIntent(context: Context, fun newIntent(context: Context,
callId: String?, callId: String,
roomId: String, roomId: String,
otherUserId: String, otherUserId: String,
isIncomingCall: Boolean, isIncomingCall: Boolean,

View file

@ -17,6 +17,7 @@
package im.vector.app.features.call package im.vector.app.features.call
import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.call.audio.CallAudioManager
sealed class VectorCallViewActions : VectorViewModelAction { sealed class VectorCallViewActions : VectorViewModelAction {
object EndCall : VectorCallViewActions() object EndCall : VectorCallViewActions()
@ -24,9 +25,13 @@ sealed class VectorCallViewActions : VectorViewModelAction {
object DeclineCall : VectorCallViewActions() object DeclineCall : VectorCallViewActions()
object ToggleMute : VectorCallViewActions() object ToggleMute : VectorCallViewActions()
object ToggleVideo : VectorCallViewActions() object ToggleVideo : VectorCallViewActions()
data class ChangeAudioDevice(val device: CallAudioManager.SoundDevice) : VectorCallViewActions() object ToggleHoldResume: VectorCallViewActions()
data class ChangeAudioDevice(val device: CallAudioManager.Device) : VectorCallViewActions()
object OpenDialPad: VectorCallViewActions()
data class SendDtmfDigit(val digit: String) : VectorCallViewActions()
object SwitchSoundDevice : VectorCallViewActions() object SwitchSoundDevice : VectorCallViewActions()
object HeadSetButtonPressed : VectorCallViewActions() object HeadSetButtonPressed : VectorCallViewActions()
object ToggleCamera : VectorCallViewActions() object ToggleCamera : VectorCallViewActions()
object ToggleHDSD : VectorCallViewActions() object ToggleHDSD : VectorCallViewActions()
object InitiateCallTransfer : VectorCallViewActions()
} }

View file

@ -17,6 +17,7 @@
package im.vector.app.features.call package im.vector.app.features.call
import im.vector.app.core.platform.VectorViewEvents import im.vector.app.core.platform.VectorViewEvents
import im.vector.app.features.call.audio.CallAudioManager
import org.matrix.android.sdk.api.session.call.TurnServerResponse import org.matrix.android.sdk.api.session.call.TurnServerResponse
sealed class VectorCallViewEvents : VectorViewEvents { sealed class VectorCallViewEvents : VectorViewEvents {
@ -24,9 +25,11 @@ sealed class VectorCallViewEvents : VectorViewEvents {
object DismissNoCall : VectorCallViewEvents() object DismissNoCall : VectorCallViewEvents()
data class ConnectionTimeout(val turn: TurnServerResponse?) : VectorCallViewEvents() data class ConnectionTimeout(val turn: TurnServerResponse?) : VectorCallViewEvents()
data class ShowSoundDeviceChooser( data class ShowSoundDeviceChooser(
val available: List<CallAudioManager.SoundDevice>, val available: Set<CallAudioManager.Device>,
val current: CallAudioManager.SoundDevice val current: CallAudioManager.Device
) : VectorCallViewEvents() ) : VectorCallViewEvents()
object ShowDialPad: VectorCallViewEvents()
object ShowCallTransferScreen: VectorCallViewEvents()
// data class CallAnswered(val content: CallAnswerContent) : VectorCallViewEvents() // data class CallAnswered(val content: CallAnswerContent) : VectorCallViewEvents()
// data class CallHangup(val content: CallHangupContent) : VectorCallViewEvents() // data class CallHangup(val content: CallHangupContent) : VectorCallViewEvents()
// object CallAccepted : VectorCallViewEvents() // object CallAccepted : VectorCallViewEvents()

View file

@ -20,41 +20,77 @@ import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.call.audio.CallAudioManager
import im.vector.app.features.call.webrtc.WebRtcCall
import im.vector.app.features.call.webrtc.WebRtcCallManager
import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCall import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.call.MxPeerConnectionState
import org.matrix.android.sdk.api.session.call.TurnServerResponse import org.matrix.android.sdk.api.session.call.TurnServerResponse
import org.matrix.android.sdk.api.session.room.model.call.supportCallTransfer
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
import org.webrtc.PeerConnection
import java.util.Timer import java.util.Timer
import java.util.TimerTask import java.util.TimerTask
class VectorCallViewModel @AssistedInject constructor( class VectorCallViewModel @AssistedInject constructor(
@Assisted initialState: VectorCallViewState, @Assisted initialState: VectorCallViewState,
@Assisted val args: CallArgs,
val session: Session, val session: Session,
val webRtcPeerConnectionManager: WebRtcPeerConnectionManager, val callManager: WebRtcCallManager,
val proximityManager: CallProximityManager val proximityManager: CallProximityManager
) : VectorViewModel<VectorCallViewState, VectorCallViewActions, VectorCallViewEvents>(initialState) { ) : VectorViewModel<VectorCallViewState, VectorCallViewActions, VectorCallViewEvents>(initialState) {
private var call: MxCall? = null private var call: WebRtcCall? = null
private var connectionTimeoutTimer: Timer? = null private var connectionTimeoutTimer: Timer? = null
private var hasBeenConnectedOnce = false private var hasBeenConnectedOnce = false
private val callStateListener = object : MxCall.StateListener { private val callListener = object : WebRtcCall.Listener {
override fun onHoldUnhold() {
setState {
copy(
isLocalOnHold = call?.isLocalOnHold ?: false,
isRemoteOnHold = call?.remoteOnHold ?: false
)
}
}
override fun onCaptureStateChanged() {
setState {
copy(
isVideoCaptureInError = call?.videoCapturerIsInError ?: false,
isHD = call?.currentCaptureFormat() is CaptureFormat.HD
)
}
}
override fun onCameraChanged() {
setState {
copy(
canSwitchCamera = call?.canSwitchCamera() ?: false,
isFrontCamera = call?.currentCameraType() == CameraType.FRONT
)
}
}
override fun onTick(formattedDuration: String) {
setState {
copy(formattedDuration = formattedDuration)
}
}
override fun onStateUpdate(call: MxCall) { override fun onStateUpdate(call: MxCall) {
val callState = call.state val callState = call.state
if (callState is CallState.Connected && callState.iceConnectionState == PeerConnection.PeerConnectionState.CONNECTED) { if (callState is CallState.Connected && callState.iceConnectionState == MxPeerConnectionState.CONNECTED) {
hasBeenConnectedOnce = true hasBeenConnectedOnce = true
connectionTimeoutTimer?.cancel() connectionTimeoutTimer?.cancel()
connectionTimeoutTimer = null connectionTimeoutTimer = null
@ -81,189 +117,182 @@ class VectorCallViewModel @AssistedInject constructor(
} }
setState { setState {
copy( copy(
callState = Success(callState) callState = Success(callState),
canOpponentBeTransferred = call.capabilities.supportCallTransfer()
) )
} }
} }
} }
private val currentCallListener = object : WebRtcPeerConnectionManager.CurrentCallListener { private val currentCallListener = object : WebRtcCallManager.CurrentCallListener {
override fun onCurrentCallChange(call: MxCall?) {
// we need to check the state
if (call == null) {
// we should dismiss, e.g handled by other session?
_viewEvents.post(VectorCallViewEvents.DismissNoCall)
}
}
override fun onCaptureStateChanged() { override fun onCurrentCallChange(call: WebRtcCall?) {
setState { if (call == null) {
copy( _viewEvents.post(VectorCallViewEvents.DismissNoCall)
isVideoCaptureInError = webRtcPeerConnectionManager.capturerIsInError, } else {
isHD = webRtcPeerConnectionManager.currentCaptureFormat() is CaptureFormat.HD updateOtherKnownCall(call)
)
} }
} }
override fun onAudioDevicesChange() { override fun onAudioDevicesChange() {
val currentSoundDevice = webRtcPeerConnectionManager.callAudioManager.getCurrentSoundDevice() val currentSoundDevice = callManager.audioManager.selectedDevice ?: return
if (currentSoundDevice == CallAudioManager.SoundDevice.PHONE) { if (currentSoundDevice == CallAudioManager.Device.PHONE) {
proximityManager.start() proximityManager.start()
} else { } else {
proximityManager.stop() proximityManager.stop()
} }
setState { setState {
copy( copy(
availableSoundDevices = webRtcPeerConnectionManager.callAudioManager.getAvailableSoundDevices(), availableDevices = callManager.audioManager.availableDevices,
soundDevice = currentSoundDevice device = currentSoundDevice
) )
} }
} }
}
override fun onCameraChange() { private fun updateOtherKnownCall(currentCall: WebRtcCall) {
setState { val otherCall = callManager.getCalls().firstOrNull {
copy( it.callId != currentCall.callId && it.mxCall.state is CallState.Connected
canSwitchCamera = webRtcPeerConnectionManager.canSwitchCamera(), }
isFrontCamera = webRtcPeerConnectionManager.currentCameraType() == CameraType.FRONT setState {
) if (otherCall == null) {
copy(otherKnownCallInfo = null)
} else {
val otherUserItem: MatrixItem? = session.getUser(otherCall.mxCall.opponentUserId)?.toMatrixItem()
copy(otherKnownCallInfo = VectorCallViewState.CallInfo(otherCall.callId, otherUserItem))
} }
} }
} }
init { init {
initialState.callId?.let { val webRtcCall = callManager.getCallById(initialState.callId)
webRtcPeerConnectionManager.addCurrentCallListener(currentCallListener) if (webRtcCall == null) {
setState {
session.callSignalingService().getCallWithId(it)?.let { mxCall -> copy(callState = Fail(IllegalArgumentException("No call")))
this.call = mxCall
mxCall.otherUserId
val item: MatrixItem? = session.getRoomMember(mxCall.otherUserId, mxCall.roomId)?.toMatrixItem()
mxCall.addListener(callStateListener)
val currentSoundDevice = webRtcPeerConnectionManager.callAudioManager.getCurrentSoundDevice()
if (currentSoundDevice == CallAudioManager.SoundDevice.PHONE) {
proximityManager.start()
}
setState {
copy(
isVideoCall = mxCall.isVideoCall,
callState = Success(mxCall.state),
otherUserMatrixItem = item?.let { Success(it) } ?: Uninitialized,
soundDevice = currentSoundDevice,
availableSoundDevices = webRtcPeerConnectionManager.callAudioManager.getAvailableSoundDevices(),
isFrontCamera = webRtcPeerConnectionManager.currentCameraType() == CameraType.FRONT,
canSwitchCamera = webRtcPeerConnectionManager.canSwitchCamera(),
isHD = mxCall.isVideoCall && webRtcPeerConnectionManager.currentCaptureFormat() is CaptureFormat.HD
)
}
} ?: run {
setState {
copy(
callState = Fail(IllegalArgumentException("No call"))
)
}
} }
} else {
call = webRtcCall
callManager.addCurrentCallListener(currentCallListener)
val item: MatrixItem? = session.getUser(webRtcCall.mxCall.opponentUserId)?.toMatrixItem()
webRtcCall.addListener(callListener)
val currentSoundDevice = callManager.audioManager.selectedDevice
if (currentSoundDevice == CallAudioManager.Device.PHONE) {
proximityManager.start()
}
setState {
copy(
isVideoCall = webRtcCall.mxCall.isVideoCall,
callState = Success(webRtcCall.mxCall.state),
callInfo = VectorCallViewState.CallInfo(callId, item),
device = currentSoundDevice ?: CallAudioManager.Device.PHONE,
isLocalOnHold = webRtcCall.isLocalOnHold,
isRemoteOnHold = webRtcCall.remoteOnHold,
availableDevices = callManager.audioManager.availableDevices,
isFrontCamera = webRtcCall.currentCameraType() == CameraType.FRONT,
canSwitchCamera = webRtcCall.canSwitchCamera(),
formattedDuration = webRtcCall.formattedDuration(),
isHD = webRtcCall.mxCall.isVideoCall && webRtcCall.currentCaptureFormat() is CaptureFormat.HD,
canOpponentBeTransferred = webRtcCall.mxCall.capabilities.supportCallTransfer()
)
}
updateOtherKnownCall(webRtcCall)
} }
} }
override fun onCleared() { override fun onCleared() {
// session.callService().removeCallListener(callServiceListener) callManager.removeCurrentCallListener(currentCallListener)
webRtcPeerConnectionManager.removeCurrentCallListener(currentCallListener) call?.removeListener(callListener)
this.call?.removeListener(callStateListener)
proximityManager.stop() proximityManager.stop()
super.onCleared() super.onCleared()
} }
override fun handle(action: VectorCallViewActions) = withState { state -> override fun handle(action: VectorCallViewActions) = withState { state ->
when (action) { when (action) {
VectorCallViewActions.EndCall -> webRtcPeerConnectionManager.endCall() VectorCallViewActions.EndCall -> call?.endCall()
VectorCallViewActions.AcceptCall -> { VectorCallViewActions.AcceptCall -> {
setState { setState {
copy(callState = Loading()) copy(callState = Loading())
} }
webRtcPeerConnectionManager.acceptIncomingCall() call?.acceptIncomingCall()
} }
VectorCallViewActions.DeclineCall -> { VectorCallViewActions.DeclineCall -> {
setState { setState {
copy(callState = Loading()) copy(callState = Loading())
} }
webRtcPeerConnectionManager.endCall() call?.endCall()
} }
VectorCallViewActions.ToggleMute -> { VectorCallViewActions.ToggleMute -> {
val muted = state.isAudioMuted val muted = state.isAudioMuted
webRtcPeerConnectionManager.muteCall(!muted) call?.muteCall(!muted)
setState { setState {
copy(isAudioMuted = !muted) copy(isAudioMuted = !muted)
} }
} }
VectorCallViewActions.ToggleVideo -> { VectorCallViewActions.ToggleVideo -> {
if (state.isVideoCall) { if (state.isVideoCall) {
val videoEnabled = state.isVideoEnabled val videoEnabled = state.isVideoEnabled
webRtcPeerConnectionManager.enableVideo(!videoEnabled) call?.enableVideo(!videoEnabled)
setState { setState {
copy(isVideoEnabled = !videoEnabled) copy(isVideoEnabled = !videoEnabled)
} }
} }
Unit Unit
} }
is VectorCallViewActions.ChangeAudioDevice -> { VectorCallViewActions.ToggleHoldResume -> {
webRtcPeerConnectionManager.callAudioManager.setCurrentSoundDevice(action.device) val isRemoteOnHold = state.isRemoteOnHold
setState { call?.updateRemoteOnHold(!isRemoteOnHold)
copy(
soundDevice = webRtcPeerConnectionManager.callAudioManager.getCurrentSoundDevice()
)
}
} }
VectorCallViewActions.SwitchSoundDevice -> { is VectorCallViewActions.ChangeAudioDevice -> {
callManager.audioManager.setAudioDevice(action.device)
}
VectorCallViewActions.SwitchSoundDevice -> {
_viewEvents.post( _viewEvents.post(
VectorCallViewEvents.ShowSoundDeviceChooser(state.availableSoundDevices, state.soundDevice) VectorCallViewEvents.ShowSoundDeviceChooser(state.availableDevices, state.device)
) )
} }
VectorCallViewActions.HeadSetButtonPressed -> { VectorCallViewActions.HeadSetButtonPressed -> {
if (state.callState.invoke() is CallState.LocalRinging) { if (state.callState.invoke() is CallState.LocalRinging) {
// accept call // accept call
webRtcPeerConnectionManager.acceptIncomingCall() call?.acceptIncomingCall()
} }
if (state.callState.invoke() is CallState.Connected) { if (state.callState.invoke() is CallState.Connected) {
// end call? // end call?
webRtcPeerConnectionManager.endCall() call?.endCall()
} }
Unit Unit
} }
VectorCallViewActions.ToggleCamera -> { VectorCallViewActions.ToggleCamera -> {
webRtcPeerConnectionManager.switchCamera() call?.switchCamera()
} }
VectorCallViewActions.ToggleHDSD -> { VectorCallViewActions.ToggleHDSD -> {
if (!state.isVideoCall) return@withState if (!state.isVideoCall) return@withState
webRtcPeerConnectionManager.setCaptureFormat(if (state.isHD) CaptureFormat.SD else CaptureFormat.HD) call?.setCaptureFormat(if (state.isHD) CaptureFormat.SD else CaptureFormat.HD)
}
VectorCallViewActions.OpenDialPad -> {
_viewEvents.post(VectorCallViewEvents.ShowDialPad)
}
is VectorCallViewActions.SendDtmfDigit -> {
call?.sendDtmfDigit(action.digit)
}
VectorCallViewActions.InitiateCallTransfer -> {
_viewEvents.post(
VectorCallViewEvents.ShowCallTransferScreen
)
} }
}.exhaustive }.exhaustive
} }
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(initialState: VectorCallViewState, args: CallArgs): VectorCallViewModel fun create(initialState: VectorCallViewState): VectorCallViewModel
} }
companion object : MvRxViewModelFactory<VectorCallViewModel, VectorCallViewState> { companion object : MvRxViewModelFactory<VectorCallViewModel, VectorCallViewState> {
@JvmStatic @JvmStatic
override fun create(viewModelContext: ViewModelContext, state: VectorCallViewState): VectorCallViewModel? { override fun create(viewModelContext: ViewModelContext, state: VectorCallViewState): VectorCallViewModel {
val callActivity: VectorCallActivity = viewModelContext.activity() val callActivity: VectorCallActivity = viewModelContext.activity()
val callArgs: CallArgs = viewModelContext.args() return callActivity.viewModelFactory.create(state)
return callActivity.viewModelFactory.create(state, callArgs)
}
override fun initialState(viewModelContext: ViewModelContext): VectorCallViewState? {
val args: CallArgs = viewModelContext.args()
return VectorCallViewState(
callId = args.callId,
roomId = args.roomId,
isVideoCall = args.isVideoCall
)
} }
} }
} }

View file

@ -19,21 +19,39 @@ package im.vector.app.features.call
import com.airbnb.mvrx.Async import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.call.audio.CallAudioManager
import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
data class VectorCallViewState( data class VectorCallViewState(
val callId: String? = null, val callId: String,
val roomId: String = "", val roomId: String,
val isVideoCall: Boolean, val isVideoCall: Boolean,
val isRemoteOnHold: Boolean = false,
val isLocalOnHold: Boolean = false,
val isAudioMuted: Boolean = false, val isAudioMuted: Boolean = false,
val isVideoEnabled: Boolean = true, val isVideoEnabled: Boolean = true,
val isVideoCaptureInError: Boolean = false, val isVideoCaptureInError: Boolean = false,
val isHD: Boolean = false, val isHD: Boolean = false,
val isFrontCamera: Boolean = true, val isFrontCamera: Boolean = true,
val canSwitchCamera: Boolean = true, val canSwitchCamera: Boolean = true,
val soundDevice: CallAudioManager.SoundDevice = CallAudioManager.SoundDevice.PHONE, val device: CallAudioManager.Device = CallAudioManager.Device.PHONE,
val availableSoundDevices: List<CallAudioManager.SoundDevice> = emptyList(), val availableDevices: Set<CallAudioManager.Device> = emptySet(),
val otherUserMatrixItem: Async<MatrixItem> = Uninitialized, val callState: Async<CallState> = Uninitialized,
val callState: Async<CallState> = Uninitialized val otherKnownCallInfo: CallInfo? = null,
) : MvRxState val callInfo: CallInfo = CallInfo(callId),
val formattedDuration: String = "",
val canOpponentBeTransferred: Boolean = false
) : MvRxState {
data class CallInfo(
val callId: String,
val otherUserItem: MatrixItem? = null
)
constructor(callArgs: CallArgs): this(
callId = callArgs.callId,
roomId = callArgs.roomId,
isVideoCall = callArgs.isVideoCall
)
}

View file

@ -0,0 +1,133 @@
/*
* 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.
*/
@file:Suppress("DEPRECATION")
package im.vector.app.features.call.audio
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.media.AudioManager
import androidx.core.content.getSystemService
import im.vector.app.core.services.BluetoothHeadsetReceiver
import im.vector.app.core.services.WiredHeadsetStateReceiver
import timber.log.Timber
import java.util.HashSet
internal class API21AudioDeviceDetector(private val context: Context,
private val audioManager: AudioManager,
private val callAudioManager: CallAudioManager
) : CallAudioManager.AudioDeviceDetector, WiredHeadsetStateReceiver.HeadsetEventListener, BluetoothHeadsetReceiver.EventListener {
private var bluetoothAdapter: BluetoothAdapter? = null
private var connectedBlueToothHeadset: BluetoothProfile? = null
private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null
private var bluetoothHeadsetStateReceiver: BluetoothHeadsetReceiver? = null
private val onAudioDeviceChangeRunner = Runnable {
val devices = getAvailableSoundDevices()
callAudioManager.replaceDevices(devices)
Timber.i(" Available audio devices: $devices")
callAudioManager.updateAudioRoute()
}
private fun getAvailableSoundDevices(): Set<CallAudioManager.Device> {
return HashSet<CallAudioManager.Device>().apply {
if (isBluetoothHeadsetOn()) add(CallAudioManager.Device.WIRELESS_HEADSET)
if (isWiredHeadsetOn()) {
add(CallAudioManager.Device.HEADSET)
} else {
add(CallAudioManager.Device.PHONE)
}
add(CallAudioManager.Device.SPEAKER)
}
}
private fun isWiredHeadsetOn(): Boolean {
return audioManager.isWiredHeadsetOn
}
private fun isBluetoothHeadsetOn(): Boolean {
Timber.v("## VOIP: AudioManager isBluetoothHeadsetOn")
try {
if (connectedBlueToothHeadset == null) return false.also {
Timber.v("## VOIP: AudioManager no connected bluetooth headset")
}
if (!audioManager.isBluetoothScoAvailableOffCall) return false.also {
Timber.v("## VOIP: AudioManager isBluetoothScoAvailableOffCall false")
}
return true
} catch (failure: Throwable) {
Timber.e("## VOIP: AudioManager isBluetoothHeadsetOn failure ${failure.localizedMessage}")
return false
}
}
/**
* Helper method to trigger an audio route update when devices change. It
* makes sure the operation is performed on the audio thread.
*/
private fun onAudioDeviceChange() {
callAudioManager.runInAudioThread(onAudioDeviceChangeRunner)
}
override fun start() {
Timber.i("Start using $this as the audio device handler")
wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(context, this)
bluetoothHeadsetStateReceiver = BluetoothHeadsetReceiver.createAndRegister(context, this)
val bm: BluetoothManager? = context.getSystemService()
val adapter = bm?.adapter
Timber.d("## VOIP Bluetooth adapter $adapter")
bluetoothAdapter = adapter
adapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceDisconnected(profile: Int) {
Timber.d("## VOIP onServiceDisconnected $profile")
if (profile == BluetoothProfile.HEADSET) {
connectedBlueToothHeadset = null
onAudioDeviceChange()
}
}
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) {
Timber.d("## VOIP onServiceConnected $profile , proxy:$proxy")
if (profile == BluetoothProfile.HEADSET) {
connectedBlueToothHeadset = proxy
onAudioDeviceChange()
}
}
}, BluetoothProfile.HEADSET)
onAudioDeviceChange()
}
override fun stop() {
Timber.i("Stop using $this as the audio device handler")
wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(context, it) }
wiredHeadsetStateReceiver = null
bluetoothHeadsetStateReceiver?.let { BluetoothHeadsetReceiver.unRegister(context, it) }
bluetoothHeadsetStateReceiver = null
}
override fun onHeadsetEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) {
Timber.v("onHeadsetEvent $event")
onAudioDeviceChange()
}
override fun onBTHeadsetEvent(event: BluetoothHeadsetReceiver.BTHeadsetPlugEvent) {
Timber.v("onBTHeadsetEvent $event")
onAudioDeviceChange()
}
}

View file

@ -0,0 +1,85 @@
/*
* 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.features.call.audio
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import androidx.annotation.RequiresApi
import timber.log.Timber
import java.util.HashSet
@RequiresApi(Build.VERSION_CODES.M)
internal class API23AudioDeviceDetector(private val audioManager: AudioManager,
private val callAudioManager: CallAudioManager
) : CallAudioManager.AudioDeviceDetector {
private val onAudioDeviceChangeRunner = Runnable {
val devices: MutableSet<CallAudioManager.Device> = HashSet()
val deviceInfos = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
for (info in deviceInfos) {
when (info.type) {
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> devices.add(CallAudioManager.Device.WIRELESS_HEADSET)
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> devices.add(CallAudioManager.Device.PHONE)
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> devices.add(CallAudioManager.Device.SPEAKER)
AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET, TYPE_USB_HEADSET -> devices.add(CallAudioManager.Device.HEADSET)
}
}
callAudioManager.replaceDevices(devices)
Timber.i(" Available audio devices: $devices")
callAudioManager.updateAudioRoute()
}
private val audioDeviceCallback: AudioDeviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(
addedDevices: Array<AudioDeviceInfo>) {
Timber.d(" Audio devices added")
onAudioDeviceChange()
}
override fun onAudioDevicesRemoved(
removedDevices: Array<AudioDeviceInfo>) {
Timber.d(" Audio devices removed")
onAudioDeviceChange()
}
}
/**
* Helper method to trigger an audio route update when devices change. It
* makes sure the operation is performed on the audio thread.
*/
private fun onAudioDeviceChange() {
callAudioManager.runInAudioThread(onAudioDeviceChangeRunner)
}
override fun start() {
Timber.i("Using $this as the audio device handler")
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
onAudioDeviceChange()
}
override fun stop() {
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
}
companion object {
/**
* Constant defining a USB headset. Only available on API level >= 26.
* The value of: AudioDeviceInfo.TYPE_USB_HEADSET
*/
private const val TYPE_USB_HEADSET = 22
}
}

View file

@ -0,0 +1,260 @@
/*
* 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.features.call.audio
import android.content.Context
import android.media.AudioManager
import android.os.Build
import androidx.core.content.getSystemService
import org.matrix.android.sdk.api.extensions.orFalse
import timber.log.Timber
import java.util.HashSet
import java.util.concurrent.Executors
class CallAudioManager(private val context: Context, val configChange: (() -> Unit)?) {
private val audioManager: AudioManager? = context.getSystemService()
private var audioDeviceDetector: AudioDeviceDetector? = null
private var audioDeviceRouter: AudioDeviceRouter? = null
enum class Device {
PHONE,
SPEAKER,
HEADSET,
WIRELESS_HEADSET
}
enum class Mode {
DEFAULT,
AUDIO_CALL,
VIDEO_CALL
}
private var mode = Mode.DEFAULT
private var _availableDevices: MutableSet<Device> = HashSet()
val availableDevices: Set<Device>
get() = _availableDevices
var selectedDevice: Device? = null
private set
private var userSelectedDevice: Device? = null
init {
runInAudioThread { setup() }
}
private fun setup() {
if (audioManager == null) {
return
}
audioDeviceDetector?.stop()
audioDeviceDetector = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
API23AudioDeviceDetector(audioManager, this)
} else {
API21AudioDeviceDetector(context, audioManager, this)
}
audioDeviceDetector?.start()
audioDeviceRouter = DefaultAudioDeviceRouter(audioManager, this)
}
fun runInAudioThread(runnable: Runnable) {
executor.execute(runnable)
}
/**
* Sets the user selected audio device as the active audio device.
*
* @param device the desired device which will become active.
*/
fun setAudioDevice(device: Device) {
runInAudioThread(Runnable {
if (!_availableDevices.contains(device)) {
Timber.w(" Audio device not available: $device")
userSelectedDevice = null
return@Runnable
}
if (mode != Mode.DEFAULT) {
Timber.i(" User selected device set to: $device")
userSelectedDevice = device
updateAudioRoute(mode, false)
}
})
}
/**
* Public method to set the current audio mode.
*
* @param mode the desired audio mode.
* could be updated successfully, and it will be rejected otherwise.
*/
fun setMode(mode: Mode) {
runInAudioThread {
var success: Boolean
try {
success = updateAudioRoute(mode, false)
} catch (e: Throwable) {
success = false
Timber.e(e, " Failed to update audio route for mode: " + mode)
}
if (success) {
this@CallAudioManager.mode = mode
}
}
}
/**
* Updates the audio route for the given mode.
*
* @param mode the audio mode to be used when computing the audio route.
* @return `true` if the audio route was updated successfully;
* `false`, otherwise.
*/
private fun updateAudioRoute(mode: Mode, force: Boolean): Boolean {
Timber.i(" Update audio route for mode: " + mode)
if (!audioDeviceRouter?.setMode(mode).orFalse()) {
return false
}
if (mode == Mode.DEFAULT) {
selectedDevice = null
userSelectedDevice = null
return true
}
val bluetoothAvailable = _availableDevices.contains(Device.WIRELESS_HEADSET)
val headsetAvailable = _availableDevices.contains(Device.HEADSET)
// Pick the desired device based on what's available and the mode.
var audioDevice: Device
audioDevice = if (bluetoothAvailable) {
Device.WIRELESS_HEADSET
} else if (headsetAvailable) {
Device.HEADSET
} else if (mode == Mode.VIDEO_CALL) {
Device.SPEAKER
} else {
Device.PHONE
}
// Consider the user's selection
if (userSelectedDevice != null && _availableDevices.contains(userSelectedDevice)) {
audioDevice = userSelectedDevice!!
}
// If the previously selected device and the current default one
// match, do nothing.
if (!force && selectedDevice != null && selectedDevice == audioDevice) {
return true
}
selectedDevice = audioDevice
Timber.i(" Selected audio device: " + audioDevice)
audioDeviceRouter?.setAudioRoute(audioDevice)
configChange?.invoke()
return true
}
/**
* Resets the current device selection.
*/
fun resetSelectedDevice() {
selectedDevice = null
userSelectedDevice = null
}
/**
* Adds a new device to the list of available devices.
*
* @param device The new device.
*/
fun addDevice(device: Device) {
_availableDevices.add(device)
resetSelectedDevice()
}
/**
* Removes a device from the list of available devices.
*
* @param device The old device to the removed.
*/
fun removeDevice(device: Device) {
_availableDevices.remove(device)
resetSelectedDevice()
}
/**
* Replaces the current list of available devices with a new one.
*
* @param devices The new devices list.
*/
fun replaceDevices(devices: Set<Device>) {
_availableDevices.clear()
_availableDevices.addAll(devices)
resetSelectedDevice()
}
/**
* Re-sets the current audio route. Needed when devices changes have happened.
*/
fun updateAudioRoute() {
if (mode != Mode.DEFAULT) {
updateAudioRoute(mode, false)
}
}
/**
* Re-sets the current audio route. Needed when focus is lost and regained.
*/
fun resetAudioRoute() {
if (mode != Mode.DEFAULT) {
updateAudioRoute(mode, true)
}
}
/**
* Interface for the modules implementing the actual audio device management.
*/
interface AudioDeviceDetector {
/**
* Start detecting audio device changes.
*/
fun start()
/**
* Stop audio device detection.
*/
fun stop()
}
interface AudioDeviceRouter {
/**
* Set the appropriate route for the given audio device.
*
* @param device Audio device for which the route must be set.
*/
fun setAudioRoute(device: Device)
/**
* Set the given audio mode.
*
* @param mode The new audio mode to be used.
* @return Whether the operation was successful or not.
*/
fun setMode(mode: Mode): Boolean
}
companion object {
// Every audio operations should be launched on single thread
private val executor = Executors.newSingleThreadExecutor()
}
}

View file

@ -0,0 +1,112 @@
/*
* 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.features.call.audio
import android.media.AudioManager
import androidx.media.AudioAttributesCompat
import androidx.media.AudioFocusRequestCompat
import androidx.media.AudioManagerCompat
import timber.log.Timber
class DefaultAudioDeviceRouter(private val audioManager: AudioManager,
private val callAudioManager: CallAudioManager
) : CallAudioManager.AudioDeviceRouter, AudioManager.OnAudioFocusChangeListener {
private var audioFocusLost = false
private var focusRequestCompat: AudioFocusRequestCompat? = null
override fun setAudioRoute(device: CallAudioManager.Device) {
audioManager.isSpeakerphoneOn = device === CallAudioManager.Device.SPEAKER
setBluetoothAudioRoute(device === CallAudioManager.Device.WIRELESS_HEADSET)
}
override fun setMode(mode: CallAudioManager.Mode): Boolean {
if (mode === CallAudioManager.Mode.DEFAULT) {
audioFocusLost = false
audioManager.mode = AudioManager.MODE_NORMAL
focusRequestCompat?.also {
AudioManagerCompat.abandonAudioFocusRequest(audioManager, it)
}
focusRequestCompat = null
audioManager.isSpeakerphoneOn = false
setBluetoothAudioRoute(false)
return true
}
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
audioManager.isMicrophoneMute = false
val audioFocusRequest = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributesCompat.Builder()
.setUsage(AudioAttributesCompat.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributesCompat.CONTENT_TYPE_SPEECH)
.build()
)
.setOnAudioFocusChangeListener(this)
.build()
.also {
focusRequestCompat = it
}
val gotFocus = AudioManagerCompat.requestAudioFocus(audioManager, audioFocusRequest)
if (gotFocus == AudioManager.AUDIOFOCUS_REQUEST_FAILED) {
Timber.w(" Audio focus request failed")
return false
}
return true
}
/**
* Helper method to set the output route to a Bluetooth device.
*
* @param enabled true if Bluetooth should use used, false otherwise.
*/
private fun setBluetoothAudioRoute(enabled: Boolean) {
if (enabled) {
audioManager.startBluetoothSco()
audioManager.isBluetoothScoOn = true
} else {
audioManager.isBluetoothScoOn = false
audioManager.stopBluetoothSco()
}
}
/**
* [AudioManager.OnAudioFocusChangeListener] interface method. Called
* when the audio focus of the system is updated.
*
* @param focusChange - The type of focus change.
*/
override fun onAudioFocusChange(focusChange: Int) {
callAudioManager.runInAudioThread {
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
Timber.d(" Audio focus gained")
if (audioFocusLost) {
callAudioManager.resetAudioRoute()
}
audioFocusLost = false
}
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
Timber.d(" Audio focus lost")
audioFocusLost = true
}
}
}
}
}

View file

@ -0,0 +1,100 @@
/*
* 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.features.call.dialpad
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import im.vector.app.R
import im.vector.app.core.extensions.addChildFragment
import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment
import im.vector.app.databinding.BottomSheetCallDialPadBinding
import im.vector.app.features.settings.VectorLocale
class CallDialPadBottomSheet : VectorBaseBottomSheetDialogFragment<BottomSheetCallDialPadBinding>() {
companion object {
private const val EXTRA_SHOW_ACTIONS = "EXTRA_SHOW_ACTIONS"
fun newInstance(showActions: Boolean): CallDialPadBottomSheet {
return CallDialPadBottomSheet().apply {
arguments = Bundle().apply {
putBoolean(EXTRA_SHOW_ACTIONS, showActions)
}
}
}
}
override val showExpanded = true
var callback: DialPadFragment.Callback? = null
set(value) {
field = value
setCallbackToFragment(callback)
}
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetCallDialPadBinding {
return BottomSheetCallDialPadBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (savedInstanceState == null) {
val showActions = arguments?.getBoolean(EXTRA_SHOW_ACTIONS, false) ?: false
DialPadFragment().apply {
arguments = Bundle().apply {
putBoolean(DialPadFragment.EXTRA_ENABLE_DELETE, showActions)
putBoolean(DialPadFragment.EXTRA_ENABLE_OK, showActions)
putString(DialPadFragment.EXTRA_REGION_CODE, VectorLocale.applicationLocale.country)
}
callback = DialPadFragmentCallbackWrapper(this@CallDialPadBottomSheet.callback)
}.also {
addChildFragment(R.id.callDialPadFragmentContainer, it)
}
} else {
setCallbackToFragment(callback)
}
views.callDialPadClose.setOnClickListener {
dismiss()
}
}
override fun onDestroyView() {
setCallbackToFragment(null)
super.onDestroyView()
}
private fun setCallbackToFragment(callback: DialPadFragment.Callback?) {
if (!isAdded) return
val dialPadFragment = childFragmentManager.findFragmentById(R.id.callDialPadFragmentContainer) as? DialPadFragment
dialPadFragment?.callback = DialPadFragmentCallbackWrapper(callback)
}
private inner class DialPadFragmentCallbackWrapper(val callback: DialPadFragment.Callback?): DialPadFragment.Callback {
override fun onDigitAppended(digit: String) {
callback?.onDigitAppended(digit)
}
override fun onOkClicked(formatted: String?, raw: String?) {
callback?.onOkClicked(formatted, raw)
dismiss()
}
}
}

View file

@ -0,0 +1,196 @@
/*
* 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.features.call.dialpad
import android.content.res.ColorStateList
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.core.widget.ImageViewCompat
import androidx.fragment.app.Fragment
import com.android.dialer.dialpadview.DialpadView
import com.android.dialer.dialpadview.DigitsEditText
import com.android.dialer.dialpadview.R
import com.google.i18n.phonenumbers.AsYouTypeFormatter
import com.google.i18n.phonenumbers.PhoneNumberUtil
import im.vector.app.features.themes.ThemeUtils
class DialPadFragment : Fragment() {
var callback: Callback? = null
private var digits: DigitsEditText? = null
private var formatter: AsYouTypeFormatter? = null
private var input = ""
private var regionCode: String = DEFAULT_REGION_CODE
private var formatAsYouType = true
private var enableStar = true
private var enablePound = true
private var enablePlus = true
private var cursorVisible = false
private var enableDelete = true
private var enableFabOk = true
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?): View {
initArgs(savedInstanceState)
val view = inflater.inflate(R.layout.dialpad_fragment, container, false)
val dialpadView = view.findViewById<View>(R.id.dialpad_view) as DialpadView
dialpadView.findViewById<View>(R.id.dialpad_key_voicemail).isVisible = false
digits = dialpadView.digits as? DigitsEditText
digits?.isCursorVisible = cursorVisible
digits?.setTextColor(ThemeUtils.getColor(requireContext(), im.vector.app.R.attr.riotx_text_primary))
dialpadView.findViewById<View>(R.id.zero).setOnClickListener { append('0') }
if (enablePlus) {
dialpadView.findViewById<View>(R.id.zero).setOnLongClickListener {
append('+')
true
}
}
dialpadView.findViewById<View>(R.id.one).setOnClickListener { append('1') }
dialpadView.findViewById<View>(R.id.two).setOnClickListener { append('2') }
dialpadView.findViewById<View>(R.id.three).setOnClickListener { append('3') }
dialpadView.findViewById<View>(R.id.four).setOnClickListener { append('4') }
dialpadView.findViewById<View>(R.id.four).setOnClickListener { append('4') }
dialpadView.findViewById<View>(R.id.five).setOnClickListener { append('5') }
dialpadView.findViewById<View>(R.id.six).setOnClickListener { append('6') }
dialpadView.findViewById<View>(R.id.seven).setOnClickListener { append('7') }
dialpadView.findViewById<View>(R.id.eight).setOnClickListener { append('8') }
dialpadView.findViewById<View>(R.id.nine).setOnClickListener { append('9') }
if (enableStar) {
dialpadView.findViewById<View>(R.id.star).setOnClickListener { append('*') }
} else {
dialpadView.findViewById<View>(R.id.star).isVisible = false
}
if (enablePound) {
dialpadView.findViewById<View>(R.id.pound).setOnClickListener { append('#') }
} else {
dialpadView.findViewById<View>(R.id.pound).isVisible = false
}
if (enableDelete) {
dialpadView.deleteButton.setOnClickListener { poll() }
dialpadView.deleteButton.setOnLongClickListener {
clear()
true
}
val tintColor = ThemeUtils.getColor(requireContext(), im.vector.app.R.attr.riotx_text_secondary)
ImageViewCompat.setImageTintList(dialpadView.deleteButton, ColorStateList.valueOf(tintColor))
} else {
dialpadView.deleteButton.isVisible = false
}
// if region code is null, no formatting is performed
formatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(if (formatAsYouType) regionCode else "")
val fabOk = view.findViewById<View>(R.id.fab_ok)
if (enableFabOk) {
fabOk.setOnClickListener {
callback?.onOkClicked(digits?.text.toString(), input)
}
} else {
fabOk.isVisible = false
}
digits?.setOnTextContextMenuClickListener {
val string = digits?.text.toString()
clear()
for (element in string) {
append(element)
}
}
return view
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(EXTRA_REGION_CODE, regionCode)
outState.putBoolean(EXTRA_FORMAT_AS_YOU_TYPE, formatAsYouType)
outState.putBoolean(EXTRA_ENABLE_STAR, enableStar)
outState.putBoolean(EXTRA_ENABLE_POUND, enablePound)
outState.putBoolean(EXTRA_ENABLE_PLUS, enablePlus)
outState.putBoolean(EXTRA_ENABLE_OK, enableFabOk)
outState.putBoolean(EXTRA_ENABLE_DELETE, enableDelete)
outState.putBoolean(EXTRA_CURSOR_VISIBLE, cursorVisible)
}
private fun initArgs(savedInstanceState: Bundle?) {
val args = savedInstanceState ?: arguments
if (args != null) {
regionCode = args.getString(EXTRA_REGION_CODE, DEFAULT_REGION_CODE)
formatAsYouType = args.getBoolean(EXTRA_FORMAT_AS_YOU_TYPE, formatAsYouType)
enableStar = args.getBoolean(EXTRA_ENABLE_STAR, enableStar)
enablePound = args.getBoolean(EXTRA_ENABLE_POUND, enablePound)
enablePlus = args.getBoolean(EXTRA_ENABLE_PLUS, enablePlus)
enableDelete = args.getBoolean(EXTRA_ENABLE_DELETE, enableDelete)
enableFabOk = args.getBoolean(EXTRA_ENABLE_OK, enableFabOk)
cursorVisible = args.getBoolean(EXTRA_CURSOR_VISIBLE, cursorVisible)
}
}
private fun poll() {
if (!input.isEmpty()) {
input = input.substring(0, input.length - 1)
formatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(regionCode)
if (formatAsYouType) {
digits?.setText("")
for (c in input.toCharArray()) {
digits?.setText(formatter?.inputDigit(c))
}
} else {
digits?.setText(input)
}
}
}
private fun clear() {
formatter?.clear()
digits?.setText("")
input = ""
}
private fun append(c: Char) {
callback?.onDigitAppended(c.toString())
input += c
if (formatAsYouType) {
digits?.setText(formatter?.inputDigit(c))
} else {
digits?.setText(input)
}
}
interface Callback {
fun onOkClicked(formatted: String?, raw: String?) = Unit
fun onDigitAppended(digit: String) = Unit
}
companion object {
const val EXTRA_REGION_CODE = "EXTRA_REGION_CODE"
const val EXTRA_FORMAT_AS_YOU_TYPE = "EXTRA_FORMAT_AS_YOU_TYPE"
const val EXTRA_ENABLE_STAR = "EXTRA_ENABLE_STAR"
const val EXTRA_ENABLE_POUND = "EXTRA_ENABLE_POUND"
const val EXTRA_ENABLE_PLUS = "EXTRA_ENABLE_PLUS"
const val EXTRA_ENABLE_DELETE = "EXTRA_ENABLE_DELETE"
const val EXTRA_ENABLE_OK = "EXTRA_ENABLE_OK"
const val EXTRA_CURSOR_VISIBLE = "EXTRA_CURSOR_VISIBLE"
private const val DEFAULT_REGION_CODE = "US"
}
}

View file

@ -0,0 +1,44 @@
/*
* 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.features.call.dialpad
import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.createdirect.DirectRoomHelper
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.Session
import javax.inject.Inject
class DialPadLookup @Inject constructor(val session: Session,
val directRoomHelper: DirectRoomHelper,
val callManager: WebRtcCallManager
) {
class Failure : Throwable()
data class Result(val userId: String, val roomId: String)
suspend fun lookupPhoneNumber(phoneNumber: String): Result {
val supportedProtocolKey = callManager.supportedPSTNProtocol ?: throw Failure()
val thirdPartyUser = tryOrNull {
session.thirdPartyService().getThirdPartyUser(supportedProtocolKey, fields = mapOf(
"m.id.phone" to phoneNumber
)).firstOrNull()
} ?: throw Failure()
val roomId = directRoomHelper.ensureDMExists(thirdPartyUser.userId)
return Result(userId = thirdPartyUser.userId, roomId = roomId)
}
}

View file

@ -20,24 +20,28 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import im.vector.app.core.di.HasVectorInjector import im.vector.app.core.di.HasVectorInjector
import im.vector.app.features.call.WebRtcPeerConnectionManager import im.vector.app.features.call.webrtc.WebRtcCallManager
import timber.log.Timber import timber.log.Timber
class CallHeadsUpActionReceiver : BroadcastReceiver() { class CallHeadsUpActionReceiver : BroadcastReceiver() {
companion object { companion object {
const val EXTRA_CALL_ACTION_KEY = "EXTRA_CALL_ACTION_KEY" const val EXTRA_CALL_ACTION_KEY = "EXTRA_CALL_ACTION_KEY"
const val EXTRA_CALL_ID = "EXTRA_CALL_ID"
const val CALL_ACTION_REJECT = 0 const val CALL_ACTION_REJECT = 0
} }
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
val peerConnectionManager = (context.applicationContext as? HasVectorInjector) val webRtcCallManager = (context.applicationContext as? HasVectorInjector)
?.injector() ?.injector()
?.webRtcPeerConnectionManager() ?.webRtcCallManager()
?: return ?: return
when (intent?.getIntExtra(EXTRA_CALL_ACTION_KEY, 0)) { when (intent?.getIntExtra(EXTRA_CALL_ACTION_KEY, 0)) {
CALL_ACTION_REJECT -> onCallRejectClicked(peerConnectionManager) CALL_ACTION_REJECT -> {
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return
onCallRejectClicked(webRtcCallManager, callId)
}
} }
// Not sure why this should be needed // Not sure why this should be needed
@ -48,9 +52,9 @@ class CallHeadsUpActionReceiver : BroadcastReceiver() {
// context.stopService(Intent(context, CallHeadsUpService::class.java)) // context.stopService(Intent(context, CallHeadsUpService::class.java))
} }
private fun onCallRejectClicked(peerConnectionManager: WebRtcPeerConnectionManager) { private fun onCallRejectClicked(callManager: WebRtcCallManager, callId: String) {
Timber.d("onCallRejectClicked") Timber.d("onCallRejectClicked")
peerConnectionManager.endCall() callManager.getCallById(callId)?.endCall()
} }
// private fun onCallAnswerClicked(context: Context) { // private fun onCallAnswerClicked(context: Context) {

View file

@ -21,7 +21,7 @@ import android.os.Build
import android.telecom.Connection import android.telecom.Connection
import android.telecom.DisconnectCause import android.telecom.DisconnectCause
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import im.vector.app.features.call.WebRtcPeerConnectionManager import im.vector.app.features.call.webrtc.WebRtcCallManager
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -31,7 +31,7 @@ import javax.inject.Inject
val callId: String val callId: String
) : Connection() { ) : Connection() {
@Inject lateinit var peerConnectionManager: WebRtcPeerConnectionManager @Inject lateinit var callManager: WebRtcCallManager
init { init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View file

@ -0,0 +1,27 @@
/*
* 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.features.call.telecom;
import android.os.Build;
import androidx.annotation.RequiresApi;
import org.jitsi.meet.sdk.ConnectionService;
@RequiresApi(api = Build.VERSION_CODES.O)
public class CallConnectionService extends ConnectionService {
}

View file

@ -71,7 +71,7 @@ import im.vector.app.core.services.CallService
bindService(Intent(applicationContext, CallService::class.java), CallServiceConnection(connection), 0) bindService(Intent(applicationContext, CallService::class.java), CallServiceConnection(connection), 0)
connection.setInitializing() connection.setInitializing()
return CallConnection(applicationContext, roomId, callId) return connection
} }
inner class CallServiceConnection(private val callConnection: CallConnection) : ServiceConnection { inner class CallServiceConnection(private val callConnection: CallConnection) : ServiceConnection {

View file

@ -0,0 +1,24 @@
/*
* 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.app.features.call.transfer
import im.vector.app.core.platform.VectorViewModelAction
sealed class CallTransferAction : VectorViewModelAction {
data class ConnectWithUserId(val consultFirst: Boolean, val selectedUserId: String) : CallTransferAction()
data class ConnectWithPhoneNumber(val consultFirst: Boolean, val phoneNumber: String) : CallTransferAction()
}

View file

@ -0,0 +1,126 @@
/*
* 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.app.features.call.transfer
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.viewModel
import com.google.android.material.tabs.TabLayoutMediator
import im.vector.app.R
import im.vector.app.core.di.ScreenComponent
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivityCallTransferBinding
import im.vector.app.features.contactsbook.ContactsBookViewModel
import im.vector.app.features.contactsbook.ContactsBookViewState
import im.vector.app.features.userdirectory.UserListViewModel
import im.vector.app.features.userdirectory.UserListViewState
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@Parcelize
data class CallTransferArgs(val callId: String) : Parcelable
private const val USER_LIST_FRAGMENT_TAG = "USER_LIST_FRAGMENT_TAG"
class CallTransferActivity : VectorBaseActivity<ActivityCallTransferBinding>(),
CallTransferViewModel.Factory,
UserListViewModel.Factory,
ContactsBookViewModel.Factory {
@Inject lateinit var userListViewModelFactory: UserListViewModel.Factory
@Inject lateinit var callTransferViewModelFactory: CallTransferViewModel.Factory
@Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory
@Inject lateinit var errorFormatter: ErrorFormatter
private lateinit var sectionsPagerAdapter: CallTransferPagerAdapter
private val callTransferViewModel: CallTransferViewModel by viewModel()
override fun getBinding() = ActivityCallTransferBinding.inflate(layoutInflater)
override fun getCoordinatorLayout() = views.vectorCoordinatorLayout
override fun injectWith(injector: ScreenComponent) {
super.injectWith(injector)
injector.inject(this)
}
override fun create(initialState: UserListViewState): UserListViewModel {
return userListViewModelFactory.create(initialState)
}
override fun create(initialState: CallTransferViewState): CallTransferViewModel {
return callTransferViewModelFactory.create(initialState)
}
override fun create(initialState: ContactsBookViewState): ContactsBookViewModel {
return contactsBookViewModelFactory.create(initialState)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
waitingView = views.waitingView.waitingView
callTransferViewModel.observeViewEvents {
when (it) {
is CallTransferViewEvents.Dismiss -> finish()
CallTransferViewEvents.Loading -> showWaitingView()
is CallTransferViewEvents.FailToTransfer -> showSnackbar(getString(R.string.call_transfer_failure))
}
}
sectionsPagerAdapter = CallTransferPagerAdapter(this).register()
views.callTransferViewPager.adapter = sectionsPagerAdapter
sectionsPagerAdapter.onDialPadOkClicked = { phoneNumber ->
val action = CallTransferAction.ConnectWithPhoneNumber(views.callTransferConsultCheckBox.isChecked, phoneNumber)
callTransferViewModel.handle(action)
}
TabLayoutMediator(views.callTransferTabLayout, views.callTransferViewPager) { tab, position ->
when (position) {
0 -> tab.text = getString(R.string.call_transfer_users_tab_title)
1 -> tab.text = getString(R.string.call_dial_pad_title)
}
}.attach()
configureToolbar(views.callTransferToolbar)
views.callTransferToolbar.title = getString(R.string.call_transfer_title)
setupConnectAction()
}
private fun setupConnectAction() {
views.callTransferConnectAction.debouncedClicks {
val selectedUser = sectionsPagerAdapter.userListFragment?.getCurrentState()?.getSelectedMatrixId()?.firstOrNull()
if (selectedUser != null) {
val action = CallTransferAction.ConnectWithUserId(views.callTransferConsultCheckBox.isChecked, selectedUser)
callTransferViewModel.handle(action)
}
}
}
companion object {
fun newIntent(context: Context, callId: String): Intent {
return Intent(context, CallTransferActivity::class.java).also {
it.putExtra(MvRx.KEY_ARG, CallTransferArgs(callId))
}
}
}
}

View file

@ -0,0 +1,88 @@
/*
* 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.app.features.call.transfer
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import im.vector.app.core.extensions.toMvRxBundle
import im.vector.app.core.platform.Restorable
import im.vector.app.features.call.dialpad.DialPadFragment
import im.vector.app.features.settings.VectorLocale
import im.vector.app.features.userdirectory.UserListFragment
import im.vector.app.features.userdirectory.UserListFragmentArgs
class CallTransferPagerAdapter(
private val fragmentActivity: FragmentActivity
) : FragmentStateAdapter(fragmentActivity), Restorable {
val userListFragment: UserListFragment?
get() = findFragmentAtPosition(0) as? UserListFragment
val dialPadFragment: DialPadFragment?
get() = findFragmentAtPosition(1) as? DialPadFragment
var onDialPadOkClicked: ((String) -> Unit)? = null
override fun getItemCount() = 2
override fun createFragment(position: Int): Fragment {
val fragment: Fragment
if (position == 0) {
fragment = fragmentActivity.supportFragmentManager.fragmentFactory.instantiate(fragmentActivity.classLoader, UserListFragment::class.java.name)
fragment.arguments = UserListFragmentArgs(
title = "",
menuResId = -1,
singleSelection = true,
showInviteActions = false,
showToolbar = false,
showContactBookAction = false
).toMvRxBundle()
} else {
fragment = fragmentActivity.supportFragmentManager.fragmentFactory.instantiate(fragmentActivity.classLoader, DialPadFragment::class.java.name)
(fragment as DialPadFragment).apply {
arguments = Bundle().apply {
putBoolean(DialPadFragment.EXTRA_ENABLE_DELETE, true)
putBoolean(DialPadFragment.EXTRA_ENABLE_OK, true)
putString(DialPadFragment.EXTRA_REGION_CODE, VectorLocale.applicationLocale.country)
}
applyCallback()
}
}
return fragment
}
private fun findFragmentAtPosition(position: Int): Fragment? {
return fragmentActivity.supportFragmentManager.findFragmentByTag("f$position")
}
override fun onSaveInstanceState(outState: Bundle) = Unit
override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
dialPadFragment?.applyCallback()
}
private fun DialPadFragment.applyCallback(): DialPadFragment {
callback = object : DialPadFragment.Callback {
override fun onOkClicked(formatted: String?, raw: String?) {
if (raw.isNullOrEmpty()) return
onDialPadOkClicked?.invoke(raw)
}
}
return this
}
}

View file

@ -0,0 +1,25 @@
/*
* 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.app.features.call.transfer
import im.vector.app.core.platform.VectorViewEvents
sealed class CallTransferViewEvents : VectorViewEvents {
object Dismiss : CallTransferViewEvents()
object Loading: CallTransferViewEvents()
object FailToTransfer : CallTransferViewEvents()
}

View file

@ -0,0 +1,107 @@
/*
* 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.app.features.call.transfer
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.call.dialpad.DialPadLookup
import im.vector.app.features.call.webrtc.WebRtcCall
import im.vector.app.features.call.webrtc.WebRtcCallManager
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCall
class CallTransferViewModel @AssistedInject constructor(@Assisted initialState: CallTransferViewState,
private val dialPadLookup: DialPadLookup,
callManager: WebRtcCallManager)
: VectorViewModel<CallTransferViewState, CallTransferAction, CallTransferViewEvents>(initialState) {
@AssistedFactory
interface Factory {
fun create(initialState: CallTransferViewState): CallTransferViewModel
}
companion object : MvRxViewModelFactory<CallTransferViewModel, CallTransferViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: CallTransferViewState): CallTransferViewModel? {
val activity: CallTransferActivity = (viewModelContext as ActivityViewModelContext).activity()
return activity.callTransferViewModelFactory.create(state)
}
}
private val call = callManager.getCallById(initialState.callId)
private val callListener = object : WebRtcCall.Listener {
override fun onStateUpdate(call: MxCall) {
if (call.state == CallState.Terminated) {
_viewEvents.post(CallTransferViewEvents.Dismiss)
}
}
}
init {
if (call == null) {
_viewEvents.post(CallTransferViewEvents.Dismiss)
} else {
call.addListener(callListener)
}
}
override fun onCleared() {
super.onCleared()
call?.removeListener(callListener)
}
override fun handle(action: CallTransferAction) {
when (action) {
is CallTransferAction.ConnectWithUserId -> connectWithUserId(action)
is CallTransferAction.ConnectWithPhoneNumber -> connectWithPhoneNumber(action)
}.exhaustive
}
private fun connectWithUserId(action: CallTransferAction.ConnectWithUserId) {
viewModelScope.launch {
try {
_viewEvents.post(CallTransferViewEvents.Loading)
call?.mxCall?.transfer(action.selectedUserId, null)
_viewEvents.post(CallTransferViewEvents.Dismiss)
} catch (failure: Throwable) {
_viewEvents.post(CallTransferViewEvents.FailToTransfer)
}
}
}
private fun connectWithPhoneNumber(action: CallTransferAction.ConnectWithPhoneNumber) {
viewModelScope.launch {
try {
_viewEvents.post(CallTransferViewEvents.Loading)
val result = dialPadLookup.lookupPhoneNumber(action.phoneNumber)
call?.mxCall?.transfer(result.userId, result.roomId)
_viewEvents.post(CallTransferViewEvents.Dismiss)
} catch (failure: Throwable) {
_viewEvents.post(CallTransferViewEvents.FailToTransfer)
}
}
}
}

View file

@ -0,0 +1,26 @@
/*
* 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.app.features.call.transfer
import com.airbnb.mvrx.MvRxState
data class CallTransferViewState(
val callId: String
) : MvRxState {
constructor(args: CallTransferArgs) : this(callId = args.callId)
}

View file

@ -1,5 +1,5 @@
/* /*
* Copyright 2020 The Matrix.org Foundation C.I.C. * Copyright (c) 2020 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
package org.matrix.android.sdk.api.session.call package im.vector.app.features.call.utils
import org.webrtc.EglBase import org.webrtc.EglBase
import timber.log.Timber import timber.log.Timber

View file

@ -0,0 +1,81 @@
/*
* 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.app.features.call.utils
import im.vector.app.features.call.webrtc.SdpObserverAdapter
import org.webrtc.MediaConstraints
import org.webrtc.PeerConnection
import org.webrtc.SessionDescription
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
suspend fun PeerConnection.awaitCreateOffer(mediaConstraints: MediaConstraints): SessionDescription? = suspendCoroutine { cont ->
createOffer(object : SdpObserverAdapter() {
override fun onCreateSuccess(p0: SessionDescription?) {
super.onCreateSuccess(p0)
cont.resume(p0)
}
override fun onCreateFailure(p0: String?) {
super.onCreateFailure(p0)
cont.resumeWithException(IllegalStateException(p0))
}
}, mediaConstraints)
}
suspend fun PeerConnection.awaitCreateAnswer(mediaConstraints: MediaConstraints): SessionDescription? = suspendCoroutine { cont ->
createAnswer(object : SdpObserverAdapter() {
override fun onCreateSuccess(p0: SessionDescription?) {
super.onCreateSuccess(p0)
cont.resume(p0)
}
override fun onCreateFailure(p0: String?) {
super.onCreateFailure(p0)
cont.resumeWithException(IllegalStateException(p0))
}
}, mediaConstraints)
}
suspend fun PeerConnection.awaitSetLocalDescription(sessionDescription: SessionDescription): Unit = suspendCoroutine { cont ->
setLocalDescription(object : SdpObserverAdapter() {
override fun onSetFailure(p0: String?) {
super.onSetFailure(p0)
cont.resumeWithException(IllegalStateException(p0))
}
override fun onSetSuccess() {
super.onSetSuccess()
cont.resume(Unit)
}
}, sessionDescription)
}
suspend fun PeerConnection.awaitSetRemoteDescription(sessionDescription: SessionDescription): Unit = suspendCoroutine { cont ->
setRemoteDescription(object : SdpObserverAdapter() {
override fun onSetFailure(p0: String?) {
super.onSetFailure(p0)
cont.resumeWithException(IllegalStateException(p0))
}
override fun onSetSuccess() {
super.onSetSuccess()
cont.resume(Unit)
}
}, sessionDescription)
}

View file

@ -0,0 +1,38 @@
/*
* 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.app.features.call.utils
import org.matrix.android.sdk.api.session.room.model.call.CallCandidate
import org.matrix.android.sdk.api.session.room.model.call.SdpType
import org.webrtc.IceCandidate
import org.webrtc.SessionDescription
fun List<IceCandidate>.mapToCallCandidate() = map {
CallCandidate(
sdpMid = it.sdpMid,
sdpMLineIndex = it.sdpMLineIndex,
candidate = it.sdp
)
}
fun SdpType.asWebRTC(): SessionDescription.Type {
return if (this == SdpType.OFFER) {
SessionDescription.Type.OFFER
} else {
SessionDescription.Type.ANSWER
}
}

View file

@ -0,0 +1,43 @@
/*
* 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.features.call.webrtc
import kotlinx.coroutines.delay
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol
private const val PSTN_VECTOR_KEY = "im.vector.protocol.pstn"
private const val PSTN_MATRIX_KEY = "m.protocol.pstn"
suspend fun Session.getSupportedPSTN(maxTries: Int): String? {
val thirdPartyProtocols: Map<String, ThirdPartyProtocol> = try {
thirdPartyService().getThirdPartyProtocols()
} catch (failure: Throwable) {
if (maxTries == 1) {
return null
} else {
// Wait for 10s before trying again
delay(10_000L)
return getSupportedPSTN(maxTries - 1)
}
}
return when {
thirdPartyProtocols.containsKey(PSTN_VECTOR_KEY) -> PSTN_VECTOR_KEY
thirdPartyProtocols.containsKey(PSTN_MATRIX_KEY) -> PSTN_MATRIX_KEY
else -> null
}
}

Some files were not shown because too many files have changed in this diff Show more