refact WIP


TMP


WIP
This commit is contained in:
Valere 2020-05-05 11:46:21 +02:00
parent d2f1488934
commit dc19652c2b
44 changed files with 3710 additions and 30 deletions

View file

@ -162,6 +162,10 @@ dependencies {
// Phone number https://github.com/google/libphonenumber
implementation 'com.googlecode.libphonenumber:libphonenumber:8.10.23'
// Web RTC
// TODO meant for development purposes only
implementation 'org.webrtc:google-webrtc:1.0.+'
debugImplementation 'com.airbnb.okreplay:okreplay:1.5.0'
releaseImplementation 'com.airbnb.okreplay:noop:1.5.0'
androidTestImplementation 'com.airbnb.okreplay:espresso:1.5.0'

View file

@ -24,6 +24,7 @@ import im.vector.matrix.android.api.pushrules.PushRuleService
import im.vector.matrix.android.api.session.account.AccountService
import im.vector.matrix.android.api.session.accountdata.AccountDataService
import im.vector.matrix.android.api.session.cache.CacheService
import im.vector.matrix.android.api.session.call.CallService
import im.vector.matrix.android.api.session.content.ContentUploadStateTracker
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.crypto.CryptoService
@ -165,6 +166,11 @@ interface Session :
*/
fun integrationManagerService(): IntegrationManagerService
/**
* Returns the cryptoService associated with the session
*/
fun callService(): CallService
/**
* Add a listener to the session.
* @param listener the listener to add.

View file

@ -0,0 +1,57 @@
/*
* 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.matrix.android.api.session.call
import im.vector.matrix.android.api.MatrixCallback
import org.webrtc.IceCandidate
import org.webrtc.SessionDescription
interface CallService {
fun getTurnServer(callback: MatrixCallback<TurnServer?>)
fun isCallSupportedInRoom(roomId: String) : Boolean
/**
* Send offer SDP to the other participant.
*/
fun sendOfferSdp(callId: String, roomId: String, sdp: SessionDescription, callback: MatrixCallback<String>)
/**
* Send answer SDP to the other participant.
*/
fun sendAnswerSdp(callId: String, roomId: String, sdp: SessionDescription, callback: MatrixCallback<String>)
/**
* Send Ice candidate to the other participant.
*/
fun sendLocalIceCandidates(callId: String, roomId: String, candidates: List<IceCandidate>)
/**
* Send removed ICE candidates to the other participant.
*/
fun sendLocalIceCandidateRemovals(callId: String, roomId: String, candidates: List<IceCandidate>)
fun addCallListener(listener: CallsListener)
fun removeCallListener(listener: CallsListener)
}

View file

@ -0,0 +1,47 @@
/*
* 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.matrix.android.api.session.call
import im.vector.matrix.android.api.session.room.model.call.CallAnswerContent
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
interface CallsListener {
// /**
// * Called when there is an incoming call within the room.
// * @param peerSignalingClient the incoming call
// */
// fun onIncomingCall(peerSignalingClient: PeerSignalingClient)
//
// /**
// * An outgoing call is started.
// *
// * @param peerSignalingClient the outgoing call
// */
// fun onOutgoingCall(peerSignalingClient: PeerSignalingClient)
//
// /**
// * Called when a called has been hung up
// *
// * @param peerSignalingClient the incoming call
// */
// fun onCallHangUp(peerSignalingClient: PeerSignalingClient)
fun onCallInviteReceived(signalingRoomId: String, callInviteContent: CallInviteContent)
fun onCallAnswerReceived(callAnswerContent: CallAnswerContent)
}

View file

@ -0,0 +1,55 @@
/*
* 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.matrix.android.api.session.call
import org.webrtc.EglBase
import timber.log.Timber
/**
* The root [EglBase] instance shared by the entire application for
* the sake of reducing the utilization of system resources (such as EGL
* contexts)
* by performing a runtime check.
*/
object EglUtils {
// TODO how do we release that?
/**
* Lazily creates and returns the one and only [EglBase] which will
* serve as the root for all contexts that are needed.
*/
@get:Synchronized var rootEglBase: EglBase? = null
get() {
if (field == null) {
val configAttributes = EglBase.CONFIG_PLAIN
try {
field = EglBase.createEgl14(configAttributes)
?: EglBase.createEgl10(configAttributes) // Fall back to EglBase10.
} catch (ex: Throwable) {
Timber.e(ex, "Failed to create EglBase")
}
}
return field
}
private set
val rootEglBaseContext: EglBase.Context?
get() {
val eglBase = rootEglBase
return eglBase?.eglBaseContext
}
}

View file

@ -0,0 +1,66 @@
///*
// * 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.matrix.android.api.session.call
//
//import im.vector.matrix.android.api.MatrixCallback
//import org.webrtc.IceCandidate
//import org.webrtc.SessionDescription
//
//interface PeerSignalingClient {
//
// val callID: String
//
// fun addListener(listener: SignalingListener)
//
// /**
// * Send offer SDP to the other participant.
// */
// fun sendOfferSdp(sdp: SessionDescription, callback: MatrixCallback<String>)
//
// /**
// * Send answer SDP to the other participant.
// */
// fun sendAnswerSdp(sdp: SessionDescription, callback: MatrixCallback<String>)
//
// /**
// * Send Ice candidate to the other participant.
// */
// fun sendLocalIceCandidates(candidates: List<IceCandidate>)
//
// /**
// * Send removed ICE candidates to the other participant.
// */
// fun sendLocalIceCandidateRemovals(candidates: List<IceCandidate>)
//
//
// interface SignalingListener {
// /**
// * Callback fired once remote SDP is received.
// */
// fun onRemoteDescription(sdp: SessionDescription)
//
// /**
// * Callback fired once remote Ice candidate is received.
// */
// fun onRemoteIceCandidate(candidate: IceCandidate)
//
// /**
// * Callback fired once remote Ice candidate removals are received.
// */
// fun onRemoteIceCandidatesRemoved(candidates: List<IceCandidate>)
// }
//}

View file

@ -0,0 +1,40 @@
/*
* 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.matrix.android.api.session.call
import org.webrtc.IceCandidate
import org.webrtc.PeerConnection.IceServer
import org.webrtc.SessionDescription
/**
* Struct holding the connection parameters of an AppRTC room.
*/
data class RoomConnectionParameters(
val callId: String,
val matrixRoomId: String
)
/**
* Struct holding the signaling parameters of an AppRTC room.
*/
data class SignalingParameters(
val iceServers: List<IceServer>,
val initiator: Boolean,
val clientId: String,
val offerSdp: SessionDescription,
val iceCandidates: List<IceCandidate>
)

View file

@ -0,0 +1,28 @@
/*
* 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.matrix.android.api.session.call
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class TurnServer(
@Json(name = "username") val username: String?,
@Json(name = "password") val password: String?,
@Json(name = "uris") val uris: List<String>?,
@Json(name = "ttl") val ttl: Int?
)

View file

@ -0,0 +1,28 @@
/*
* 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.matrix.android.api.session.call
import im.vector.matrix.android.internal.network.NetworkConstants
import retrofit2.Call
import retrofit2.http.GET
internal interface VoipApi {
@GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "voip/turnServer")
fun getTurnServer(): Call<TurnServer>
}

View file

@ -58,7 +58,6 @@ object EventType {
const val STATE_ROOM_ENCRYPTION = "m.room.encryption"
// Call Events
const val CALL_INVITE = "m.call.invite"
const val CALL_CANDIDATES = "m.call.candidates"
const val CALL_ANSWER = "m.call.answer"

View file

@ -21,21 +21,42 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CallInviteContent(
@Json(name = "call_id") val callId: String,
@Json(name = "version") val version: Int,
@Json(name = "lifetime") val lifetime: Int,
@Json(name = "offer") val offer: Offer
/**
* A unique identifier for the call.
*/
@Json(name = "call_id") val callId: String?,
/**
* The session description object
*/
@Json(name = "version") val version: Int?,
/**
* The version of the VoIP specification this message adheres to. This specification is version 0.
*/
@Json(name = "lifetime") val lifetime: Int?,
/**
* The time in milliseconds that the invite is valid for.
* 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.
*/
@Json(name = "offer") val offer: Offer?
) {
@JsonClass(generateAdapter = true)
data class Offer(
@Json(name = "type") val type: String,
@Json(name = "sdp") val sdp: String
/**
* The type of session description (offer, answer)
*/
@Json(name = "type") val type: String?,
/**
* The SDP text of the session description.
*/
@Json(name = "sdp") val sdp: String?
) {
companion object {
const val SDP_VIDEO = "m=video"
}
}
fun isVideo(): Boolean = offer.sdp.contains(Offer.SDP_VIDEO)
fun isVideo(): Boolean = offer?.sdp?.contains(Offer.SDP_VIDEO) == true
}

View file

@ -67,6 +67,7 @@ import im.vector.matrix.android.internal.crypto.tasks.DefaultEncryptEventTask
import im.vector.matrix.android.internal.crypto.tasks.DefaultGetDeviceInfoTask
import im.vector.matrix.android.internal.crypto.tasks.DefaultGetDevicesTask
import im.vector.matrix.android.internal.crypto.tasks.DefaultInitializeCrossSigningTask
import im.vector.matrix.android.internal.crypto.tasks.DefaultSendEventTask
import im.vector.matrix.android.internal.crypto.tasks.DefaultSendToDeviceTask
import im.vector.matrix.android.internal.crypto.tasks.DefaultSendVerificationMessageTask
import im.vector.matrix.android.internal.crypto.tasks.DefaultSetDeviceNameTask
@ -80,6 +81,7 @@ import im.vector.matrix.android.internal.crypto.tasks.EncryptEventTask
import im.vector.matrix.android.internal.crypto.tasks.GetDeviceInfoTask
import im.vector.matrix.android.internal.crypto.tasks.GetDevicesTask
import im.vector.matrix.android.internal.crypto.tasks.InitializeCrossSigningTask
import im.vector.matrix.android.internal.crypto.tasks.SendEventTask
import im.vector.matrix.android.internal.crypto.tasks.SendToDeviceTask
import im.vector.matrix.android.internal.crypto.tasks.SendVerificationMessageTask
import im.vector.matrix.android.internal.crypto.tasks.SetDeviceNameTask
@ -251,4 +253,7 @@ internal abstract class CryptoModule {
@Binds
abstract fun bindInitializeCrossSigningTask(task: DefaultInitializeCrossSigningTask): InitializeCrossSigningTask
@Binds
abstract fun bindSendEventTask(task: DefaultSendEventTask): SendEventTask
}

View file

@ -0,0 +1,79 @@
/*
* 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.matrix.android.internal.crypto.tasks
import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.room.RoomAPI
import im.vector.matrix.android.internal.session.room.send.LocalEchoUpdater
import im.vector.matrix.android.internal.session.room.send.SendResponse
import im.vector.matrix.android.internal.task.Task
import org.greenrobot.eventbus.EventBus
import javax.inject.Inject
internal interface SendEventTask : Task<SendEventTask.Params, String> {
data class Params(
val event: Event,
val cryptoService: CryptoService?
)
}
internal class DefaultSendEventTask @Inject constructor(
private val localEchoUpdater: LocalEchoUpdater,
private val encryptEventTask: DefaultEncryptEventTask,
private val roomAPI: RoomAPI,
private val eventBus: EventBus) : SendEventTask {
override suspend fun execute(params: SendEventTask.Params): String {
val event = handleEncryption(params)
val localId = event.eventId!!
try {
localEchoUpdater.updateSendState(localId, SendState.SENDING)
val executeRequest = executeRequest<SendResponse>(eventBus) {
apiCall = roomAPI.send(
localId,
roomId = event.roomId ?: "",
content = event.content,
eventType = event.type
)
}
localEchoUpdater.updateSendState(localId, SendState.SENT)
return executeRequest.eventId
} catch (e: Throwable) {
localEchoUpdater.updateSendState(localId, SendState.UNDELIVERED)
throw e
}
}
private suspend fun handleEncryption(params: SendEventTask.Params): Event {
if (params.cryptoService?.isRoomEncrypted(params.event.roomId ?: "") == true) {
try {
return encryptEventTask.execute(EncryptEventTask.Params(
params.event.roomId ?: "",
params.event,
listOf("m.relates_to"),
params.cryptoService
))
} catch (throwable: Throwable) {
// We said it's ok to send verification request in clear
}
}
return params.event
}
}

View file

@ -28,6 +28,7 @@ import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.account.AccountService
import im.vector.matrix.android.api.session.accountdata.AccountDataService
import im.vector.matrix.android.api.session.cache.CacheService
import im.vector.matrix.android.api.session.call.CallService
import im.vector.matrix.android.api.session.content.ContentUploadStateTracker
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.crypto.CryptoService
@ -110,7 +111,8 @@ internal class DefaultSession @Inject constructor(
private val integrationManagerService: IntegrationManagerService,
private val taskExecutor: TaskExecutor,
private val widgetDependenciesHolder: WidgetDependenciesHolder,
private val shieldTrustUpdater: ShieldTrustUpdater)
private val shieldTrustUpdater: ShieldTrustUpdater,
private val callService: Lazy<CallService>)
: Session,
RoomService by roomService.get(),
RoomDirectoryService by roomDirectoryService.get(),
@ -245,6 +247,8 @@ internal class DefaultSession @Inject constructor(
override fun integrationManagerService() = integrationManagerService
override fun callService(): CallService = callService.get()
override fun addListener(listener: Session.Listener) {
sessionListeners.addListener(listener)
}

View file

@ -33,6 +33,7 @@ import im.vector.matrix.android.api.crypto.MXCryptoConfig
import im.vector.matrix.android.api.session.InitialSyncProgressService
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.accountdata.AccountDataService
import im.vector.matrix.android.api.session.call.CallService
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilitiesService
import im.vector.matrix.android.api.session.securestorage.SecureStorageService
import im.vector.matrix.android.api.session.securestorage.SharedSecretStorageService
@ -56,9 +57,9 @@ import im.vector.matrix.android.internal.network.NetworkCallbackStrategy
import im.vector.matrix.android.internal.network.NetworkConnectivityChecker
import im.vector.matrix.android.internal.network.PreferredNetworkCallbackStrategy
import im.vector.matrix.android.internal.network.RetrofitFactory
import im.vector.matrix.android.internal.network.httpclient.addAccessTokenInterceptor
import im.vector.matrix.android.internal.network.token.AccessTokenProvider
import im.vector.matrix.android.internal.network.token.HomeserverAccessTokenProvider
import im.vector.matrix.android.internal.network.interceptors.CurlLoggingInterceptor
import im.vector.matrix.android.internal.session.call.CallEventObserver
import im.vector.matrix.android.internal.session.call.DefaultCallService
import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater
import im.vector.matrix.android.internal.session.homeserver.DefaultHomeServerCapabilitiesService
import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater
@ -245,7 +246,11 @@ internal abstract class SessionModule {
@Binds
@IntoSet
abstract fun bindRoomTombstoneEventLiveObserver(observer: RoomTombstoneEventLiveObserver): LiveEntityObserver
abstract fun bindCallEventObserver(callEventObserver: CallEventObserver): LiveEntityObserver
@Binds
@IntoSet
abstract fun bindRoomTombstoneEventLiveObserver(roomTombstoneEventLiveObserver: RoomTombstoneEventLiveObserver): LiveEntityObserver
@Binds
@IntoSet
@ -269,4 +274,7 @@ internal abstract class SessionModule {
@Binds
abstract fun bindSharedSecretStorageService(service: DefaultSharedSecretStorageService): SharedSecretStorageService
@Binds
abstract fun bindCallService(service:DefaultCallService): CallService
}

View file

@ -0,0 +1,67 @@
/*
* 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.matrix.android.internal.session.call
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.internal.database.RealmLiveEntityObserver
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.query.whereTypes
import im.vector.matrix.android.internal.di.SessionDatabase
import im.vector.matrix.android.internal.di.UserId
import im.vector.matrix.android.internal.session.room.EventRelationsAggregationTask
import io.realm.OrderedCollectionChangeSet
import io.realm.RealmConfiguration
import io.realm.RealmResults
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
internal class CallEventObserver @Inject constructor(
@SessionDatabase realmConfiguration: RealmConfiguration,
@UserId private val userId: String,
private val task: CallEventsObserverTask) :
RealmLiveEntityObserver<EventEntity>(realmConfiguration) {
override val query = Monarchy.Query<EventEntity> {
EventEntity.whereTypes(it, listOf(
EventType.CALL_ANSWER,
EventType.CALL_CANDIDATES,
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
EventType.ENCRYPTED)
)
}
override fun onChange(results: RealmResults<EventEntity>, changeSet: OrderedCollectionChangeSet) {
Timber.v("EventRelationsAggregationUpdater called with ${changeSet.insertions.size} insertions")
val insertedDomains = changeSet.insertions
.asSequence()
.mapNotNull { results[it]?.asDomain() }
.toList()
val params = CallEventsObserverTask.Params(
insertedDomains,
userId
)
observerScope.launch {
task.execute(params)
}
}
}

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.matrix.android.internal.session.call
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.crypto.MXCryptoError
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult
import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.awaitTransaction
import io.realm.Realm
import timber.log.Timber
import javax.inject.Inject
internal interface CallEventsObserverTask : Task<CallEventsObserverTask.Params, Unit> {
data class Params(
val events: List<Event>,
val userId: String
)
}
internal class DefaultCallEventsObserverTask @Inject constructor(
private val monarchy: Monarchy,
private val cryptoService: CryptoService,
private val callService: DefaultCallService) : CallEventsObserverTask {
override suspend fun execute(params: CallEventsObserverTask.Params) {
val events = params.events
val userId = params.userId
monarchy.awaitTransaction { realm ->
Timber.v(">>> DefaultCallEventsObserverTask[${params.hashCode()}] called with ${events.size} events")
update(realm, events, userId)
Timber.v("<<< DefaultCallEventsObserverTask[${params.hashCode()}] finished")
}
}
private fun update(realm: Realm, events: List<Event>, userId: String) {
events.forEach { event ->
event.roomId ?: return@forEach Unit.also {
Timber.w("Event with no room id ${event.eventId}")
}
decryptIfNeeded(event)
when (event.getClearType()) {
EventType.CALL_INVITE,
EventType.CALL_CANDIDATES,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> {
callService.onCallEvent(event)
}
}
}
Timber.v("$realm : $userId")
}
private fun decryptIfNeeded(event: Event) {
if (event.isEncrypted() && event.mxDecryptionResult == null) {
try {
val result = cryptoService.decryptEvent(event, event.roomId ?: "")
event.mxDecryptionResult = OlmDecryptionResult(
payload = result.clearEvent,
senderKey = result.senderCurve25519Key,
keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) },
forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain
)
} catch (e: MXCryptoError) {
Timber.v("Call service: Failed to decrypt event")
// TODO -> we should keep track of this and retry, or aggregation will be broken
}
}
}
}

View file

@ -0,0 +1,206 @@
/*
* 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.matrix.android.internal.session.call
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.extensions.tryThis
import im.vector.matrix.android.api.session.call.CallService
import im.vector.matrix.android.api.session.call.CallsListener
import im.vector.matrix.android.api.session.call.TurnServer
import im.vector.matrix.android.api.session.events.model.Content
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.LocalEcho
import im.vector.matrix.android.api.session.events.model.UnsignedData
import im.vector.matrix.android.api.session.events.model.toContent
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.call.CallAnswerContent
import im.vector.matrix.android.api.session.room.model.call.CallCandidatesContent
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.matrix.android.internal.di.UserId
import im.vector.matrix.android.internal.session.SessionScope
import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory
import im.vector.matrix.android.internal.session.room.send.RoomEventSender
import org.webrtc.IceCandidate
import org.webrtc.SessionDescription
import javax.inject.Inject
@SessionScope
internal class DefaultCallService @Inject constructor(
@UserId
private val userId: String,
private val localEchoEventFactory: LocalEchoEventFactory,
private val roomEventSender: RoomEventSender
) : CallService {
private val callListeners = ArrayList<CallsListener>()
override fun getTurnServer(callback: MatrixCallback<TurnServer?>) {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
override fun isCallSupportedInRoom(roomId: String): Boolean {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
override fun sendOfferSdp(callId: String, roomId: String, sdp: SessionDescription, callback: MatrixCallback<String>) {
val eventContent = CallInviteContent(
callId = callId,
version = 0,
lifetime = CALL_TIMEOUT_MS,
offer = CallInviteContent.Offer(
type = sdp.type.canonicalForm(),
sdp = sdp.description
)
)
createEventAndLocalEcho(type = EventType.CALL_INVITE, roomId = roomId, content = eventContent.toContent()).let { event ->
roomEventSender.sendEvent(event)
// sendEventTask
// .configureWith(
// SendEventTask.Params(event = event, cryptoService = cryptoService)
// ) {
// this.callback = callback
// }.executeBy(taskExecutor)
}
}
override fun sendAnswerSdp(callId: String, roomId: String, sdp: SessionDescription, callback: MatrixCallback<String>) {
val eventContent = CallAnswerContent(
callId = callId,
version = 0,
answer = CallAnswerContent.Answer(
type = sdp.type.canonicalForm(),
sdp = sdp.description
)
)
createEventAndLocalEcho(type = EventType.CALL_INVITE, roomId = roomId, content = eventContent.toContent()).let { event ->
roomEventSender.sendEvent(event)
// sendEventTask
// .configureWith(
// SendEventTask.Params(event = event, cryptoService = cryptoService)
// ) {
// this.callback = callback
// }.executeBy(taskExecutor)
}
}
override fun sendLocalIceCandidates(callId: String, roomId: String, candidates: List<IceCandidate>) {
val eventContent = CallCandidatesContent(
callId = callId,
version = 0,
candidates = candidates.map {
CallCandidatesContent.Candidate(
sdpMid = it.sdpMid,
sdpMLineIndex = it.sdpMLineIndex.toString(),
candidate = it.sdp
)
}
)
createEventAndLocalEcho(type = EventType.CALL_CANDIDATES, roomId = roomId, content = eventContent.toContent()).let { event ->
roomEventSender.sendEvent(event)
// sendEventTask
// .configureWith(
// SendEventTask.Params(event = event, cryptoService = cryptoService)
// ) {
// this.callback = callback
// }.executeBy(taskExecutor)
}
}
override fun sendLocalIceCandidateRemovals(callId: String, roomId: String, candidates: List<IceCandidate>) {
}
override fun addCallListener(listener: CallsListener) {
if (!callListeners.contains(listener)) callListeners.add(listener)
}
override fun removeCallListener(listener: CallsListener) {
callListeners.remove(listener)
}
fun onCallEvent(event: Event) {
when (event.getClearType()) {
EventType.CALL_ANSWER -> {
event.getClearContent().toModel<CallAnswerContent>()?.let {
onCallAnswer(it)
}
}
EventType.CALL_INVITE -> {
event.getClearContent().toModel<CallInviteContent>()?.let {
onCallInvite(event.roomId ?: "", it)
}
}
}
}
private fun onCallAnswer(answer: CallAnswerContent) {
callListeners.forEach {
tryThis {
it.onCallAnswerReceived(answer)
}
}
}
private fun onCallInvite(roomId: String, answer: CallInviteContent) {
callListeners.forEach {
tryThis {
it.onCallInviteReceived(roomId, answer)
}
}
}
private fun createEventAndLocalEcho(localId: String = LocalEcho.createLocalEchoId(), type: String, roomId: String, content: Content): Event {
return Event(
roomId = roomId,
originServerTs = System.currentTimeMillis(),
senderId = userId,
eventId = localId,
type = type,
content = content,
unsignedData = UnsignedData(age = null, transactionId = localId)
).also {
localEchoEventFactory.createLocalEcho(it)
}
}
companion object {
const val CALL_TIMEOUT_MS = 120_000
}
// internal class PeerSignalingClientFactory @Inject constructor(
// @UserId private val userId: String,
// private val localEchoEventFactory: LocalEchoEventFactory,
// private val sendEventTask: SendEventTask,
// private val taskExecutor: TaskExecutor,
// private val cryptoService: CryptoService
// ) {
//
// fun create(roomId: String, callId: String): PeerSignalingClient {
// return RoomPeerSignalingClient(
// callID = callId,
// roomId = roomId,
// userId = userId,
// localEchoEventFactory = localEchoEventFactory,
// sendEventTask = sendEventTask,
// taskExecutor = taskExecutor,
// cryptoService = cryptoService
// )
// }
// }
}

View file

@ -24,6 +24,8 @@ import im.vector.matrix.android.api.session.room.RoomDirectoryService
import im.vector.matrix.android.api.session.room.RoomService
import im.vector.matrix.android.internal.session.DefaultFileService
import im.vector.matrix.android.internal.session.SessionScope
import im.vector.matrix.android.internal.session.call.CallEventsObserverTask
import im.vector.matrix.android.internal.session.call.DefaultCallEventsObserverTask
import im.vector.matrix.android.internal.session.room.alias.DefaultGetRoomIdByAliasTask
import im.vector.matrix.android.internal.session.room.alias.GetRoomIdByAliasTask
import im.vector.matrix.android.internal.session.room.create.CreateRoomTask
@ -201,4 +203,8 @@ internal abstract class RoomModule {
@Binds
abstract fun bindDeleteTagFromRoomTask(task: DefaultDeleteTagFromRoomTask): DeleteTagFromRoomTask
// TODO is this in the correct module?
@Binds
abstract fun bindCallEventsObserverTask(task: DefaultCallEventsObserverTask): CallEventsObserverTask
}

View file

@ -58,7 +58,8 @@ internal class DefaultSendService @AssistedInject constructor(
private val localEchoEventFactory: LocalEchoEventFactory,
private val cryptoService: CryptoService,
private val taskExecutor: TaskExecutor,
private val localEchoRepository: LocalEchoRepository
private val localEchoRepository: LocalEchoRepository,
private val roomEventSender: RoomEventSender
) : SendService {
@AssistedInject.Factory
@ -111,20 +112,6 @@ internal class DefaultSendService @AssistedInject constructor(
.let { sendEvent(it) }
}
private fun sendEvent(event: Event): Cancelable {
// Encrypted room handling
return if (cryptoService.isRoomEncrypted(roomId)) {
Timber.v("Send event in encrypted room")
val encryptWork = createEncryptEventWork(event, true)
// Note that event will be replaced by the result of the previous work
val sendWork = createSendEventWork(event, false)
timelineSendEventWorkCommon.postSequentialWorks(roomId, encryptWork, sendWork)
} else {
val sendWork = createSendEventWork(event, true)
timelineSendEventWorkCommon.postWork(roomId, sendWork)
}
}
override fun sendMedias(attachments: List<ContentAttachmentData>,
compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable {
@ -269,6 +256,10 @@ internal class DefaultSendService @AssistedInject constructor(
return cancelableBag
}
private fun sendEvent(event: Event): Cancelable {
return roomEventSender.sendEvent(event)
}
private fun createLocalEcho(event: Event) {
localEchoEventFactory.createLocalEcho(event)
}

View file

@ -0,0 +1,72 @@
/*
* 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.matrix.android.internal.session.room.send
import androidx.work.BackoffPolicy
import androidx.work.OneTimeWorkRequest
import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.di.SessionId
import im.vector.matrix.android.internal.di.WorkManagerProvider
import im.vector.matrix.android.internal.session.room.timeline.TimelineSendEventWorkCommon
import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import im.vector.matrix.android.internal.worker.startChain
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
internal class RoomEventSender @Inject constructor(
private val workManagerProvider: WorkManagerProvider,
private val timelineSendEventWorkCommon: TimelineSendEventWorkCommon,
@SessionId private val sessionId: String,
private val cryptoService: CryptoService
) {
fun sendEvent(event: Event): Cancelable {
// Encrypted room handling
return if (cryptoService.isRoomEncrypted(event.roomId ?: "")) {
Timber.v("Send event in encrypted room")
val encryptWork = createEncryptEventWork(event, true)
// Note that event will be replaced by the result of the previous work
val sendWork = createSendEventWork(event, false)
timelineSendEventWorkCommon.postSequentialWorks(event.roomId ?: "", encryptWork, sendWork)
} else {
val sendWork = createSendEventWork(event, true)
timelineSendEventWorkCommon.postWork(event.roomId ?: "", sendWork)
}
}
private fun createEncryptEventWork(event: Event, startChain: Boolean): OneTimeWorkRequest {
// Same parameter
val params = EncryptEventWorker.Params(sessionId, event)
val sendWorkData = WorkerParamsFactory.toData(params)
return workManagerProvider.matrixOneTimeWorkRequestBuilder<EncryptEventWorker>()
.setConstraints(WorkManagerProvider.workConstraints)
.setInputData(sendWorkData)
.startChain(startChain)
.setBackoffCriteria(BackoffPolicy.LINEAR, WorkManagerProvider.BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}
private fun createSendEventWork(event: Event, startChain: Boolean): OneTimeWorkRequest {
val sendContentWorkerParams = SendEventWorker.Params(sessionId, event)
val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)
return timelineSendEventWorkCommon.createWork<SendEventWorker>(sendWorkData, startChain)
}
}

View file

@ -390,6 +390,9 @@ dependencies {
implementation 'com.github.BillCarsonFr:JsonViewer:0.5'
// TODO meant for development purposes only
implementation 'org.webrtc:google-webrtc:1.0.+'
// QR-code
// Stick to 3.3.3 because of https://github.com/zxing/zxing/issues/1170
implementation 'com.google.zxing:core:3.3.3'

View file

@ -35,6 +35,7 @@ import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA
import im.vector.riotx.core.utils.allGranted
import im.vector.riotx.core.utils.checkPermissions
import im.vector.riotx.core.utils.toast
import im.vector.riotx.features.call.VectorCallActivity
import im.vector.riotx.features.debug.sas.DebugSasEmojiActivity
import im.vector.riotx.features.qrcode.QrCodeScannerActivity
import kotlinx.android.synthetic.debug.activity_debug_menu.*
@ -183,7 +184,8 @@ class DebugMenuActivity : VectorBaseActivity() {
@OnClick(R.id.debug_scan_qr_code)
fun scanQRCode() {
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) {
doScanQRCode()
//doScanQRCode()
startActivity(VectorCallActivity.newIntent(this, "!cyIJhOLwWgmmqreHLD:matrix.org"))
}
}

View file

@ -8,8 +8,16 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- Call feature -->
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
<uses-permission android:name="android.permission.READ_CALL_LOG"/>
<!-- READ_PHONE_STATE is needed only if your calling app reads numbers from the `PHONE_STATE`
intent action. -->
<!-- Adding CAMERA permission prevents Chromebooks to see the application on the PlayStore -->
<!-- Tell that the Camera is not mandatory to install the application -->
<uses-feature
@ -172,6 +180,7 @@
<activity
android:name=".features.attachments.preview.AttachmentsPreviewActivity"
android:theme="@style/AppTheme.AttachmentsPreview" />
<activity android:name=".features.call.VectorCallActivity" />
<activity android:name=".features.terms.ReviewTermsActivity" />
<activity android:name=".features.widgets.WidgetActivity" />
@ -186,6 +195,13 @@
android:name=".core.services.VectorSyncService"
android:exported="false" />
<service android:name=".features.call.VectorConnectionService"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
<!-- Receivers -->
<!-- Exported false, should only be accessible from this app!! -->

View file

@ -43,6 +43,7 @@ import im.vector.riotx.core.di.HasVectorInjector
import im.vector.riotx.core.di.VectorComponent
import im.vector.riotx.core.extensions.configureAndStart
import im.vector.riotx.core.rx.RxConfig
import im.vector.riotx.features.call.WebRtcPeerConnectionManager
import im.vector.riotx.features.configuration.VectorConfiguration
import im.vector.riotx.features.lifecycle.VectorActivityLifecycleCallbacks
import im.vector.riotx.features.notifications.NotificationDrawerManager
@ -80,6 +81,8 @@ class VectorApplication : Application(), HasVectorInjector, MatrixConfiguration.
@Inject lateinit var appStateHandler: AppStateHandler
@Inject lateinit var rxConfig: RxConfig
@Inject lateinit var popupAlertManager: PopupAlertManager
@Inject lateinit var webRtcPeerConnectionManager: WebRtcPeerConnectionManager
lateinit var vectorComponent: VectorComponent
private var fontThreadHandler: Handler? = null
@ -122,6 +125,7 @@ class VectorApplication : Application(), HasVectorInjector, MatrixConfiguration.
val lastAuthenticatedSession = authenticationService.getLastAuthenticatedSession()!!
activeSessionHolder.setActiveSession(lastAuthenticatedSession)
lastAuthenticatedSession.configureAndStart(applicationContext, pushRuleTriggerListener, sessionListener)
lastAuthenticatedSession.callService().addCallListener(webRtcPeerConnectionManager)
}
ProcessLifecycleOwner.get().lifecycle.addObserver(object : LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)

View file

@ -24,6 +24,7 @@ import dagger.Component
import im.vector.riotx.core.error.ErrorFormatter
import im.vector.riotx.core.preference.UserAvatarPreference
import im.vector.riotx.features.MainActivity
import im.vector.riotx.features.call.VectorCallActivity
import im.vector.riotx.features.createdirect.CreateDirectRoomActivity
import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupManageActivity
import im.vector.riotx.features.crypto.quads.SharedSecureStorageActivity
@ -130,6 +131,7 @@ interface ScreenComponent {
fun inject(activity: InviteUsersToRoomActivity)
fun inject(activity: ReviewTermsActivity)
fun inject(activity: WidgetActivity)
fun inject(activity: VectorCallActivity)
/* ==========================================================================================
* BottomSheets

View file

@ -31,6 +31,7 @@ import im.vector.riotx.core.error.ErrorFormatter
import im.vector.riotx.core.pushers.PushersManager
import im.vector.riotx.core.utils.AssetReader
import im.vector.riotx.core.utils.DimensionConverter
import im.vector.riotx.features.call.WebRtcPeerConnectionManager
import im.vector.riotx.features.configuration.VectorConfiguration
import im.vector.riotx.features.crypto.keysrequest.KeyRequestHandler
import im.vector.riotx.features.crypto.verification.IncomingVerificationRequestHandler
@ -134,6 +135,8 @@ interface VectorComponent {
fun reAuthHelper(): ReAuthHelper
fun webRtcPeerConnectionManager(): WebRtcPeerConnectionManager
@Component.Factory
interface Factory {
fun create(@BindsInstance context: Context): VectorComponent

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.call
import android.content.Context
import android.os.Build
import android.telecom.Connection
import androidx.annotation.RequiresApi
@RequiresApi(Build.VERSION_CODES.M) class CallConnection(
private val context: Context,
private val roomId: String,
private val callId: String
) : Connection() {
/**
* The telecom subsystem calls this method when you add a new incoming call and your app should show its incoming call UI.
*/
override fun onShowIncomingCallUi() {
VectorCallActivity.newIntent(context, roomId).let {
context.startActivity(it)
}
}
override fun onAnswer() {
super.onAnswer()
}
}

View file

@ -0,0 +1,133 @@
///*
// * Copyright (c) 2020 New Vector Ltd
// *
// * Licensed under the Apache License, Version 2.0 (the "License");
// * you may not use this file except in compliance with the License.
// * You may obtain a copy of the License at
// *
// * http://www.apache.org/licenses/LICENSE-2.0
// *
// * Unless required by applicable law or agreed to in writing, software
// * distributed under the License is distributed on an "AS IS" BASIS,
// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// * See the License for the specific language governing permissions and
// * limitations under the License.
// */
//
//package im.vector.riotx.features.call;
//
//import android.os.Bundle;
//import android.view.LayoutInflater;
//import android.view.View;
//import android.view.ViewGroup;
//import android.widget.ImageButton;
//import android.widget.SeekBar;
//import android.widget.TextView;
//import org.webrtc.RendererCommon.ScalingType;
//
//import androidx.fragment.app.Fragment;
//
//import im.vector.riotx.R;
//
///**
// * Fragment for call control.
// */
//public class CallFragment extends Fragment {
// private TextView contactView;
// private ImageButton cameraSwitchButton;
// private ImageButton videoScalingButton;
// private ImageButton toggleMuteButton;
// private TextView captureFormatText;
// private SeekBar captureFormatSlider;
// private OnCallEvents callEvents;
// private ScalingType scalingType;
// private boolean videoCallEnabled = true;
// /**
// * Call control interface for container activity.
// */
// public interface OnCallEvents {
// void onCallHangUp();
// void onCameraSwitch();
// void onVideoScalingSwitch(ScalingType scalingType);
// void onCaptureFormatChange(int width, int height, int framerate);
// boolean onToggleMic();
// }
// @Override
// public View onCreateView(
// LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
// View controlView = inflater.inflate(R.layout.fragment_call, container, false);
// // Create UI controls.
// contactView = controlView.findViewById(R.id.contact_name_call);
// ImageButton disconnectButton = controlView.findViewById(R.id.button_call_disconnect);
// cameraSwitchButton = controlView.findViewById(R.id.button_call_switch_camera);
// videoScalingButton = controlView.findViewById(R.id.button_call_scaling_mode);
// toggleMuteButton = controlView.findViewById(R.id.button_call_toggle_mic);
// captureFormatText = controlView.findViewById(R.id.capture_format_text_call);
// captureFormatSlider = controlView.findViewById(R.id.capture_format_slider_call);
// // Add buttons click events.
// disconnectButton.setOnClickListener(new View.OnClickListener() {
// @Override
// public void onClick(View view) {
// callEvents.onCallHangUp();
// }
// });
// cameraSwitchButton.setOnClickListener(new View.OnClickListener() {
// @Override
// public void onClick(View view) {
// callEvents.onCameraSwitch();
// }
// });
// videoScalingButton.setOnClickListener(new View.OnClickListener() {
// @Override
// public void onClick(View view) {
// if (scalingType == ScalingType.SCALE_ASPECT_FILL) {
// videoScalingButton.setBackgroundResource(R.drawable.ic_action_full_screen);
// scalingType = ScalingType.SCALE_ASPECT_FIT;
// } else {
// videoScalingButton.setBackgroundResource(R.drawable.ic_action_return_from_full_screen);
// scalingType = ScalingType.SCALE_ASPECT_FILL;
// }
// callEvents.onVideoScalingSwitch(scalingType);
// }
// });
// scalingType = ScalingType.SCALE_ASPECT_FILL;
// toggleMuteButton.setOnClickListener(new View.OnClickListener() {
// @Override
// public void onClick(View view) {
// boolean enabled = callEvents.onToggleMic();
// toggleMuteButton.setAlpha(enabled ? 1.0f : 0.3f);
// }
// });
// return controlView;
// }
// @Override
// public void onStart() {
// super.onStart();
// boolean captureSliderEnabled = false;
// Bundle args = getArguments();
// if (args != null) {
// String contactName = args.getString(CallActivity.EXTRA_ROOMID);
// contactView.setText(contactName);
// videoCallEnabled = args.getBoolean(CallActivity.EXTRA_VIDEO_CALL, true);
// captureSliderEnabled = videoCallEnabled
// && args.getBoolean(CallActivity.EXTRA_VIDEO_CAPTUREQUALITYSLIDER_ENABLED, false);
// }
// if (!videoCallEnabled) {
// cameraSwitchButton.setVisibility(View.INVISIBLE);
// }
// if (captureSliderEnabled) {
// captureFormatSlider.setOnSeekBarChangeListener(
// new CaptureQualityController(captureFormatText, callEvents));
// } else {
// captureFormatText.setVisibility(View.GONE);
// captureFormatSlider.setVisibility(View.GONE);
// }
// }
// // TODO(sakal): Replace with onAttach(Context) once we only support API level 23+.
// @SuppressWarnings("deprecation")
// @Override
// public void onAttach(Activity activity) {
// super.onAttach(activity);
// callEvents = (OnCallEvents) activity;
// }
//}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.call
import org.webrtc.DataChannel
import org.webrtc.IceCandidate
import org.webrtc.MediaStream
import org.webrtc.PeerConnection
import org.webrtc.RtpReceiver
import timber.log.Timber
abstract class PeerConnectionObserverAdapter : PeerConnection.Observer {
override fun onIceCandidate(p0: IceCandidate?) {
Timber.v("## VOIP onIceCandidate $p0")
}
override fun onDataChannel(p0: DataChannel?) {
Timber.v("## VOIP onDataChannel $p0")
}
override fun onIceConnectionReceivingChange(p0: Boolean) {
Timber.v("## VOIP onIceConnectionReceivingChange $p0")
}
override fun onIceConnectionChange(p0: PeerConnection.IceConnectionState?) {
Timber.v("## VOIP onIceConnectionChange $p0")
}
override fun onIceGatheringChange(p0: PeerConnection.IceGatheringState?) {
Timber.v("## VOIP onIceConnectionChange $p0")
}
override fun onAddStream(mediaStream: MediaStream?) {
Timber.v("## VOIP onAddStream $mediaStream")
}
override fun onSignalingChange(p0: PeerConnection.SignalingState?) {
Timber.v("## VOIP onSignalingChange $p0")
}
override fun onIceCandidatesRemoved(p0: Array<out IceCandidate>?) {
Timber.v("## VOIP onIceCandidatesRemoved $p0")
}
override fun onRemoveStream(mediaStream: MediaStream?) {
Timber.v("## VOIP onRemoveStream $mediaStream")
}
override fun onRenegotiationNeeded() {
Timber.v("## VOIP onRenegotiationNeeded")
}
override fun onAddTrack(p0: RtpReceiver?, p1: Array<out MediaStream>?) {
Timber.v("## VOIP onAddTrack $p0 / out: $p1")
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.call
import org.webrtc.SdpObserver
import org.webrtc.SessionDescription
import timber.log.Timber
abstract class SdpObserverAdapter : SdpObserver {
override fun onSetFailure(p0: String?) {
Timber.e("## SdpObserver: onSetFailure $p0")
}
override fun onSetSuccess() {
Timber.v("## SdpObserver: onSetSuccess")
}
override fun onCreateSuccess(p0: SessionDescription?) {
Timber.e("## SdpObserver: onSetFailure $p0")
}
override fun onCreateFailure(p0: String?) {
Timber.e("## SdpObserver: onSetFailure $p0")
}
}

View file

@ -0,0 +1,434 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.call
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.View
import android.view.Window
import android.view.WindowManager
import butterknife.BindView
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.viewModel
import im.vector.matrix.android.api.session.call.EglUtils
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.core.utils.PERMISSIONS_FOR_VIDEO_IP_CALL
import im.vector.riotx.core.utils.allGranted
import im.vector.riotx.core.utils.checkPermissions
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.subjects.PublishSubject
import kotlinx.android.parcel.Parcelize
import org.webrtc.Camera1Enumerator
import org.webrtc.Camera2Enumerator
import org.webrtc.EglBase
import org.webrtc.IceCandidate
import org.webrtc.MediaStream
import org.webrtc.PeerConnection
import org.webrtc.RendererCommon
import org.webrtc.SessionDescription
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoTrack
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@Parcelize
data class CallArgs(
// val callId: String? = null,
val roomId: String
) : Parcelable
class VectorCallActivity : VectorBaseActivity(), WebRtcPeerConnectionManager.Listener {
override fun getLayoutRes() = R.layout.activity_call
override fun injectWith(injector: ScreenComponent) {
super.injectWith(injector)
injector.inject(this)
}
private val callViewModel: VectorCallViewModel by viewModel()
@Inject lateinit var peerConnectionManager: WebRtcPeerConnectionManager
@Inject lateinit var viewModelFactory: VectorCallViewModel.Factory
@BindView(R.id.pip_video_view)
lateinit var pipRenderer: SurfaceViewRenderer
@BindView(R.id.fullscreen_video_view)
lateinit var fullscreenRenderer: SurfaceViewRenderer
private var rootEglBase: EglBase? = null
// private var peerConnectionFactory: PeerConnectionFactory? = null
//private var peerConnection: PeerConnection? = null
// private var remoteVideoTrack: VideoTrack? = null
private val iceCandidateSource: PublishSubject<IceCandidate> = PublishSubject.create()
override fun doBeforeSetContentView() {
// Set window styles for fullscreen-window size. Needs to be done before adding content.
requestWindowFeature(Window.FEATURE_NO_TITLE)
window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN or WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setTurnScreenOn(true)
setShowWhenLocked(true)
getSystemService(KeyguardManager::class.java)?.requestDismissKeyguard(this, null)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
or WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
)
}
window.decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
setContentView(R.layout.activity_call)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
rootEglBase = EglUtils.rootEglBase ?: return Unit.also {
finish()
}
callViewModel.viewEvents
.observe()
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
handleViewEvents(it)
}
.disposeOnDestroy()
//
// if (isFirstCreation()) {
//
// }
if (checkPermissions(PERMISSIONS_FOR_VIDEO_IP_CALL, this, CAPTURE_PERMISSION_REQUEST_CODE, R.string.permissions_rationale_msg_camera_and_audio)) {
start()
}
peerConnectionManager.listener = this
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (requestCode == CAPTURE_PERMISSION_REQUEST_CODE && allGranted(grantResults)) {
start()
} else {
// TODO display something
finish()
}
}
private fun start(): Boolean {
// Init Picture in Picture renderer
pipRenderer.init(rootEglBase!!.eglBaseContext, null)
pipRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
// Init Full Screen renderer
fullscreenRenderer.init(rootEglBase!!.eglBaseContext, null)
fullscreenRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
pipRenderer.setZOrderMediaOverlay(true);
pipRenderer.setEnableHardwareScaler(true /* enabled */);
fullscreenRenderer.setEnableHardwareScaler(true /* enabled */);
// Start with local feed in fullscreen and swap it to the pip when the call is connected.
//setSwappedFeeds(true /* isSwappedFeeds */);
if (isFirstCreation()) {
peerConnectionManager.createPeerConnectionFactory()
val cameraIterator = if (Camera2Enumerator.isSupported(this)) Camera2Enumerator(this) else Camera1Enumerator(false)
val frontCamera = cameraIterator.deviceNames
?.firstOrNull { cameraIterator.isFrontFacing(it) }
?: cameraIterator.deviceNames?.first()
?: return true
val videoCapturer = cameraIterator.createCapturer(frontCamera, null)
val iceServers = ArrayList<PeerConnection.IceServer>().apply {
listOf("turn:turn.matrix.org:3478?transport=udp", "turn:turn.matrix.org:3478?transport=tcp", "turns:turn.matrix.org:443?transport=tcp").forEach {
add(
PeerConnection.IceServer.builder(it)
.setUsername("xxxxx")
.setPassword("xxxxx")
.createIceServer()
)
}
}
peerConnectionManager.createPeerConnection(videoCapturer, iceServers)
peerConnectionManager.startCall()
}
// PeerConnectionFactory.initialize(PeerConnectionFactory
// .InitializationOptions.builder(applicationContext)
// .createInitializationOptions()
// )
// val options = PeerConnectionFactory.Options()
// val defaultVideoEncoderFactory = DefaultVideoEncoderFactory(
// rootEglBase!!.eglBaseContext, /* enableIntelVp8Encoder */
// true, /* enableH264HighProfile */
// true)
// val defaultVideoDecoderFactory = DefaultVideoDecoderFactory(rootEglBase!!.eglBaseContext)
//
// peerConnectionFactory = PeerConnectionFactory.builder()
// .setOptions(options)
// .setVideoEncoderFactory(defaultVideoEncoderFactory)
// .setVideoDecoderFactory(defaultVideoDecoderFactory)
// .createPeerConnectionFactory()
// val cameraIterator = if (Camera2Enumerator.isSupported(this)) Camera2Enumerator(this) else Camera1Enumerator(false)
// val frontCamera = cameraIterator.deviceNames
// ?.firstOrNull { cameraIterator.isFrontFacing(it) }
// ?: cameraIterator.deviceNames?.first()
// ?: return true
// val videoCapturer = cameraIterator.createCapturer(frontCamera, null)
//
// // Following instruction here: https://stackoverflow.com/questions/55085726/webrtc-create-peerconnectionfactory-object
// val surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", rootEglBase!!.eglBaseContext)
//
// val videoSource = peerConnectionFactory?.createVideoSource(videoCapturer.isScreencast)
// videoCapturer.initialize(surfaceTextureHelper, this, videoSource!!.capturerObserver)
// videoCapturer.startCapture(1280, 720, 30)
//
//
// val localVideoTrack = peerConnectionFactory?.createVideoTrack("ARDAMSv0", videoSource)
//
// // create a local audio track
// val audioSource = peerConnectionFactory?.createAudioSource(DEFAULT_AUDIO_CONSTRAINTS)
// val audioTrack = peerConnectionFactory?.createAudioTrack("ARDAMSa0", audioSource)
pipRenderer.setMirror(true)
// localVideoTrack?.addSink(pipRenderer)
/*
{
"username": "1586847781:@valere35:matrix.org",
"password": "ZzbqbqfT9O2G3WpCpesdts2lyns=",
"ttl": 86400.0,
"uris": ["turn:turn.matrix.org:3478?transport=udp", "turn:turn.matrix.org:3478?transport=tcp", "turns:turn.matrix.org:443?transport=tcp"]
}
*/
// val iceServers = ArrayList<PeerConnection.IceServer>().apply {
// listOf("turn:turn.matrix.org:3478?transport=udp", "turn:turn.matrix.org:3478?transport=tcp", "turns:turn.matrix.org:443?transport=tcp").forEach {
// add(
// PeerConnection.IceServer.builder(it)
// .setUsername("1586847781:@valere35:matrix.org")
// .setPassword("ZzbqbqfT9O2G3WpCpesdts2lyns=")
// .createIceServer()
// )
// }
// }
//
// val iceCandidateSource: PublishSubject<IceCandidate> = PublishSubject.create()
//
// iceCandidateSource
// .buffer(400, TimeUnit.MILLISECONDS)
// .subscribe {
// // omit empty :/
// if (it.isNotEmpty()) {
// callViewModel.handle(VectorCallViewActions.AddLocalIceCandidate(it))
// }
// }
// .disposeOnDestroy()
//
// peerConnection = peerConnectionFactory?.createPeerConnection(
// iceServers,
// object : PeerConnectionObserverAdapter() {
// override fun onIceCandidate(p0: IceCandidate?) {
// p0?.let {
// iceCandidateSource.onNext(it)
// }
// }
//
// override fun onAddStream(mediaStream: MediaStream?) {
// runOnUiThread {
// mediaStream?.videoTracks?.firstOrNull()?.let { videoTrack ->
// remoteVideoTrack = videoTrack
// remoteVideoTrack?.setEnabled(true)
// remoteVideoTrack?.addSink(fullscreenRenderer)
// }
// }
// }
//
// override fun onRemoveStream(mediaStream: MediaStream?) {
// remoteVideoTrack = null
// }
//
// override fun onIceConnectionChange(p0: PeerConnection.IceConnectionState?) {
// if (p0 == PeerConnection.IceConnectionState.DISCONNECTED) {
// // TODO prompt something?
// finish()
// }
// }
// }
// )
//
// val localMediaStream = peerConnectionFactory?.createLocalMediaStream("ARDAMS") // magic value?
// localMediaStream?.addTrack(localVideoTrack)
// localMediaStream?.addTrack(audioTrack)
//
// val constraints = MediaConstraints()
// constraints.mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
// constraints.mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
//
// peerConnection?.addStream(localMediaStream)
//
// peerConnection?.createOffer(object : SdpObserver {
// override fun onSetFailure(p0: String?) {
// Timber.v("## VOIP onSetFailure $p0")
// }
//
// override fun onSetSuccess() {
// Timber.v("## VOIP onSetSuccess")
// }
//
// override fun onCreateSuccess(sessionDescription: SessionDescription) {
// Timber.v("## VOIP onCreateSuccess $sessionDescription")
// peerConnection?.setLocalDescription(object : SdpObserverAdapter() {
// override fun onSetSuccess() {
// callViewModel.handle(VectorCallViewActions.SendOffer(sessionDescription))
// }
// }, sessionDescription)
// }
//
// override fun onCreateFailure(p0: String?) {
// Timber.v("## VOIP onCreateFailure $p0")
// }
// }, constraints)
iceCandidateSource
.buffer(400, TimeUnit.MILLISECONDS)
.subscribe {
// omit empty :/
if (it.isNotEmpty()) {
callViewModel.handle(VectorCallViewActions.AddLocalIceCandidate(it))
}
}
.disposeOnDestroy()
peerConnectionManager.attachViewRenderers(pipRenderer, fullscreenRenderer)
return false
}
override fun onDestroy() {
peerConnectionManager.detachRenderers()
peerConnectionManager.listener = this
super.onDestroy()
}
private fun handleViewEvents(event: VectorCallViewEvents?) {
when (event) {
is VectorCallViewEvents.CallAnswered -> {
val sdp = SessionDescription(SessionDescription.Type.ANSWER, event.content.answer.sdp)
peerConnectionManager.answerReceived("", sdp)
// peerConnection?.setRemoteDescription(object : SdpObserverAdapter() {}, sdp)
}
}
}
// @TargetApi(17)
// private fun getDisplayMetrics(): DisplayMetrics? {
// val displayMetrics = DisplayMetrics()
// val windowManager = application.getSystemService(Context.WINDOW_SERVICE) as WindowManager
// windowManager.defaultDisplay.getRealMetrics(displayMetrics)
// return displayMetrics
// }
// @TargetApi(21)
// private fun startScreenCapture() {
// val mediaProjectionManager: MediaProjectionManager = application.getSystemService(
// Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
// startActivityForResult(
// mediaProjectionManager.createScreenCaptureIntent(), CAPTURE_PERMISSION_REQUEST_CODE)
// }
//
// override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
// if (requestCode != CAPTURE_PERMISSION_REQUEST_CODE) {
// super.onActivityResult(requestCode, resultCode, data)
// }
//// mediaProjectionPermissionResultCode = resultCode;
//// mediaProjectionPermissionResultData = data;
//// startCall();
// }
companion object {
private const val CAPTURE_PERMISSION_REQUEST_CODE = 1
// private val DEFAULT_AUDIO_CONSTRAINTS = MediaConstraints().apply {
// // add all existing audio filters to avoid having echos
// mandatory.add(MediaConstraints.KeyValuePair("googEchoCancellation", "true"))
// mandatory.add(MediaConstraints.KeyValuePair("googEchoCancellation2", "true"))
// mandatory.add(MediaConstraints.KeyValuePair("googDAEchoCancellation", "true"))
//
// mandatory.add(MediaConstraints.KeyValuePair("googTypingNoiseDetection", "true"))
//
// mandatory.add(MediaConstraints.KeyValuePair("googAutoGainControl", "true"))
// mandatory.add(MediaConstraints.KeyValuePair("googAutoGainControl2", "true"))
//
// mandatory.add(MediaConstraints.KeyValuePair("googNoiseSuppression", "true"))
// mandatory.add(MediaConstraints.KeyValuePair("googNoiseSuppression2", "true"))
//
// mandatory.add(MediaConstraints.KeyValuePair("googAudioMirroring", "false"))
// mandatory.add(MediaConstraints.KeyValuePair("googHighpassFilter", "true"))
// }
fun newIntent(context: Context, signalingRoomId: String): Intent {
return Intent(context, VectorCallActivity::class.java).apply {
putExtra(MvRx.KEY_ARG, CallArgs(roomId = signalingRoomId))
}
}
}
override fun addLocalIceCandidate(candidates: IceCandidate) {
iceCandidateSource.onNext(candidates)
}
override fun addRemoteVideoTrack(videoTrack: VideoTrack) {
runOnUiThread {
videoTrack.setEnabled(true)
videoTrack.addSink(fullscreenRenderer)
}
}
override fun addLocalVideoTrack(videoTrack: VideoTrack) {
runOnUiThread {
videoTrack.addSink(pipRenderer)
}
}
override fun removeRemoteVideoStream(mediaStream: MediaStream) {
}
override fun onDisconnect() {
}
override fun sendOffer(sessionDescription: SessionDescription) {
callViewModel.handle(VectorCallViewActions.SendOffer(sessionDescription))
}
}

View file

@ -0,0 +1,124 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.call
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.call.CallsListener
import im.vector.matrix.android.api.session.room.model.call.CallAnswerContent
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.matrix.android.internal.util.awaitCallback
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.VectorViewEvents
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.platform.VectorViewModelAction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.webrtc.IceCandidate
import org.webrtc.SessionDescription
import java.util.UUID
data class VectorCallViewState(
val callId: String? = null,
val roomId: String = ""
) : MvRxState
sealed class VectorCallViewActions : VectorViewModelAction {
data class SendOffer(val sdp: SessionDescription) : VectorCallViewActions()
data class AddLocalIceCandidate(val iceCandidates: List<IceCandidate>) : VectorCallViewActions()
}
sealed class VectorCallViewEvents : VectorViewEvents {
data class CallAnswered(val content: CallAnswerContent) : VectorCallViewEvents()
}
class VectorCallViewModel @AssistedInject constructor(
@Assisted initialState: VectorCallViewState,
val session: Session
) : VectorViewModel<VectorCallViewState, VectorCallViewActions, VectorCallViewEvents>(initialState) {
private val callServiceListener: CallsListener = object : CallsListener {
override fun onCallAnswerReceived(callAnswerContent: CallAnswerContent) {
withState { state ->
if (callAnswerContent.callId == state.callId) {
_viewEvents.post(VectorCallViewEvents.CallAnswered(callAnswerContent))
}
}
}
override fun onCallInviteReceived(signalingRoomId: String, callInviteContent: CallInviteContent) {
}
}
init {
session.callService().addCallListener(callServiceListener)
}
override fun onCleared() {
session.callService().removeCallListener(callServiceListener)
super.onCleared()
}
override fun handle(action: VectorCallViewActions) = withState { state ->
when (action) {
is VectorCallViewActions.SendOffer -> {
viewModelScope.launch(Dispatchers.IO) {
awaitCallback<String> {
val callId = state.callId ?: UUID.randomUUID().toString().also {
setState {
copy(callId = it)
}
}
session.callService().sendOfferSdp(callId, state.roomId, action.sdp, it)
}
}
}
is VectorCallViewActions.AddLocalIceCandidate -> {
viewModelScope.launch {
session.callService().sendLocalIceCandidates(state.callId ?: "", state.roomId, action.iceCandidates)
}
}
}.exhaustive
}
@AssistedInject.Factory
interface Factory {
fun create(initialState: VectorCallViewState): VectorCallViewModel
}
companion object : MvRxViewModelFactory<VectorCallViewModel, VectorCallViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: VectorCallViewState): VectorCallViewModel? {
val callActivity: VectorCallActivity = viewModelContext.activity()
return callActivity.viewModelFactory.create(state)
}
override fun initialState(viewModelContext: ViewModelContext): VectorCallViewState? {
val args: CallArgs = viewModelContext.args()
return VectorCallViewState(roomId = args.roomId)
}
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.call
import android.os.Build
import android.telecom.Connection
import android.telecom.ConnectionRequest
import android.telecom.ConnectionService
import android.telecom.PhoneAccountHandle
import androidx.annotation.RequiresApi
/**
* No active calls in other apps
*
*To answer incoming calls when there are no active calls in other apps, follow these steps:
*
* <pre>
* * Your app receives a new incoming call using its usual mechanisms.
* - Use the addNewIncomingCall(PhoneAccountHandle, Bundle) method to inform the telecom subsystem about the new incoming call.
* - The telecom subsystem binds to your app's ConnectionService implementation and requests a new instance of the
* Connection class representing the new incoming call using the onCreateIncomingConnection(PhoneAccountHandle, ConnectionRequest) method.
* - The telecom subsystem informs your app that it should show its incoming call user interface using the onShowIncomingCallUi() method.
* - Your app shows its incoming UI using a notification with an associated full-screen intent. For more information, see onShowIncomingCallUi().
* - Call the setActive() method if the user accepts the incoming call, or setDisconnected(DisconnectCause) specifying REJECTED as
* the parameter followed by a call to the destroy() method if the user rejects the incoming call.
*</pre>
*/
@RequiresApi(Build.VERSION_CODES.M) class VectorConnectionService : ConnectionService() {
/**
* The telecom subsystem calls this method in response to your app calling placeCall(Uri, Bundle) to create a new outgoing call
*/
override fun onCreateOutgoingConnection(connectionManagerPhoneAccount: PhoneAccountHandle?, request: ConnectionRequest?): Connection? {
val callId = request?.address?.encodedQuery ?: return null
val roomId = request.extras.getString("MX_CALL_ROOM_ID") ?: return null
return CallConnection(applicationContext, roomId, callId)
}
}

View file

@ -0,0 +1,372 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.call
import android.content.ComponentName
import android.content.Context
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Bundle
import android.telecom.PhoneAccount
import android.telecom.PhoneAccountHandle
import android.telecom.TelecomManager
import androidx.core.content.ContextCompat
import im.vector.matrix.android.api.session.call.CallsListener
import im.vector.matrix.android.api.session.call.EglUtils
import im.vector.matrix.android.api.session.room.model.call.CallAnswerContent
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.riotx.BuildConfig
import im.vector.riotx.R
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.DefaultVideoEncoderFactory
import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints
import org.webrtc.MediaStream
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.SdpObserver
import org.webrtc.SessionDescription
import org.webrtc.SurfaceTextureHelper
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoCapturer
import org.webrtc.VideoSource
import org.webrtc.VideoTrack
import timber.log.Timber
import java.lang.ref.WeakReference
import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manage peerConnectionFactory & Peer connections outside of activity lifecycle to resist configuration changes
* Use app context
*/
@Singleton
class WebRtcPeerConnectionManager @Inject constructor(
private val context: Context
) : CallsListener {
interface Listener {
fun addLocalIceCandidate(candidates: IceCandidate)
fun addRemoteVideoTrack(videoTrack: VideoTrack)
fun addLocalVideoTrack(videoTrack: VideoTrack)
fun removeRemoteVideoStream(mediaStream: MediaStream)
fun onDisconnect()
fun sendOffer(sessionDescription: SessionDescription)
}
var phoneAccountHandle: PhoneAccountHandle? = null
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val componentName = ComponentName(BuildConfig.APPLICATION_ID, VectorConnectionService::class.java.name)
val appName = context.getString(R.string.app_name)
phoneAccountHandle = PhoneAccountHandle(componentName, appName)
val phoneAccount = PhoneAccount.Builder(phoneAccountHandle, appName)
.setIcon(Icon.createWithResource(context, R.drawable.riotx_logo))
.build()
ContextCompat.getSystemService(context, TelecomManager::class.java)
?.registerPhoneAccount(phoneAccount)
} else {
// ignore?
}
}
var listener: Listener? = null
// *Comments copied from webrtc demo app*
// Executor thread is started once and is used for all
// peer connection API calls to ensure new peer connection factory is
// created on the same thread as previously destroyed factory.
private val executor = Executors.newSingleThreadExecutor();
private val rootEglBase by lazy { EglUtils.rootEglBase }
private var peerConnectionFactory: PeerConnectionFactory? = null
private var peerConnection: PeerConnection? = null
private var remoteVideoTrack: VideoTrack? = null
private var localVideoTrack: VideoTrack? = null
private var videoSource: VideoSource? = null
private var audioSource: AudioSource? = null
private var audioTrack: AudioTrack? = null
private var videoCapturer: VideoCapturer? = null
var localSurfaceRenderer: WeakReference<SurfaceViewRenderer>? = null
var remoteSurfaceRenderer: WeakReference<SurfaceViewRenderer>? = null
fun createPeerConnectionFactory() {
executor.execute {
if (peerConnectionFactory == null) {
Timber.v("## VOIP createPeerConnectionFactory")
val eglBaseContext = rootEglBase?.eglBaseContext ?: return@execute Unit.also {
Timber.e("## VOIP No EGL BASE")
}
Timber.v("## VOIP PeerConnectionFactory.initialize")
PeerConnectionFactory.initialize(PeerConnectionFactory
.InitializationOptions.builder(context.applicationContext)
.createInitializationOptions()
)
val options = PeerConnectionFactory.Options()
val defaultVideoEncoderFactory = DefaultVideoEncoderFactory(
eglBaseContext,
/* enableIntelVp8Encoder */
true,
/* enableH264HighProfile */
true)
val defaultVideoDecoderFactory = DefaultVideoDecoderFactory(eglBaseContext)
Timber.v("## VOIP PeerConnectionFactory.createPeerConnectionFactory ...")
peerConnectionFactory = PeerConnectionFactory.builder()
.setOptions(options)
.setVideoEncoderFactory(defaultVideoEncoderFactory)
.setVideoDecoderFactory(defaultVideoDecoderFactory)
.createPeerConnectionFactory()
}
}
}
fun createPeerConnection(videoCapturer: VideoCapturer, iceServers: List<PeerConnection.IceServer>) {
executor.execute {
Timber.v("## VOIP PeerConnectionFactory.createPeerConnection ${peerConnectionFactory}...")
// Following instruction here: https://stackoverflow.com/questions/55085726/webrtc-create-peerconnectionfactory-object
val surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", rootEglBase!!.eglBaseContext)
videoSource = peerConnectionFactory?.createVideoSource(videoCapturer.isScreencast)
Timber.v("## VOIP Local video source created")
videoCapturer.initialize(surfaceTextureHelper, context.applicationContext, videoSource!!.capturerObserver)
videoCapturer.startCapture(1280, 720, 30)
localVideoTrack = peerConnectionFactory?.createVideoTrack("ARDAMSv0", videoSource)?.also {
Timber.v("## VOIP Local video track created")
listener?.addLocalVideoTrack(it)
// localSurfaceRenderer?.get()?.let { surface ->
//// it.addSink(surface)
//// }
}
// create a local audio track
Timber.v("## VOIP create local audio track")
audioSource = peerConnectionFactory?.createAudioSource(DEFAULT_AUDIO_CONSTRAINTS)
audioTrack = peerConnectionFactory?.createAudioTrack(AUDIO_TRACK_ID, audioSource)
// pipRenderer.setMirror(true)
// localVideoTrack?.addSink(pipRenderer)
//
// val iceCandidateSource: PublishSubject<IceCandidate> = PublishSubject.create()
//
// iceCandidateSource
// .buffer(400, TimeUnit.MILLISECONDS)
// .subscribe {
// // omit empty :/
// if (it.isNotEmpty()) {
// listener.addLocalIceCandidate()
// callViewModel.handle(VectorCallViewActions.AddLocalIceCandidate(it))
// }
// }
// .disposeOnDestroy()
Timber.v("## VOIP creating peer connection... ")
peerConnection = peerConnectionFactory?.createPeerConnection(
iceServers,
object : PeerConnectionObserverAdapter() {
override fun onIceCandidate(p0: IceCandidate?) {
Timber.v("## VOIP onIceCandidate local $p0")
p0?.let {
// iceCandidateSource.onNext(it)
listener?.addLocalIceCandidate(it)
}
}
override fun onAddStream(mediaStream: MediaStream?) {
Timber.v("## VOIP onAddStream remote $mediaStream")
mediaStream?.videoTracks?.firstOrNull()?.let {
listener?.addRemoteVideoTrack(it)
remoteVideoTrack = it
// remoteSurfaceRenderer?.get()?.let { surface ->
// it.setEnabled(true)
// it.addSink(surface)
// }
}
// runOnUiThread {
// mediaStream?.videoTracks?.firstOrNull()?.let { videoTrack ->
// remoteVideoTrack = videoTrack
// remoteVideoTrack?.setEnabled(true)
// remoteVideoTrack?.addSink(fullscreenRenderer)
// }
// }
}
override fun onRemoveStream(mediaStream: MediaStream?) {
mediaStream?.let {
listener?.removeRemoteVideoStream(it)
}
remoteSurfaceRenderer?.get()?.let {
remoteVideoTrack?.removeSink(it)
}
remoteVideoTrack = null
}
override fun onIceConnectionChange(p0: PeerConnection.IceConnectionState?) {
Timber.v("## VOIP onIceConnectionChange $p0")
if (p0 == PeerConnection.IceConnectionState.DISCONNECTED) {
listener?.onDisconnect()
}
}
}
)
val localMediaStream = peerConnectionFactory?.createLocalMediaStream("ARDAMS") // magic value?
localMediaStream?.addTrack(localVideoTrack)
localMediaStream?.addTrack(audioTrack)
// val constraints = MediaConstraints()
// constraints.mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
// constraints.mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
Timber.v("## VOIP add local stream to peer connection")
peerConnection?.addStream(localMediaStream)
}
}
fun answerReceived(callId: String, answerSdp: SessionDescription) {
executor.execute {
Timber.v("## answerReceived $callId")
peerConnection?.setRemoteDescription(object : SdpObserverAdapter() {}, answerSdp)
}
}
fun startCall() {
executor.execute {
val constraints = MediaConstraints()
constraints.mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
constraints.mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
Timber.v("## VOIP creating offer...")
peerConnection?.createOffer(object : SdpObserver {
override fun onSetFailure(p0: String?) {
Timber.v("## VOIP onSetFailure $p0")
}
override fun onSetSuccess() {
Timber.v("## VOIP onSetSuccess")
}
override fun onCreateSuccess(sessionDescription: SessionDescription) {
Timber.v("## VOIP onCreateSuccess $sessionDescription")
peerConnection?.setLocalDescription(object : SdpObserverAdapter() {
override fun onSetSuccess() {
listener?.sendOffer(sessionDescription)
//callViewModel.handle(VectorCallViewActions.SendOffer(sessionDescription))
}
}, sessionDescription)
}
override fun onCreateFailure(p0: String?) {
Timber.v("## VOIP onCreateFailure $p0")
}
}, constraints)
}
}
fun attachViewRenderers(localViewRenderer: SurfaceViewRenderer, remoteViewRenderer: SurfaceViewRenderer) {
localVideoTrack?.addSink(localViewRenderer)
remoteVideoTrack?.let {
it.setEnabled(true)
it.addSink(remoteViewRenderer)
}
localSurfaceRenderer = WeakReference(localViewRenderer)
remoteSurfaceRenderer = WeakReference(remoteViewRenderer)
}
fun detachRenderers() {
localSurfaceRenderer?.get()?.let {
localVideoTrack?.removeSink(it)
}
remoteSurfaceRenderer?.get()?.let {
remoteVideoTrack?.removeSink(it)
}
localSurfaceRenderer = null
remoteSurfaceRenderer = null
}
fun close() {
executor.execute {
peerConnectionFactory?.stopAecDump()
peerConnectionFactory = null
audioSource?.dispose()
videoSource?.dispose()
peerConnection?.dispose()
peerConnection = null
videoCapturer?.dispose()
}
}
companion object {
private const val AUDIO_TRACK_ID = "ARDAMSa0"
private val DEFAULT_AUDIO_CONSTRAINTS = MediaConstraints().apply {
// add all existing audio filters to avoid having echos
mandatory.add(MediaConstraints.KeyValuePair("googEchoCancellation", "true"))
mandatory.add(MediaConstraints.KeyValuePair("googEchoCancellation2", "true"))
mandatory.add(MediaConstraints.KeyValuePair("googDAEchoCancellation", "true"))
mandatory.add(MediaConstraints.KeyValuePair("googTypingNoiseDetection", "true"))
mandatory.add(MediaConstraints.KeyValuePair("googAutoGainControl", "true"))
mandatory.add(MediaConstraints.KeyValuePair("googAutoGainControl2", "true"))
mandatory.add(MediaConstraints.KeyValuePair("googNoiseSuppression", "true"))
mandatory.add(MediaConstraints.KeyValuePair("googNoiseSuppression2", "true"))
mandatory.add(MediaConstraints.KeyValuePair("googAudioMirroring", "false"))
mandatory.add(MediaConstraints.KeyValuePair("googHighpassFilter", "true"))
}
}
override fun onCallInviteReceived(signalingRoomId: String, callInviteContent: CallInviteContent) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ContextCompat.getSystemService(context, TelecomManager::class.java)?.let { telecomManager ->
phoneAccountHandle?.let { phoneAccountHandle ->
telecomManager.addNewIncomingCall(
phoneAccountHandle,
Bundle().apply {
putString("MX_CALL_ROOM_ID", signalingRoomId)
putString("MX_CALL_CALL_ID", callInviteContent.callId)
}
)
}
}
}
}
override fun onCallAnswerReceived(callAnswerContent: CallAnswerContent) {
}
}

View file

@ -68,6 +68,7 @@ sealed class RoomDetailAction : VectorViewModelAction {
object ClearSendQueue : RoomDetailAction()
object ResendAll : RoomDetailAction()
object StartCall : RoomDetailAction()
data class AcceptVerificationRequest(val transactionId: String, val otherUserId: String) : RoomDetailAction()
data class DeclineVerificationRequest(val transactionId: String, val otherUserId: String) : RoomDetailAction()

View file

@ -127,6 +127,7 @@ import im.vector.riotx.features.attachments.ContactAttachment
import im.vector.riotx.features.attachments.preview.AttachmentsPreviewActivity
import im.vector.riotx.features.attachments.preview.AttachmentsPreviewArgs
import im.vector.riotx.features.attachments.toGroupedContentAttachmentData
import im.vector.riotx.features.call.VectorCallActivity
import im.vector.riotx.features.command.Command
import im.vector.riotx.features.crypto.keysbackup.restore.KeysBackupRestoreActivity
import im.vector.riotx.features.crypto.util.toImageRes
@ -479,6 +480,17 @@ class RoomDetailFragment @Inject constructor(
}
else -> super.onOptionsItemSelected(item)
}
if (item.itemId == R.id.resend_all) {
roomDetailViewModel.handle(RoomDetailAction.ResendAll)
return true
}
if (item.itemId == R.id.voip_call) {
VectorCallActivity.newIntent(requireContext(), roomDetailArgs.roomId).let {
startActivity(it)
}
return true
}
return super.onOptionsItemSelected(item)
}
private fun displayDisabledIntegrationDialog() {

View file

@ -66,6 +66,7 @@ import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.core.resources.UserPreferencesProvider
import im.vector.riotx.core.utils.subscribeLogError
import im.vector.riotx.features.call.VectorCallActivity
import im.vector.riotx.features.command.CommandParser
import im.vector.riotx.features.command.ParsedCommand
import im.vector.riotx.features.crypto.verification.SupportedVerificationMethodsProvider
@ -372,6 +373,7 @@ class RoomDetailViewModel @AssistedInject constructor(
R.id.resend_all -> timeline.failedToDeliverEventCount() > 0
R.id.clear_all -> timeline.failedToDeliverEventCount() > 0
R.id.open_matrix_apps -> true
R.id.voip_call -> room.roomSummary()?.isDirect == true && room.roomSummary()?.joinedMembersCount == 2
else -> false
}

View file

@ -239,7 +239,7 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
return when (type) {
EventType.CALL_INVITE -> {
val content = event.getClearContent().toModel<CallInviteContent>() ?: return null
val isVideoCall = content.offer.sdp == CallInviteContent.Offer.SDP_VIDEO
val isVideoCall = content.offer?.sdp == CallInviteContent.Offer.SDP_VIDEO
return if (isVideoCall) {
if (event.isSentByCurrentUser()) {
sp.getString(R.string.notice_placed_video_call_by_you)

View file

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M21.9994,16.9738V19.9846C22.0017,20.5498 21.7651,21.0898 21.3478,21.4718C20.9305,21.8539 20.3712,22.0427 19.8072,21.9918C16.7128,21.6563 13.7404,20.601 11.1289,18.9108C8.6992,17.3699 6.6393,15.3141 5.0953,12.8892C3.3959,10.271 2.3382,7.2901 2.0082,4.188C1.9574,3.6268 2.1452,3.0702 2.5258,2.6541C2.9064,2.2379 3.4448,2.0006 4.0093,2.0001H7.0261C8.0356,1.9902 8.896,2.7287 9.0373,3.7263C9.1646,4.6898 9.4007,5.6359 9.7412,6.5464C10.0175,7.2799 9.8407,8.1068 9.2887,8.664L8.0116,9.9386C9.4431,12.4512 11.5276,14.5315 14.0451,15.9602L15.3222,14.6856C15.8805,14.1346 16.7091,13.9583 17.444,14.234C18.3564,14.5738 19.3043,14.8094 20.2697,14.9365C21.2809,15.0789 22.0247,15.955 21.9994,16.9738Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- tools:ignore is needed because lint thinks this can be replaced with a merge. Replacing this
with a merge causes the fullscreen SurfaceView not to be centered. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:ignore="MergeRootFrame">
<org.webrtc.SurfaceViewRenderer
android:id="@+id/fullscreen_video_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center" />
<org.webrtc.SurfaceViewRenderer
android:id="@+id/pip_video_view"
android:layout_width="wrap_content"
android:layout_height="144dp"
android:layout_gravity="bottom|end"
android:layout_margin="16dp" />
<FrameLayout
android:id="@+id/hud_fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<FrameLayout
android:id="@+id/call_fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
</FrameLayout>

View file

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<TextView
android:id="@+id/contact_name_call"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_above="@+id/buttons_call_container"
android:textSize="24sp"
android:layout_margin="8dp"/>
<LinearLayout
android:id="@+id/buttons_call_container"
android:orientation="horizontal"
android:layout_above="@+id/capture_format_text_call"
android:layout_alignWithParentIfMissing="true"
android:layout_marginBottom="32dp"
android:layout_centerHorizontal="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<ImageButton
android:id="@+id/button_call_disconnect"
android:background="@drawable/ic_phone"
android:layout_marginEnd="16dp"
android:layout_width="48dp"
android:layout_height="48dp"/>
<ImageButton
android:id="@+id/button_call_switch_camera"
android:background="@drawable/ic_camera"
android:layout_marginEnd="8dp"
android:layout_width="48dp"
android:layout_height="48dp"/>
<ImageButton
android:id="@+id/button_call_scaling_mode"
android:background="@drawable/scrolldown"
android:layout_width="48dp"
android:layout_height="48dp"/>
<ImageButton
android:id="@+id/button_call_toggle_mic"
android:background="@android:drawable/ic_btn_speak_now"
android:layout_marginEnd="8dp"
android:layout_width="48dp"
android:layout_height="48dp"/>
</LinearLayout>
<TextView
android:id="@+id/capture_format_text_call"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_above="@+id/capture_format_slider_call"
android:textSize="16sp"
tools:text="Slide to change capture format"/>
<SeekBar
android:id="@+id/capture_format_slider_call"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_alignParentBottom="true"
android:progress="50"
android:layout_margin="8dp"/>
</RelativeLayout>

View file

@ -8,6 +8,14 @@
android:title="@string/room_add_matrix_apps"
app:showAsAction="never" />
<item
android:id="@+id/voip_call"
android:icon="@drawable/ic_phone"
android:title="@string/call"
android:visible="false"
app:showAsAction="always"
tools:visible="true" />
<item
android:id="@+id/resend_all"
android:icon="@drawable/ic_refresh_cw"