Merge pull request #1416 from vector-im/feature/voip

Call support with WebRTC
This commit is contained in:
Valere 2020-06-22 17:23:22 +02:00 committed by GitHub
commit 55993aff04
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
110 changed files with 6669 additions and 171 deletions

View File

@ -2,7 +2,7 @@ Changes in RiotX 0.23.0 (2020-XX-XX)
===================================================
Features ✨:
-
- Call with WebRTC support (##611)
Improvements 🙌:
- "Add Matrix app" menu is now always visible (#1495)

420
docs/voip_signaling.md Normal file
View File

@ -0,0 +1,420 @@
╔════════════════════════════════════════════════╗
║ ║
║A] Placing a call offer ║
║ ║
╚════════════════════════════════════════════════╝
┌───────────────┐
│ Matrix │
├───────────────┴ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
┌─────────────────┐ │ ┌───────────────────────────┐ ┌─────────────────┐
│ Caller │ │ Signaling Room │ │ │ Callee │
└─────────────────┘ │ ├───────────────────────────┤ └─────────────────┘
┌────┐ │ │ │
│ 3 │ │ │ ┌────────────────────┐ │
┌─────────────────┐──────┴────┴──────────────────────────┼─▶│ m.call.invite │ │ │ ┌─────────────────┐
│ │ │ │ │ mx event │ │ │ │
│ │ │ └────────────────────┘ │ │ │ │
│ │ │ │ │ │ │
│ Riot.im │ │ │ │ │ Riot.im │
┌──│ App │ │ │ │ │ App │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ └─────────────────┘ │ │ │ └─────────────────┘
┌────┤ ▲ │ │ │
│ 1 │ ├────┐ │ └───────────────────────────┘
└────┤ │ 2 │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
│ ┌──┴────┴─────────┐ ┌─────────────────┐
│ │ │ │ │
│ │ │ │ │
│ │ WebRtc │ │ WebRtc │
└─▶│ │ │ │
│ │ │ │
│ │ │ │
└─────────────────┘ └─────────────────┘
┌────┐
│ 1 │ The Caller app get access to system resources (camera, mic), eventually stun/turn servers, define some
└────┘ constrains (video quality, format) and pass it to WebRtc in order to create a Peer Call offer
┌────┐
│ 2 │ The WebRtc layer creates a call Offer (sdp) that needs to be sent to callee
└────┘
┌────┐ The app layer, takes the webrtc offer, encapsulate it in a matrix event adds a callId and send it to the other
│ 3 │ user via the room
└────┘
┌──────────────┐
│ mx event │
├──────────────┴────────┐
│ type: m.call.invite │
│ + callId │
│ │
│ ┌──────────────────┐ │
│ │ webrtc sdp │ │
│ └──────────────────┘ │
└───────────────────────┘
╔════════════════════════════════════════════════╗
║ ║
║B] Sending connection establishment info ║
║ ║
╚════════════════════════════════════════════════╝
┌───────────────┐
│ Matrix │
├───────────────┴ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
┌─────────────────┐ │ ┌───────────────────────────┐ ┌─────────────────┐
│ Caller │ │ Signaling Room │ │ │ Callee │
└─────────────────┘ │ ├───────────────────────────┤ └─────────────────┘
│ ┌────────────────────┐ │ │
│ │ │ m.call.invite │ │
┌─────────────────┐ │ │ mx event │ │ │ ┌─────────────────┐
│ │ ┌────┐ │ │ └────────────────────┘ │ │ │
│ │ │ 3 │ │ ┌────────────────────┐ │ │ │ │
│ │──────┴────┴───────┼──────────────────┼─▶│ m.call.candidates │ │ │ │
│ Riot.im │ │ │ mx event │ │ │ │ Riot.im │
│ App │ │ │ └────────────────────┘ │ │ App │
│ │ │ │ │ │ │
│ │ │ │ │ │ │
│ │ │ │ │ │ │
└─────────────────┘ │ │ │ └─────────────────┘
▲ │ │ │
├────┐ │ └───────────────────────────┘
│ 2 │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
┌───────┴────┴────┐ ┌─────────────────┐
│ │ │ │
│ │ │ │
│ WebRtc │ ┌───────────────┐ │ WebRtc │
│ │ │ Stun / Turn │ │ │
│ │ ├───────────────┴ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │
│ │ │ │ │
└─────────────────┘ │ └─────────────────┘
▲ │
│ │
└──────────┬────┬───────────▶ │
┌───────────────┐ │ 1 │ │
│ │ └────┘ │
│ Network Stack │ │
│ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
│ │
└───────────────┘
┌────┐
│ 1 │ The WebRtc layer gathers information on how it can be reach by the other peer directly (Ice candidates)
└────┘
┌──────────────────────────────────────────────────────────────────┐
│candidate:1 1 tcp 1518149375 127.0.0.1 35990 typ host │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│candidate:2 1 UDP 2130706431 192.168.1.102 1816 typ host │
└──────────────────────────────────────────────────────────────────┘
┌────┐
│ 2 │ The WebRTC layer notifies the App layer when it finds new Ice Candidates
└────┘
┌────┐ The app layer, takes the ice candidates, encapsulate them in one or several matrix event adds the callId and
│ 3 │ send it to the other user via the room
└────┘
┌──────────────┐
│ mx event │
├──────────────┴────────────────────────┐
│ type: m.call.candidates │
│ │
│ +CallId │
│ │
│ ┌──────────────────┐ │
│ │ice candidate sdp │ │
│ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ice candidate sdp │ │
│ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ice candidate sdp │ │
│ └──────────────────┘ │
└───────────────────────────────────────┘
╔════════════════════════════════════════════════╗
║ ║
║C] Receiving a call offer ║
║ ║
╚════════════════════════════════════════════════╝
┌───────────────┐
│ Matrix │
├───────────────┴ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│ ┌─────────────────┐
│ │ Callee │
┌─────────────────┐ │ ┌───────────────────────────┐ └─────────────────┘
│ Caller │ │ Signaling Room │ │
└─────────────────┘ │ ├───────────────────────────┤
│ ┌────────────────────┐ │ │ ┌─────────────────┐
│ │ │ m.call.invite │───┼────────────────────────────┬────┬───▶│ │
┌─────────────────┐ │ │ mx event │ │ │ │ 1 │ │ │
│ │ │ │ └────────────────────┘ │ └────┘ │ │
│ │ │ ┌────────────────────┐ │ │ │ Riot.im │
│ │ │ │ │ m.call.candidates │ │ │ App │
│ Riot.im │ │ │ mx event │ │ │ │ │
│ App │ │ │ └────────────────────┘ │ │ │
│ │ │ ┌────────────────────┐◀──┼─────────────────┼───┬────┬───────────┤ │
│ │◀──────────────────┼──────────────────┼──│ m.call.answer │ │ │ 4 │ └──┬──────────────┘
│ │ │ │ mx event │ │ │ └────┘ ├────┐ ▲
└────┬────────────┘ │ │ └────────────────────┘ │ │ 2 │ ├────┐
│ │ │ │ ├────┘ │ 3 │
│ │ └───────────────────────────┘ ┌──▼─────────┴────┤
┌────▼────────────┐ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ │
│ │ │ │
│ │ │ WebRtc │
│ WebRtc │ │ ┌──┴─────────────────┐
│ │ │ │ caller offer │
┌──────────┴─────────┐ │ │ └──┬─────────────────┘
│ callee answer │ │ └─────────────────┘
└────────────────────┴───────┘
┌────┐
│ 1 │ Bob receives a call.invite event in a room, then creates a WebRTC peer connection object
└────┘
┌────┐
│ 2 │ The encapsulated call offer sdp from the mx event is transmitted to WebRTC
└────┘
┌────┐
│ 3 │ WebRTC then creates a call answer for the offer and send it back to app layer
└────┘
┌────┐ The app layer, takes the webrtc answer, encapsulate it in a matrix event adds a callId and send it to the
│ 3 │ other user via the room
└────┘
┌──────────────┐
│ mx event │
├──────────────┴────────┐
│ type: m.call.answer │
│ + callId │
│ │
│ ┌──────────────────┐ │
│ │ webrtc sdp │ │
│ └──────────────────┘ │
└───────────────────────┘
╔════════════════════════════════════════════════╗
║ ║
║D] Callee sends connection establishment info ║
║ ║
╚════════════════════════════════════════════════╝
┌───────────────┐
│ Matrix │
├───────────────┴ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
┌─────────────────┐ │ ┌───────────────────────────┐ ┌─────────────────┐
│ Caller │ │ Signaling Room │ │ │ Callee │
└─────────────────┘ │ ├───────────────────────────┤ └─────────────────┘
│ ┌────────────────────┐ │ │
│ │ │ m.call.invite │ │
┌─────────────────┐ │ │ mx event │ │ │ ┌─────────────────┐
│ │ │ │ └────────────────────┘ │ │ │
│ │ │ ┌────────────────────┐ │ │ │ │
│ │ │ │ │ m.call.candidates │ │ │ │
│ Riot.im │ │ │ mx event │ │ │ │ Riot.im │
│ App │ │ │ └────────────────────┘ │ ┌────┐ │ App │
│ │ │ ┌────────────────────┐ │ │ │ 3 │ │ │
│ │◀──────────────────┼┐ │ │ m.call.answer │ │ ┌───────┴────┴────────│ │
│ │ │ │ │ mx event │ │ ││ │ │
└─────────────────┘ ││ │ └────────────────────┘ │ │ └─────────────────┘
│ │ │ ┌────────────────────┐ │ ││ ▲
│ │└─────────────────┼──│ m.call.candidates │ │ │ ├────┐
▼ │ │ mx event │◀──┼────────────────┘│ │ 2 │
┌─────────────────┐ │ │ └────────────────────┘ │ ┌────┴────┴───────┐
│ │ └───────────────────────────┘ │ │ │
│ │ │ │ │
│ WebRtc │ │ │ WebRtc │
│ │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ ┌───┴────────────────┐
│ │ │ │ caller offer │
┌────────┴───────────┐ │ │ └───┬────────────────┘
│ callee answer ├─────┘ ┌───────────────┐ └─────────────────┘
├────────────────────┤ │ Stun / Turn │ ▲
│ callee ice │ ├───────────────┴ ─ ─ ─ ─ ─ ─ ─ ─ ┌────┐ │
│ candidates │ │ │ 1 │ │
└────────────────────┘ │ ├────┴──┴───────┐
│ │ │
│ │ Network Stack │
│◀─────────────────────┤ │
│ │ │
│ └───────────────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
┌────┐
│ 1 │ The WebRtc layer gathers information on how it can be reach by the other peer directly (Ice candidates)
└────┘
┌──────────────────────────────────────────────────────────────────┐
│candidate:1 1 tcp 1518149375 127.0.0.1 35990 typ host │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│candidate:2 1 UDP 2130706431 192.168.1.102 1816 typ host │
└──────────────────────────────────────────────────────────────────┘
┌────┐
│ 2 │ The WebRTC layer notifies the App layer when it finds new Ice Candidates
└────┘
┌────┐ The app layer, takes the ice candidates, encapsulate them in one or several matrix event adds the callId and
│ 3 │ send it to the other user via the room
└────┘
┌──────────────┐
│ mx event │
├──────────────┴────────────────────────┐
│ type: m.call.candidates │
│ │
│ +CallId │
│ │
│ ┌──────────────────┐ │
│ │ice candidate sdp │ │
│ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ice candidate sdp │ │
│ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ice candidate sdp │ │
│ └──────────────────┘ │
└───────────────────────────────────────┘
╔════════════════════════════════════════════════╗
║ ║
║D] Caller Callee connection ║
║ ║
╚════════════════════════════════════════════════╝
┌───────────────┐
┌─────────────────┐ │ Matrix │ ┌─────────────────┐
│ Caller │ ├───────────────┴ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Callee │
└─────────────────┘ │ └─────────────────┘
┌─────────────────┐ │ ┌─────────────────┐
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │
│ Riot.im │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Riot.im │
│ App │ │ App │
│ │ │ │
│ │ │ │
│ │ │ │
└─────────────────┘ └─────────────────┘
┌───────────────┐
│ Internet │
├───────────────┴ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
┌─────────────────┐ │ ┌─────────────────┐
│ │ │ │ │
│ ├───────────────────────────────────────────────────────────────────────────────────┴─────────────────────┤ │
│ WebRtc │█████████████████████████████████████████████████████████████████████████████████████████████████████████│ WebRtc │
┌─────────────┴──────┐ ├────────────────────────────────────────┬──────────────────────────┬───────────────┬─────────────────────┤ ┌─────┴──────────────┐
│ callee answer │ │ │ │ Video / Audio Stream │ │ │ caller offer │
├────────────────────┤ │ └──────────────────────────┘ │ │ ├────────────────────┤
│ callee ice ├──────────┘ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ └───────────┤ caller ice │
│ candidates │ │ candidates │
└────────────────────┘ └────────────────────┘
┌─────────────────────────────────────────────────────┐
│ │░
│ If connection is impossible (firewall), and a turn │░
│server is available, connection could happen through │░
│ a relay │░
│ │░
└─────────────────────────────────────────────────────┘░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
┌───────────────┐
│ Internet │
└─┬─────────────┴ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
┌─────────────────┐ │ ┌─────────────────┐
│ │ │ ┌─────────────────────────┐ │ │
│ ├───────────────────────────────────────┐│ │ │ │ │
│ WebRtc │███████████████████████████████████████││ │ │ WebRtc │
│ ├───────────────────────────────────────┘│ │ │ │ │
│ │ ┌────────┴─────────────────┐ │ Relay │┌─────────────────────────────────────┤ │
┌───────────────┴────┐ │ │ Video / Audio Stream │ │ ││█████████████████████████████████████│ ┌───────┴────────────┐
│ callee answer ├────────────┘ └────────┬─────────────────┘ │ │└─────────────────────────────────────┴─────────┤ caller offer │
├────────────────────┤ │ │ │ ├────────────────────┤
│ callee ice │ │ │ │ │ caller ice │
│ candidates │ └─────────────────────────┘ │ │ candidates │
└────────────────────┘ │ └────────────────────┘
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─

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. See http://webrtc.github.io/webrtc-org/native-code/android/
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

@ -16,10 +16,15 @@
package im.vector.matrix.android.api.extensions
inline fun <A> tryThis(operation: () -> A): A? {
import timber.log.Timber
inline fun <A> tryThis(message: String? = null, operation: () -> A): A? {
return try {
operation()
} catch (any: Throwable) {
if (message != null) {
Timber.e(any, message)
}
null
}
}

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.CallSignalingService
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 call signaling service associated with the session
*/
fun callSignalingService(): CallSignalingService
/**
* Add a listener to the session.
* @param listener the listener to add.

View File

@ -0,0 +1,36 @@
/*
* 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 im.vector.matrix.android.api.util.Cancelable
interface CallSignalingService {
fun getTurnServer(callback: MatrixCallback<TurnServerResponse>): Cancelable
/**
* Create an outgoing call
*/
fun createOutgoingCall(roomId: String, otherUserId: String, isVideoCall: Boolean): MxCall
fun addCallListener(listener: CallsListener)
fun removeCallListener(listener: CallsListener)
fun getCallWithId(callId: String) : MxCall?
}

View File

@ -0,0 +1,44 @@
/*
* 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.PeerConnection
sealed class CallState {
/** Idle, setting up objects */
object Idle : CallState()
/** Dialing. Outgoing call is signaling the remote peer */
object Dialing : CallState()
/** Local ringing. Incoming call offer received */
object LocalRinging : CallState()
/** Answering. Incoming call is responding to remote peer */
object Answering : CallState()
/**
* Connected. Incoming/Outgoing call, ice layer connecting or connected
* Notice that the PeerState failed is not always final, if you switch network, new ice candidtates
* could be exchanged, and the connection could go back to connected
* */
data class Connected(val iceConnectionState: PeerConnection.PeerConnectionState) : CallState()
/** Terminated. Incoming/Outgoing call, the call is terminated */
object Terminated : CallState()
}

View File

@ -0,0 +1,43 @@
/*
* 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.CallCandidatesContent
import im.vector.matrix.android.api.session.room.model.call.CallHangupContent
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
interface CallsListener {
/**
* Called when there is an incoming call within the room.
*/
fun onCallInviteReceived(mxCall: MxCall, callInviteContent: CallInviteContent)
fun onCallIceCandidateReceived(mxCall: MxCall, iceCandidatesContent: CallCandidatesContent)
/**
* An outgoing call is started.
*/
fun onCallAnswerReceived(callAnswerContent: CallAnswerContent)
/**
* Called when a called has been hung up
*/
fun onCallHangupReceived(callHangupContent: CallHangupContent)
fun onCallManagedByOtherSession(callId: String)
}

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,75 @@
/*
* 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.SessionDescription
interface MxCallDetail {
val callId: String
val isOutgoing: Boolean
val roomId: String
val otherUserId: String
val isVideoCall: Boolean
}
/**
* Define both an incoming call and on outgoing call
*/
interface MxCall : MxCallDetail {
var state: CallState
/**
* Pick Up the incoming call
* It has no effect on outgoing call
*/
fun accept(sdp: SessionDescription)
/**
* Reject an incoming call
* It's an alias to hangUp
*/
fun reject() = hangUp()
/**
* End the call
*/
fun hangUp()
/**
* Start a call
* Send offer SDP to the other participant.
*/
fun offerSdp(sdp: SessionDescription)
/**
* 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>)
fun addListener(listener: StateListener)
fun removeListener(listener: StateListener)
interface StateListener {
fun onStateUpdate(call: MxCall)
}
}

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 com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
// TODO Should not be exposed
/**
* Ref: https://matrix.org/docs/spec/client_server/r0.6.1#get-matrix-client-r0-voip-turnserver
*/
@JsonClass(generateAdapter = true)
data class TurnServerResponse(
/**
* Required. The username to use.
*/
@Json(name = "username") val username: String?,
/**
* Required. The password to use.
*/
@Json(name = "password") val password: String?,
/**
* Required. A list of TURN URIs
*/
@Json(name = "uris") val uris: List<String>?,
/**
* Required. The time-to-live in seconds
*/
@Json(name = "ttl") val ttl: Int?
)

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

@ -17,6 +17,7 @@
package im.vector.matrix.android.api.session.room
import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.session.room.call.RoomCallService
import im.vector.matrix.android.api.session.room.crypto.RoomCryptoService
import im.vector.matrix.android.api.session.room.members.MembershipService
import im.vector.matrix.android.api.session.room.model.RoomSummary
@ -47,6 +48,7 @@ interface Room :
StateService,
UploadsService,
ReportingService,
RoomCallService,
RelationService,
RoomCryptoService,
RoomPushRuleService {

View File

@ -0,0 +1,27 @@
/*
* 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.room.call
/**
* This interface defines methods to handle calls in a room. It's implemented at the room level.
*/
interface RoomCallService {
/**
* Return true if calls (audio or video) can be performed on this Room
*/
fun canStartCall(): Boolean
}

View File

@ -62,6 +62,9 @@ data class RoomSummary constructor(
val isFavorite: Boolean
get() = tags.any { it.name == RoomTag.ROOM_TAG_FAVOURITE }
val canStartCall: Boolean
get() = isDirect && joinedMembersCount == 2
companion object {
const val NOT_IN_BREADCRUMBS = -1
}

View File

@ -19,16 +19,34 @@ package im.vector.matrix.android.api.session.room.model.call
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* This event is sent by the callee when they wish to answer the call.
*/
@JsonClass(generateAdapter = true)
data class CallAnswerContent(
/**
* Required. The ID of the call this event relates to.
*/
@Json(name = "call_id") val callId: String,
@Json(name = "version") val version: Int,
@Json(name = "answer") val answer: Answer
/**
* Required. The session description object
*/
@Json(name = "answer") val answer: Answer,
/**
* Required. The version of the VoIP specification this messages adheres to. This specification is version 0.
*/
@Json(name = "version") val version: Int = 0
) {
@JsonClass(generateAdapter = true)
data class Answer(
@Json(name = "type") val type: String,
/**
* Required. The type of session description. Must be 'answer'.
*/
@Json(name = "type") val type: SdpType = SdpType.ANSWER,
/**
* Required. The SDP text of the session description.
*/
@Json(name = "sdp") val sdp: String
)
}

View File

@ -19,17 +19,39 @@ package im.vector.matrix.android.api.session.room.model.call
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* This event is sent by callers after sending an invite and by the callee after answering.
* Its purpose is to give the other party additional ICE candidates to try using to communicate.
*/
@JsonClass(generateAdapter = true)
data class CallCandidatesContent(
/**
* Required. The ID of the call this event relates to.
*/
@Json(name = "call_id") val callId: String,
@Json(name = "version") val version: Int,
@Json(name = "candidates") val candidates: List<Candidate> = emptyList()
/**
* Required. Array of objects describing the candidates.
*/
@Json(name = "candidates") val candidates: List<Candidate> = emptyList(),
/**
* Required. The version of the VoIP specification this messages adheres to. This specification is version 0.
*/
@Json(name = "version") val version: Int = 0
) {
@JsonClass(generateAdapter = true)
data class Candidate(
/**
* Required. The SDP media type this candidate is intended for.
*/
@Json(name = "sdpMid") val sdpMid: String,
@Json(name = "sdpMLineIndex") val sdpMLineIndex: String,
/**
* Required. The index of the SDP 'm' line this candidate is intended for.
*/
@Json(name = "sdpMLineIndex") val sdpMLineIndex: Int,
/**
* Required. The SDP 'a' line of the candidate.
*/
@Json(name = "candidate") val candidate: String
)
}

View File

@ -19,8 +19,32 @@ package im.vector.matrix.android.api.session.room.model.call
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Sent by either party to signal their termination of the call. This can be sent either once
* the call has been established or before to abort the call.
*/
@JsonClass(generateAdapter = true)
data class CallHangupContent(
/**
* Required. The ID of the call this event relates to.
*/
@Json(name = "call_id") val callId: String,
@Json(name = "version") val version: Int
)
/**
* Required. The version of the VoIP specification this message adheres to. This specification is version 0.
*/
@Json(name = "version") val version: Int = 0,
/**
* Optional error reason for the hangup. This should not be provided when the user naturally ends or rejects the call.
* When there was an error in the call negotiation, this should be `ice_failed` for when ICE negotiation fails
* or `invite_timeout` for when the other party did not answer in time. One of: ["ice_failed", "invite_timeout"]
*/
@Json(name = "reason") val reason: Reason? = null
) {
enum class Reason {
@Json(name = "ice_failed")
ICE_FAILED,
@Json(name = "invite_timeout")
INVITE_TIMEOUT
}
}

View File

@ -19,23 +19,45 @@ package im.vector.matrix.android.api.session.room.model.call
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* This event is sent by the caller when they wish to establish a call.
*/
@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
/**
* Required. A unique identifier for the call.
*/
@Json(name = "call_id") val callId: String?,
/**
* Required. The session description object
*/
@Json(name = "offer") val offer: Offer?,
/**
* Required. The version of the VoIP specification this message adheres to. This specification is version 0.
*/
@Json(name = "version") val version: Int? = 0,
/**
* Required. 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 = "lifetime") val lifetime: Int?
) {
@JsonClass(generateAdapter = true)
data class Offer(
@Json(name = "type") val type: String,
@Json(name = "sdp") val sdp: String
/**
* Required. The type of session description. Must be 'offer'.
*/
@Json(name = "type") val type: SdpType? = SdpType.OFFER,
/**
* Required. 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

@ -0,0 +1,27 @@
/*
* 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.room.model.call
import com.squareup.moshi.Json
enum class SdpType {
@Json(name = "offer")
OFFER,
@Json(name = "answer")
ANSWER
}

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.CallSignalingService
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 callSignalingService: Lazy<CallSignalingService>)
: Session,
RoomService by roomService.get(),
RoomDirectoryService by roomDirectoryService.get(),
@ -245,6 +247,8 @@ internal class DefaultSession @Inject constructor(
override fun integrationManagerService() = integrationManagerService
override fun callSignalingService(): CallSignalingService = callSignalingService.get()
override fun addListener(listener: Session.Listener) {
sessionListeners.addListener(listener)
}

View File

@ -30,6 +30,7 @@ import im.vector.matrix.android.internal.di.SessionAssistedInjectModule
import im.vector.matrix.android.internal.network.NetworkConnectivityChecker
import im.vector.matrix.android.internal.session.account.AccountModule
import im.vector.matrix.android.internal.session.cache.CacheModule
import im.vector.matrix.android.internal.session.call.CallModule
import im.vector.matrix.android.internal.session.content.ContentModule
import im.vector.matrix.android.internal.session.content.UploadContentWorker
import im.vector.matrix.android.internal.session.filter.FilterModule
@ -83,7 +84,8 @@ import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
AccountDataModule::class,
ProfileModule::class,
SessionAssistedInjectModule::class,
AccountModule::class
AccountModule::class,
CallModule::class
]
)
@SessionScope

View File

@ -59,6 +59,7 @@ 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.session.call.CallEventObserver
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
@ -243,6 +244,10 @@ internal abstract class SessionModule {
@IntoSet
abstract fun bindEventRelationsAggregationUpdater(updater: EventRelationsAggregationUpdater): LiveEntityObserver
@Binds
@IntoSet
abstract fun bindCallEventObserver(observer: CallEventObserver): LiveEntityObserver
@Binds
@IntoSet
abstract fun bindRoomTombstoneEventLiveObserver(observer: RoomTombstoneEventLiveObserver): LiveEntityObserver

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.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 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,91 @@
/*
* 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: DefaultCallSignalingService) : 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) {
val now = System.currentTimeMillis()
// TODO might check if an invite is not closed (hangup/answsered) in the same event batch?
events.forEach { event ->
event.roomId ?: return@forEach Unit.also {
Timber.w("Event with no room id ${event.eventId}")
}
val age = now - (event.ageLocalTs ?: now)
if (age > 40_000) {
// To old to ring?
return@forEach
}
event.ageLocalTs
decryptIfNeeded(event)
if (EventType.isCallEvent(event.getClearType())) {
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,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.internal.session.call
import dagger.Binds
import dagger.Module
import dagger.Provides
import im.vector.matrix.android.api.session.call.CallSignalingService
import im.vector.matrix.android.internal.session.SessionScope
import retrofit2.Retrofit
@Module
internal abstract class CallModule {
@Module
companion object {
@Provides
@JvmStatic
@SessionScope
fun providesVoipApi(retrofit: Retrofit): VoipApi {
return retrofit.create(VoipApi::class.java)
}
}
@Binds
abstract fun bindCallSignalingService(service: DefaultCallSignalingService): CallSignalingService
@Binds
abstract fun bindGetTurnServerTask(task: DefaultGetTurnServerTask): GetTurnServerTask
@Binds
abstract fun bindCallEventsObserverTask(task: DefaultCallEventsObserverTask): CallEventsObserverTask
}

View File

@ -0,0 +1,236 @@
/*
* 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.CallSignalingService
import im.vector.matrix.android.api.session.call.CallState
import im.vector.matrix.android.api.session.call.CallsListener
import im.vector.matrix.android.api.session.call.MxCall
import im.vector.matrix.android.api.session.call.TurnServerResponse
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.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.CallHangupContent
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.api.util.NoOpCancellable
import im.vector.matrix.android.internal.di.UserId
import im.vector.matrix.android.internal.session.SessionScope
import im.vector.matrix.android.internal.session.call.model.MxCallImpl
import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory
import im.vector.matrix.android.internal.session.room.send.RoomEventSender
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@SessionScope
internal class DefaultCallSignalingService @Inject constructor(
@UserId
private val userId: String,
private val localEchoEventFactory: LocalEchoEventFactory,
private val roomEventSender: RoomEventSender,
private val taskExecutor: TaskExecutor,
private val turnServerTask: GetTurnServerTask
) : CallSignalingService {
private val callListeners = mutableSetOf<CallsListener>()
private val activeCalls = mutableListOf<MxCall>()
private var cachedTurnServerResponse: TurnServerResponse? = null
override fun getTurnServer(callback: MatrixCallback<TurnServerResponse>): Cancelable {
if (cachedTurnServerResponse != null) {
cachedTurnServerResponse?.let { callback.onSuccess(it) }
return NoOpCancellable
}
return turnServerTask
.configureWith(GetTurnServerTask.Params) {
this.callback = object : MatrixCallback<TurnServerResponse> {
override fun onSuccess(data: TurnServerResponse) {
cachedTurnServerResponse = data
callback.onSuccess(data)
}
override fun onFailure(failure: Throwable) {
callback.onFailure(failure)
}
}
}
.executeBy(taskExecutor)
}
override fun createOutgoingCall(roomId: String, otherUserId: String, isVideoCall: Boolean): MxCall {
return MxCallImpl(
callId = UUID.randomUUID().toString(),
isOutgoing = true,
roomId = roomId,
userId = userId,
otherUserId = otherUserId,
isVideoCall = isVideoCall,
localEchoEventFactory = localEchoEventFactory,
roomEventSender = roomEventSender
).also {
activeCalls.add(it)
}
}
override fun addCallListener(listener: CallsListener) {
callListeners.add(listener)
}
override fun removeCallListener(listener: CallsListener) {
callListeners.remove(listener)
}
override fun getCallWithId(callId: String): MxCall? {
Timber.v("## VOIP getCallWithId $callId all calls ${activeCalls.map { it.callId }}")
return activeCalls.find { it.callId == callId }
}
internal fun onCallEvent(event: Event) {
when (event.getClearType()) {
EventType.CALL_ANSWER -> {
event.getClearContent().toModel<CallAnswerContent>()?.let {
if (event.senderId == userId) {
// ok it's an answer from me.. is it remote echo or other session
val knownCall = getCallWithId(it.callId)
if (knownCall == null) {
Timber.d("## VOIP onCallEvent ${event.getClearType()} id ${it.callId} send by me")
} else if (!knownCall.isOutgoing) {
// incoming call
// if it was anwsered by this session, the call state would be in Answering(or connected) state
if (knownCall.state == CallState.LocalRinging) {
// discard current call, it's answered by another of my session
onCallManageByOtherSession(it.callId)
}
}
return
}
onCallAnswer(it)
}
}
EventType.CALL_INVITE -> {
if (event.senderId == userId) {
// Always ignore local echos of invite
return
}
event.getClearContent().toModel<CallInviteContent>()?.let { content ->
val incomingCall = MxCallImpl(
callId = content.callId ?: return@let,
isOutgoing = false,
roomId = event.roomId ?: return@let,
userId = userId,
otherUserId = event.senderId ?: return@let,
isVideoCall = content.isVideo(),
localEchoEventFactory = localEchoEventFactory,
roomEventSender = roomEventSender
)
activeCalls.add(incomingCall)
onCallInvite(incomingCall, content)
}
}
EventType.CALL_HANGUP -> {
event.getClearContent().toModel<CallHangupContent>()?.let { content ->
if (event.senderId == userId) {
// ok it's an answer from me.. is it remote echo or other session
val knownCall = getCallWithId(content.callId)
if (knownCall == null) {
Timber.d("## VOIP onCallEvent ${event.getClearType()} id ${content.callId} send by me")
} else if (!knownCall.isOutgoing) {
// incoming call
if (knownCall.state == CallState.LocalRinging) {
// discard current call, it's answered by another of my session
onCallManageByOtherSession(content.callId)
}
}
return
}
onCallHangup(content)
activeCalls.removeAll { it.callId == content.callId }
}
}
EventType.CALL_CANDIDATES -> {
if (event.senderId == userId) {
// Always ignore local echos of invite
return
}
event.getClearContent().toModel<CallCandidatesContent>()?.let { content ->
activeCalls.firstOrNull { it.callId == content.callId }?.let {
onCallIceCandidate(it, content)
}
}
}
}
}
private fun onCallHangup(hangup: CallHangupContent) {
callListeners.toList().forEach {
tryThis {
it.onCallHangupReceived(hangup)
}
}
}
private fun onCallAnswer(answer: CallAnswerContent) {
callListeners.toList().forEach {
tryThis {
it.onCallAnswerReceived(answer)
}
}
}
private fun onCallManageByOtherSession(callId: String) {
callListeners.toList().forEach {
tryThis {
it.onCallManagedByOtherSession(callId)
}
}
}
private fun onCallInvite(incomingCall: MxCall, invite: CallInviteContent) {
// Ignore the invitation from current user
if (incomingCall.otherUserId == userId) return
callListeners.toList().forEach {
tryThis {
it.onCallInviteReceived(incomingCall, invite)
}
}
}
private fun onCallIceCandidate(incomingCall: MxCall, candidates: CallCandidatesContent) {
callListeners.toList().forEach {
tryThis {
it.onCallIceCandidateReceived(incomingCall, candidates)
}
}
}
companion object {
const val CALL_TIMEOUT_MS = 120_000
}
}

View File

@ -0,0 +1,37 @@
/*
* 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.session.call.TurnServerResponse
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.task.Task
import org.greenrobot.eventbus.EventBus
import javax.inject.Inject
internal abstract class GetTurnServerTask : Task<GetTurnServerTask.Params, TurnServerResponse> {
object Params
}
internal class DefaultGetTurnServerTask @Inject constructor(private val voipAPI: VoipApi,
private val eventBus: EventBus) : GetTurnServerTask() {
override suspend fun execute(params: Params): TurnServerResponse {
return executeRequest(eventBus) {
apiCall = voipAPI.getTurnServer()
}
}
}

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.internal.session.call
import im.vector.matrix.android.api.session.call.TurnServerResponse
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<TurnServerResponse>
}

View File

@ -0,0 +1,150 @@
/*
* 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.model
import im.vector.matrix.android.api.session.call.CallState
import im.vector.matrix.android.api.session.call.MxCall
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.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.CallHangupContent
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.matrix.android.internal.session.call.DefaultCallSignalingService
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 timber.log.Timber
internal class MxCallImpl(
override val callId: String,
override val isOutgoing: Boolean,
override val roomId: String,
private val userId: String,
override val otherUserId: String,
override val isVideoCall: Boolean,
private val localEchoEventFactory: LocalEchoEventFactory,
private val roomEventSender: RoomEventSender
) : MxCall {
override var state: CallState = CallState.Idle
set(value) {
field = value
dispatchStateChange()
}
private val listeners = mutableListOf<MxCall.StateListener>()
override fun addListener(listener: MxCall.StateListener) {
listeners.add(listener)
}
override fun removeListener(listener: MxCall.StateListener) {
listeners.remove(listener)
}
private fun dispatchStateChange() {
listeners.forEach {
try {
it.onStateUpdate(this)
} catch (failure: Throwable) {
Timber.d("dispatchStateChange failed for call $callId : ${failure.localizedMessage}")
}
}
}
init {
if (isOutgoing) {
state = CallState.Idle
} else {
// because it's created on reception of an offer
state = CallState.LocalRinging
}
}
override fun offerSdp(sdp: SessionDescription) {
if (!isOutgoing) return
Timber.v("## VOIP offerSdp $callId")
state = CallState.Dialing
CallInviteContent(
callId = callId,
lifetime = DefaultCallSignalingService.CALL_TIMEOUT_MS,
offer = CallInviteContent.Offer(sdp = sdp.description)
)
.let { createEventAndLocalEcho(type = EventType.CALL_INVITE, roomId = roomId, content = it.toContent()) }
.also { roomEventSender.sendEvent(it) }
}
override fun sendLocalIceCandidates(candidates: List<IceCandidate>) {
CallCandidatesContent(
callId = callId,
candidates = candidates.map {
CallCandidatesContent.Candidate(
sdpMid = it.sdpMid,
sdpMLineIndex = it.sdpMLineIndex,
candidate = it.sdp
)
}
)
.let { createEventAndLocalEcho(type = EventType.CALL_CANDIDATES, roomId = roomId, content = it.toContent()) }
.also { roomEventSender.sendEvent(it) }
}
override fun sendLocalIceCandidateRemovals(candidates: List<IceCandidate>) {
// For now we don't support this flow
}
override fun hangUp() {
Timber.v("## VOIP hangup $callId")
CallHangupContent(
callId = callId
)
.let { createEventAndLocalEcho(type = EventType.CALL_HANGUP, roomId = roomId, content = it.toContent()) }
.also { roomEventSender.sendEvent(it) }
state = CallState.Terminated
}
override fun accept(sdp: SessionDescription) {
Timber.v("## VOIP accept $callId")
if (isOutgoing) return
state = CallState.Answering
CallAnswerContent(
callId = callId,
answer = CallAnswerContent.Answer(sdp = sdp.description)
)
.let { createEventAndLocalEcho(type = EventType.CALL_ANSWER, roomId = roomId, content = it.toContent()) }
.also { roomEventSender.sendEvent(it) }
}
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) }
}
}

View File

@ -23,6 +23,7 @@ import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.api.session.room.call.RoomCallService
import im.vector.matrix.android.api.session.room.members.MembershipService
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.relation.RelationService
@ -58,6 +59,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String,
private val stateService: StateService,
private val uploadsService: UploadsService,
private val reportingService: ReportingService,
private val roomCallService: RoomCallService,
private val readService: ReadService,
private val typingService: TypingService,
private val tagsService: TagsService,
@ -74,6 +76,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String,
StateService by stateService,
UploadsService by uploadsService,
ReportingService by reportingService,
RoomCallService by roomCallService,
ReadService by readService,
TypingService by typingService,
TagsService by tagsService,

View File

@ -21,6 +21,7 @@ import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.internal.database.mapper.RoomSummaryMapper
import im.vector.matrix.android.internal.session.SessionScope
import im.vector.matrix.android.internal.session.room.call.DefaultRoomCallService
import im.vector.matrix.android.internal.session.room.draft.DefaultDraftService
import im.vector.matrix.android.internal.session.room.membership.DefaultMembershipService
import im.vector.matrix.android.internal.session.room.notification.DefaultRoomPushRuleService
@ -51,6 +52,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona
private val stateServiceFactory: DefaultStateService.Factory,
private val uploadsServiceFactory: DefaultUploadsService.Factory,
private val reportingServiceFactory: DefaultReportingService.Factory,
private val roomCallServiceFactory: DefaultRoomCallService.Factory,
private val readServiceFactory: DefaultReadService.Factory,
private val typingServiceFactory: DefaultTypingService.Factory,
private val tagsServiceFactory: DefaultTagsService.Factory,
@ -72,6 +74,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona
stateService = stateServiceFactory.create(roomId),
uploadsService = uploadsServiceFactory.create(roomId),
reportingService = reportingServiceFactory.create(roomId),
roomCallService = roomCallServiceFactory.create(roomId),
readService = readServiceFactory.create(roomId),
typingService = typingServiceFactory.create(roomId),
tagsService = tagsServiceFactory.create(roomId),

View File

@ -0,0 +1,38 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.room.call
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.extensions.orFalse
import im.vector.matrix.android.api.session.room.call.RoomCallService
import im.vector.matrix.android.internal.session.room.RoomGetter
internal class DefaultRoomCallService @AssistedInject constructor(
@Assisted private val roomId: String,
private val roomGetter: RoomGetter
) : RoomCallService {
@AssistedInject.Factory
interface Factory {
fun create(roomId: String): RoomCallService
}
override fun canStartCall(): Boolean {
return roomGetter.getRoom(roomId)?.roomSummary()?.canStartCall.orFalse()
}
}

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

@ -43,6 +43,8 @@
<string name="notice_placed_video_call_by_you">You placed a video call.</string>
<string name="notice_placed_voice_call">%s placed a voice call.</string>
<string name="notice_placed_voice_call_by_you">You placed a voice call.</string>
<string name="notice_call_candidates">%s sent data to setup the call.</string>
<string name="notice_call_candidates_by_you">You sent data to setup the call.</string>
<string name="notice_answered_call">%s answered the call.</string>
<string name="notice_answered_call_by_you">You answered the call.</string>
<string name="notice_ended_call">%s ended the call.</string>
@ -362,4 +364,8 @@
<string name="key_verification_request_fallback_message">%s is requesting to verify your key, but your client does not support in-chat key verification. You will need to use legacy key verification to verify keys.</string>
<string name="call_notification_answer">Accept</string>
<string name="call_notification_reject">Decline</string>
<string name="call_notification_hangup">Hang Up</string>
</resources>

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

@ -3,13 +3,30 @@
xmlns:tools="http://schemas.android.com/tools"
package="im.vector.riotx">
<!-- Needed for VOIP call to detect and switch to headset-->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<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" />
<!-- Needed for voice call to toggle speaker on or off -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- READ_PHONE_STATE is needed only if your calling app reads numbers from the `PHONE_STATE`
intent action. -->
<!-- Needed to show incoming calls activity in lock screen-->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Needed for incoming calls -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- 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 +189,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" />
@ -180,20 +198,47 @@
<service
android:name=".core.services.CallService"
android:exported="false" />
android:exported="false" >
<!-- in order to get headset button events -->
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</service>
<service
android:name=".core.services.VectorSyncService"
android:exported="false" />
<service
android:name=".features.call.telecom.VectorConnectionService"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
<!-- Receivers -->
<receiver
android:name=".features.call.service.CallHeadsUpActionReceiver"
android:exported="false" />
<!-- Exported false, should only be accessible from this app!! -->
<receiver
android:name=".features.notifications.NotificationBroadcastReceiver"
android:enabled="true"
android:exported="false" />
<!--
A media button receiver receives and helps translate hardware media playback buttons,
such as those found on wired and wireless headsets, into the appropriate callbacks in your app.
-->
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- Providers -->
<provider

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.callSignalingService().addCallListener(webRtcPeerConnectionManager)
}
ProcessLifecycleOwner.get().lifecycle.addObserver(object : LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)

View File

@ -24,6 +24,8 @@ 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.CallControlsBottomSheet
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 +132,7 @@ interface ScreenComponent {
fun inject(activity: InviteUsersToRoomActivity)
fun inject(activity: ReviewTermsActivity)
fun inject(activity: WidgetActivity)
fun inject(activity: VectorCallActivity)
/* ==========================================================================================
* BottomSheets
@ -146,6 +149,7 @@ interface ScreenComponent {
fun inject(bottomSheet: BootstrapBottomSheet)
fun inject(bottomSheet: RoomWidgetPermissionBottomSheet)
fun inject(bottomSheet: RoomWidgetsBottomSheet)
fun inject(bottomSheet: CallControlsBottomSheet)
/* ==========================================================================================
* Others

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

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

View File

@ -17,9 +17,14 @@
package im.vector.riotx.core.extensions
import androidx.fragment.app.FragmentTransaction
import im.vector.matrix.android.api.extensions.tryThis
inline fun androidx.fragment.app.FragmentManager.commitTransactionNow(func: FragmentTransaction.() -> FragmentTransaction) {
beginTransaction().func().commitNow()
// Could throw and make the app crash
// e.g sharedActionViewModel.observe()
tryThis("Failed to commitTransactionNow") {
beginTransaction().func().commitNow()
}
}
inline fun androidx.fragment.app.FragmentManager.commitTransaction(func: FragmentTransaction.() -> FragmentTransaction) {

View File

@ -38,10 +38,6 @@ fun Session.configureAndStart(context: Context,
startSyncing(context)
refreshPushers()
pushRuleTriggerListener.startWithSession(this)
// TODO P1 From HomeActivity
// @Inject lateinit var incomingVerificationRequestHandler: IncomingVerificationRequestHandler
// @Inject lateinit var keyRequestHandler: KeyRequestHandler
}
fun Session.startSyncing(context: Context) {

View File

@ -165,6 +165,7 @@ abstract class VectorBaseActivity : AppCompatActivity(), HasScreenInjector {
}
override fun onCreate(savedInstanceState: Bundle?) {
Timber.i("onCreate Activity ${this.javaClass.simpleName}")
val vectorComponent = getVectorComponent()
screenComponent = DaggerScreenComponent.factory().create(vectorComponent, this)
val timeForInjection = measureTimeMillis {
@ -252,6 +253,7 @@ abstract class VectorBaseActivity : AppCompatActivity(), HasScreenInjector {
override fun onDestroy() {
super.onDestroy()
Timber.i("onDestroy Activity ${this.javaClass.simpleName}")
unBinder?.unbind()
unBinder = null
@ -279,6 +281,7 @@ abstract class VectorBaseActivity : AppCompatActivity(), HasScreenInjector {
override fun onPause() {
super.onPause()
Timber.i("onPause Activity ${this.javaClass.simpleName}")
rageShake.stop()

View File

@ -34,6 +34,7 @@ import com.airbnb.mvrx.MvRxViewId
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.jakewharton.rxbinding3.view.clicks
import im.vector.riotx.core.di.DaggerScreenComponent
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.utils.DimensionConverter
@ -41,6 +42,7 @@ import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
import timber.log.Timber
import java.util.concurrent.TimeUnit
/**
* Add MvRx capabilities to bottomsheetdialog (like BaseMvRxFragment)
@ -169,6 +171,18 @@ abstract class VectorBaseBottomSheetDialogFragment : BottomSheetDialogFragment()
return this
}
/* ==========================================================================================
* Views
* ========================================================================================== */
protected fun View.debouncedClicks(onClicked: () -> Unit) {
clicks()
.throttleFirst(300, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { onClicked() }
.disposeOnDestroyView()
}
/* ==========================================================================================
* ViewEvents
* ========================================================================================== */

View File

@ -0,0 +1,92 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.core.services
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothClass
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import java.lang.ref.WeakReference
class BluetoothHeadsetReceiver : BroadcastReceiver() {
interface EventListener {
fun onBTHeadsetEvent(event: BTHeadsetPlugEvent)
}
var delegate: WeakReference<EventListener>? = null
data class BTHeadsetPlugEvent(
val plugged: Boolean,
val headsetName: String?,
/**
* BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE
* BluetoothClass.Device.AUDIO_VIDEO_CAR_AUDIO
* AUDIO_VIDEO_WEARABLE_HEADSET
*/
val deviceClass: Int
)
override fun onReceive(context: Context?, intent: Intent?) {
// This intent will have 3 extras:
// EXTRA_CONNECTION_STATE - The current connection state
// EXTRA_PREVIOUS_CONNECTION_STATE}- The previous connection state.
// BluetoothDevice#EXTRA_DEVICE - The remote device.
// EXTRA_CONNECTION_STATE or EXTRA_PREVIOUS_CONNECTION_STATE can be any of
// STATE_DISCONNECTED}, STATE_CONNECTING, STATE_CONNECTED, STATE_DISCONNECTING
val headsetConnected = when (intent?.getIntExtra(BluetoothAdapter.EXTRA_CONNECTION_STATE, -1)) {
BluetoothAdapter.STATE_CONNECTED -> true
BluetoothAdapter.STATE_DISCONNECTED -> false
else -> return // ignore intermediate states
}
val device = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
val deviceName = device?.name
when (device?.bluetoothClass?.deviceClass) {
BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE,
BluetoothClass.Device.AUDIO_VIDEO_CAR_AUDIO,
BluetoothClass.Device.AUDIO_VIDEO_WEARABLE_HEADSET -> {
// filter only device that we care about for
delegate?.get()?.onBTHeadsetEvent(
BTHeadsetPlugEvent(
plugged = headsetConnected,
headsetName = deviceName,
deviceClass = device.bluetoothClass.deviceClass
)
)
}
else -> return
}
}
companion object {
fun createAndRegister(context: Context, listener: EventListener): BluetoothHeadsetReceiver {
val receiver = BluetoothHeadsetReceiver()
receiver.delegate = WeakReference(listener)
context.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED))
return receiver
}
fun unRegister(context: Context, receiver: BluetoothHeadsetReceiver) {
context.unregisterReceiver(receiver)
}
}
}

View File

@ -0,0 +1,93 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.core.services
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.os.Build
import im.vector.riotx.R
import timber.log.Timber
class CallRingPlayer(
context: Context
) {
private val applicationContext = context.applicationContext
private var player: MediaPlayer? = null
fun start() {
val audioManager: AudioManager = applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
player?.release()
player = createPlayer()
// Check if sound is enabled
val ringerMode = audioManager.ringerMode
if (player != null && ringerMode == AudioManager.RINGER_MODE_NORMAL) {
try {
if (player?.isPlaying == false) {
player?.start()
Timber.v("## VOIP Starting ringing")
} else {
Timber.v("## VOIP already playing")
}
} catch (failure: Throwable) {
Timber.e(failure, "## VOIP Failed to start ringing")
player = null
}
} else {
Timber.v("## VOIP Can't play $player ode $ringerMode")
}
}
fun stop() {
player?.release()
player = null
}
private fun createPlayer(): MediaPlayer? {
try {
val mediaPlayer = MediaPlayer.create(applicationContext, R.raw.ring)
mediaPlayer.setOnErrorListener(MediaPlayerErrorListener())
mediaPlayer.isLooping = true
if (Build.VERSION.SDK_INT <= 21) {
@Suppress("DEPRECATION")
mediaPlayer.setAudioStreamType(AudioManager.STREAM_RING)
} else {
mediaPlayer.setAudioAttributes(AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING)
.build())
}
return mediaPlayer
} catch (failure: Throwable) {
Timber.e(failure, "Failed to create Call ring player")
return null
}
}
inner class MediaPlayerErrorListener : MediaPlayer.OnErrorListener {
override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean {
Timber.w("onError($mp, $what, $extra")
player = null
return false
}
}
}

View File

@ -1,5 +1,6 @@
/*
* Copyright 2019 New Vector Ltd
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -14,53 +15,119 @@
* limitations under the License.
*/
@file:Suppress("UNUSED_PARAMETER")
package im.vector.riotx.core.services
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent
import androidx.core.content.ContextCompat
import androidx.media.session.MediaButtonReceiver
import im.vector.riotx.core.extensions.vectorComponent
import im.vector.riotx.features.call.WebRtcPeerConnectionManager
import im.vector.riotx.features.call.telecom.CallConnection
import im.vector.riotx.features.notifications.NotificationUtils
import timber.log.Timber
/**
* Foreground service to manage calls
*/
class CallService : VectorService() {
class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListener, BluetoothHeadsetReceiver.EventListener {
/**
* call in progress (foreground notification)
*/
private var mCallIdInProgress: String? = null
private val connections = mutableMapOf<String, CallConnection>()
private lateinit var notificationUtils: NotificationUtils
private lateinit var webRtcPeerConnectionManager: WebRtcPeerConnectionManager
/**
* incoming (foreground notification)
*/
private var mIncomingCallId: String? = null
private var callRingPlayer: CallRingPlayer? = null
private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null
private var bluetoothHeadsetStateReceiver: BluetoothHeadsetReceiver? = null
// A media button receiver receives and helps translate hardware media playback buttons,
// such as those found on wired and wireless headsets, into the appropriate callbacks in your app
private var mediaSession: MediaSessionCompat? = null
private val mediaSessionButtonCallback = object : MediaSessionCompat.Callback() {
override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean {
val keyEvent = mediaButtonEvent?.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT) ?: return false
if (keyEvent.keyCode == KeyEvent.KEYCODE_HEADSETHOOK) {
webRtcPeerConnectionManager.headSetButtonTapped()
return true
}
return false
}
}
override fun onCreate() {
super.onCreate()
notificationUtils = vectorComponent().notificationUtils()
webRtcPeerConnectionManager = vectorComponent().webRtcPeerConnectionManager()
callRingPlayer = CallRingPlayer(applicationContext)
wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(this, this)
bluetoothHeadsetStateReceiver = BluetoothHeadsetReceiver.createAndRegister(this, this)
}
override fun onDestroy() {
super.onDestroy()
callRingPlayer?.stop()
wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(this, it) }
wiredHeadsetStateReceiver = null
bluetoothHeadsetStateReceiver?.let { BluetoothHeadsetReceiver.unRegister(this, it) }
bluetoothHeadsetStateReceiver = null
mediaSession?.release()
mediaSession = null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Timber.v("## VOIP onStartCommand $intent")
if (mediaSession == null) {
mediaSession = MediaSessionCompat(applicationContext, CallService::class.java.name).apply {
setCallback(mediaSessionButtonCallback)
}
}
if (intent == null) {
// Service started again by the system.
// TODO What do we do here?
return START_STICKY
}
mediaSession?.let {
// This ensures that the correct callbacks to MediaSessionCompat.Callback
// will be triggered based on the incoming KeyEvent.
MediaButtonReceiver.handleIntent(it, intent)
}
when (intent.action) {
ACTION_INCOMING_CALL -> displayIncomingCallNotification(intent)
ACTION_PENDING_CALL -> displayCallInProgressNotification(intent)
ACTION_NO_ACTIVE_CALL -> hideCallNotifications()
else ->
ACTION_INCOMING_RINGING_CALL -> {
mediaSession?.isActive = true
callRingPlayer?.start()
displayIncomingCallNotification(intent)
}
ACTION_OUTGOING_RINGING_CALL -> {
mediaSession?.isActive = true
callRingPlayer?.start()
displayOutgoingRingingCallNotification(intent)
}
ACTION_ONGOING_CALL -> {
callRingPlayer?.stop()
displayCallInProgressNotification(intent)
}
ACTION_NO_ACTIVE_CALL -> hideCallNotifications()
ACTION_CALL_CONNECTING -> {
// lower notification priority
displayCallInProgressNotification(intent)
// stop ringing
callRingPlayer?.stop()
}
ACTION_ONGOING_CALL_BG -> {
// there is an ongoing call but call activity is in background
displayCallOnGoingInBackground(intent)
}
else -> {
// Should not happen
callRingPlayer?.stop()
myStopSelf()
}
}
// We want the system to restore the service if killed
@ -80,54 +147,65 @@ class CallService : VectorService() {
* @param callId the callId
*/
private fun displayIncomingCallNotification(intent: Intent) {
Timber.v("displayIncomingCallNotification")
// TODO
/*
Timber.v("## VOIP displayIncomingCallNotification $intent")
// the incoming call in progress is already displayed
if (!TextUtils.isEmpty(mIncomingCallId)) {
Timber.v("displayIncomingCallNotification : the incoming call in progress is already displayed")
} else if (!TextUtils.isEmpty(mCallIdInProgress)) {
Timber.v("displayIncomingCallNotification : a 'call in progress' notification is displayed")
} else if (null == CallsManager.getSharedInstance().activeCall) {
val callId = intent.getStringExtra(EXTRA_CALL_ID)
// if (!TextUtils.isEmpty(mIncomingCallId)) {
// Timber.v("displayIncomingCallNotification : the incoming call in progress is already displayed")
// } else if (!TextUtils.isEmpty(mCallIdInProgress)) {
// Timber.v("displayIncomingCallNotification : a 'call in progress' notification is displayed")
// } else
// // if (null == webRtcPeerConnectionManager.currentCall)
// {
val callId = intent.getStringExtra(EXTRA_CALL_ID)
Timber.v("displayIncomingCallNotification : display the dedicated notification")
val notification = NotificationUtils.buildIncomingCallNotification(
this,
intent.getBooleanExtra(EXTRA_IS_VIDEO, false),
intent.getStringExtra(EXTRA_ROOM_NAME),
intent.getStringExtra(EXTRA_MATRIX_ID),
callId)
startForeground(NOTIFICATION_ID, notification)
Timber.v("displayIncomingCallNotification : display the dedicated notification")
val notification = notificationUtils.buildIncomingCallNotification(
intent.getBooleanExtra(EXTRA_IS_VIDEO, false),
intent.getStringExtra(EXTRA_ROOM_NAME) ?: "",
intent.getStringExtra(EXTRA_ROOM_ID) ?: "",
callId ?: "")
startForeground(NOTIFICATION_ID, notification)
mIncomingCallId = callId
// mIncomingCallId = callId
// turn the screen on for 3 seconds
if (Matrix.getInstance(VectorApp.getInstance())!!.pushManager.isScreenTurnedOn) {
try {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
val wl = pm.newWakeLock(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or PowerManager.ACQUIRE_CAUSES_WAKEUP,
CallService::class.java.simpleName)
wl.acquire(3000)
wl.release()
} catch (re: RuntimeException) {
Timber.e(re, "displayIncomingCallNotification : failed to turn screen on ")
}
// turn the screen on for 3 seconds
// if (Matrix.getInstance(VectorApp.getInstance())!!.pushManager.isScreenTurnedOn) {
// try {
// val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
// val wl = pm.newWakeLock(
// WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or PowerManager.ACQUIRE_CAUSES_WAKEUP,
// CallService::class.java.simpleName)
// wl.acquire(3000)
// wl.release()
// } catch (re: RuntimeException) {
// Timber.e(re, "displayIncomingCallNotification : failed to turn screen on ")
// }
//
// }
// }
// else {
// Timber.i("displayIncomingCallNotification : do not display the incoming call notification because there is a pending call")
// }
}
}
} else {
Timber.i("displayIncomingCallNotification : do not display the incoming call notification because there is a pending call")
}// test if there is no active call
*/
private fun displayOutgoingRingingCallNotification(intent: Intent) {
val callId = intent.getStringExtra(EXTRA_CALL_ID)
Timber.v("displayOutgoingCallNotification : display the dedicated notification")
val notification = notificationUtils.buildOutgoingRingingCallNotification(
intent.getBooleanExtra(EXTRA_IS_VIDEO, false),
intent.getStringExtra(EXTRA_ROOM_NAME) ?: "",
intent.getStringExtra(EXTRA_ROOM_ID) ?: "",
callId ?: "")
startForeground(NOTIFICATION_ID, notification)
}
/**
* Display a call in progress notification.
*/
private fun displayCallInProgressNotification(intent: Intent) {
Timber.v("## VOIP displayCallInProgressNotification")
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
val notification = notificationUtils.buildPendingCallNotification(
@ -139,7 +217,27 @@ class CallService : VectorService() {
startForeground(NOTIFICATION_ID, notification)
mCallIdInProgress = callId
// mCallIdInProgress = callId
}
/**
* Display a call in progress notification.
*/
private fun displayCallOnGoingInBackground(intent: Intent) {
Timber.v("## VOIP displayCallInProgressNotification")
val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: ""
val notification = notificationUtils.buildPendingCallNotification(
isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, false),
roomName = intent.getStringExtra(EXTRA_ROOM_NAME) ?: "",
roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: "",
matrixId = intent.getStringExtra(EXTRA_MATRIX_ID) ?: "",
callId = callId,
fromBg = true)
startForeground(NOTIFICATION_ID, notification)
// mCallIdInProgress = callId
}
/**
@ -148,18 +246,28 @@ class CallService : VectorService() {
private fun hideCallNotifications() {
val notification = notificationUtils.buildCallEndedNotification()
mediaSession?.isActive = false
// It's mandatory to startForeground to avoid crash
startForeground(NOTIFICATION_ID, notification)
myStopSelf()
}
fun addConnection(callConnection: CallConnection) {
connections[callConnection.callId] = callConnection
}
companion object {
private const val NOTIFICATION_ID = 6480
private const val ACTION_INCOMING_CALL = "im.vector.riotx.core.services.CallService.INCOMING_CALL"
private const val ACTION_PENDING_CALL = "im.vector.riotx.core.services.CallService.PENDING_CALL"
private const val ACTION_INCOMING_RINGING_CALL = "im.vector.riotx.core.services.CallService.ACTION_INCOMING_RINGING_CALL"
private const val ACTION_OUTGOING_RINGING_CALL = "im.vector.riotx.core.services.CallService.ACTION_OUTGOING_RINGING_CALL"
private const val ACTION_CALL_CONNECTING = "im.vector.riotx.core.services.CallService.ACTION_CALL_CONNECTING"
private const val ACTION_ONGOING_CALL = "im.vector.riotx.core.services.CallService.ACTION_ONGOING_CALL"
private const val ACTION_ONGOING_CALL_BG = "im.vector.riotx.core.services.CallService.ACTION_ONGOING_CALL_BG"
private const val ACTION_NO_ACTIVE_CALL = "im.vector.riotx.core.services.CallService.NO_ACTIVE_CALL"
// private const val ACTION_ACTIVITY_VISIBLE = "im.vector.riotx.core.services.CallService.ACTION_ACTIVITY_VISIBLE"
// private const val ACTION_STOP_RINGING = "im.vector.riotx.core.services.CallService.ACTION_STOP_RINGING"
private const val EXTRA_IS_VIDEO = "EXTRA_IS_VIDEO"
private const val EXTRA_ROOM_NAME = "EXTRA_ROOM_NAME"
@ -167,15 +275,53 @@ class CallService : VectorService() {
private const val EXTRA_MATRIX_ID = "EXTRA_MATRIX_ID"
private const val EXTRA_CALL_ID = "EXTRA_CALL_ID"
fun onIncomingCall(context: Context,
isVideo: Boolean,
roomName: String,
roomId: String,
matrixId: String,
callId: String) {
fun onIncomingCallRinging(context: Context,
isVideo: Boolean,
roomName: String,
roomId: String,
matrixId: String,
callId: String) {
val intent = Intent(context, CallService::class.java)
.apply {
action = ACTION_INCOMING_CALL
action = ACTION_INCOMING_RINGING_CALL
putExtra(EXTRA_IS_VIDEO, isVideo)
putExtra(EXTRA_ROOM_NAME, roomName)
putExtra(EXTRA_ROOM_ID, roomId)
putExtra(EXTRA_MATRIX_ID, matrixId)
putExtra(EXTRA_CALL_ID, callId)
}
ContextCompat.startForegroundService(context, intent)
}
fun onOnGoingCallBackground(context: Context,
isVideo: Boolean,
roomName: String,
roomId: String,
matrixId: String,
callId: String) {
val intent = Intent(context, CallService::class.java)
.apply {
action = ACTION_ONGOING_CALL_BG
putExtra(EXTRA_IS_VIDEO, isVideo)
putExtra(EXTRA_ROOM_NAME, roomName)
putExtra(EXTRA_ROOM_ID, roomId)
putExtra(EXTRA_MATRIX_ID, matrixId)
putExtra(EXTRA_CALL_ID, callId)
}
ContextCompat.startForegroundService(context, intent)
}
fun onOutgoingCallRinging(context: Context,
isVideo: Boolean,
roomName: String,
roomId: String,
matrixId: String,
callId: String) {
val intent = Intent(context, CallService::class.java)
.apply {
action = ACTION_OUTGOING_RINGING_CALL
putExtra(EXTRA_IS_VIDEO, isVideo)
putExtra(EXTRA_ROOM_NAME, roomName)
putExtra(EXTRA_ROOM_ID, roomId)
@ -194,7 +340,7 @@ class CallService : VectorService() {
callId: String) {
val intent = Intent(context, CallService::class.java)
.apply {
action = ACTION_PENDING_CALL
action = ACTION_ONGOING_CALL
putExtra(EXTRA_IS_VIDEO, isVideo)
putExtra(EXTRA_ROOM_NAME, roomName)
putExtra(EXTRA_ROOM_ID, roomId)
@ -214,4 +360,20 @@ class CallService : VectorService() {
ContextCompat.startForegroundService(context, intent)
}
}
inner class CallServiceBinder : Binder() {
fun getCallService(): CallService {
return this@CallService
}
}
override fun onHeadsetEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) {
Timber.v("## VOIP: onHeadsetEvent $event")
webRtcPeerConnectionManager.onWiredDeviceEvent(event)
}
override fun onBTHeadsetEvent(event: BluetoothHeadsetReceiver.BTHeadsetPlugEvent) {
Timber.v("## VOIP: onBTHeadsetEvent $event")
webRtcPeerConnectionManager.onWirelessDeviceEvent(event)
}
}

View File

@ -0,0 +1,85 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.core.services
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import android.os.Build
import timber.log.Timber
import java.lang.ref.WeakReference
/**
* Dynamic broadcast receiver to detect headset plug/unplug
*/
class WiredHeadsetStateReceiver : BroadcastReceiver() {
interface HeadsetEventListener {
fun onHeadsetEvent(event: HeadsetPlugEvent)
}
var delegate: WeakReference<HeadsetEventListener>? = null
data class HeadsetPlugEvent(
val plugged: Boolean,
val headsetName: String?,
val hasMicrophone: Boolean
)
override fun onReceive(context: Context?, intent: Intent?) {
// The intent will have the following extra values:
// state 0 for unplugged, 1 for plugged
// name Headset type, human readable string
// microphone 1 if headset has a microphone, 0 otherwise
val isPlugged = when (intent?.getIntExtra("state", -1)) {
0 -> false
1 -> true
else -> return Unit.also {
Timber.v("## VOIP WiredHeadsetStateReceiver invalid state")
}
}
val hasMicrophone = when (intent.getIntExtra("microphone", -1)) {
1 -> true
else -> false
}
delegate?.get()?.onHeadsetEvent(
HeadsetPlugEvent(plugged = isPlugged, headsetName = intent.getStringExtra("name"), hasMicrophone = hasMicrophone)
)
}
companion object {
fun createAndRegister(context: Context, listener: HeadsetEventListener): WiredHeadsetStateReceiver {
val receiver = WiredHeadsetStateReceiver()
receiver.delegate = WeakReference(listener)
val action = if (Build.VERSION.SDK_INT >= 21) {
AudioManager.ACTION_HEADSET_PLUG
} else {
Intent.ACTION_HEADSET_PLUG
}
context.registerReceiver(receiver, IntentFilter(action))
return receiver
}
fun unRegister(context: Context, receiver: WiredHeadsetStateReceiver) {
context.unregisterReceiver(receiver)
}
}
}

View File

@ -0,0 +1,46 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.core.ui.views
import android.content.Context
import android.util.AttributeSet
import android.widget.RelativeLayout
import im.vector.riotx.R
import im.vector.riotx.features.themes.ThemeUtils
class ActiveCallView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RelativeLayout(context, attrs, defStyleAttr) {
interface Callback {
fun onTapToReturnToCall()
}
var callback: Callback? = null
init {
setupView()
}
private fun setupView() {
inflate(context, R.layout.view_active_call_view, this)
setBackgroundColor(ThemeUtils.getColor(context, R.attr.colorPrimary))
setOnClickListener { callback?.onTapToReturnToCall() }
}
}

View File

@ -0,0 +1,98 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.core.ui.views
import android.view.View
import androidx.cardview.widget.CardView
import androidx.core.view.isVisible
import im.vector.matrix.android.api.session.call.CallState
import im.vector.matrix.android.api.session.call.EglUtils
import im.vector.matrix.android.api.session.call.MxCall
import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.features.call.WebRtcPeerConnectionManager
import org.webrtc.RendererCommon
import org.webrtc.SurfaceViewRenderer
class ActiveCallViewHolder {
private var activeCallPiP: SurfaceViewRenderer? = null
private var activeCallView: ActiveCallView? = null
private var pipWrapper: CardView? = null
private var activeCallPipInitialized = false
fun updateCall(activeCall: MxCall?, webRtcPeerConnectionManager: WebRtcPeerConnectionManager) {
val hasActiveCall = activeCall?.state is CallState.Connected
if (hasActiveCall) {
val isVideoCall = activeCall?.isVideoCall == true
if (isVideoCall) initIfNeeded()
activeCallView?.isVisible = !isVideoCall
pipWrapper?.isVisible = isVideoCall
activeCallPiP?.isVisible = isVideoCall
activeCallPiP?.let {
webRtcPeerConnectionManager.attachViewRenderers(null, it, null)
}
} else {
activeCallView?.isVisible = false
activeCallPiP?.isVisible = false
pipWrapper?.isVisible = false
activeCallPiP?.let {
webRtcPeerConnectionManager.detachRenderers(listOf(it))
}
}
}
private fun initIfNeeded() {
if (!activeCallPipInitialized && activeCallPiP != null) {
activeCallPiP?.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
EglUtils.rootEglBase?.let { eglBase ->
activeCallPiP?.init(eglBase.eglBaseContext, null)
activeCallPiP?.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_BALANCED)
activeCallPiP?.setEnableHardwareScaler(true /* enabled */)
activeCallPiP?.setZOrderMediaOverlay(true)
activeCallPipInitialized = true
}
}
}
fun bind(activeCallPiP: SurfaceViewRenderer, activeCallView: ActiveCallView, pipWrapper: CardView, interactionListener: ActiveCallView.Callback) {
this.activeCallPiP = activeCallPiP
this.activeCallView = activeCallView
this.pipWrapper = pipWrapper
this.activeCallView?.callback = interactionListener
pipWrapper.setOnClickListener(
DebouncedClickListener(View.OnClickListener { _ ->
interactionListener.onTapToReturnToCall()
})
)
}
fun unBind(webRtcPeerConnectionManager: WebRtcPeerConnectionManager) {
activeCallPiP?.let {
webRtcPeerConnectionManager.detachRenderers(listOf(it))
}
if (activeCallPipInitialized) {
activeCallPiP?.release()
}
this.activeCallView?.callback = null
pipWrapper?.setOnClickListener(null)
activeCallPiP = null
activeCallView = null
pipWrapper = null
}
}

View File

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

View File

@ -0,0 +1,138 @@
/*
* 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.View
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import com.airbnb.mvrx.activityViewModel
import im.vector.riotx.R
import im.vector.riotx.core.platform.VectorBaseBottomSheetDialogFragment
import kotlinx.android.synthetic.main.bottom_sheet_call_controls.*
import me.gujun.android.span.span
class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment() {
override fun getLayoutResId() = R.layout.bottom_sheet_call_controls
private val callViewModel: VectorCallViewModel by activityViewModel()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
callViewModel.subscribe(this) {
renderState(it)
}
callControlsSoundDevice.clickableView.debouncedClicks {
callViewModel.handle(VectorCallViewActions.SwitchSoundDevice)
}
callControlsSwitchCamera.clickableView.debouncedClicks {
callViewModel.handle(VectorCallViewActions.ToggleCamera)
dismiss()
}
callControlsToggleSDHD.clickableView.debouncedClicks {
callViewModel.handle(VectorCallViewActions.ToggleHDSD)
dismiss()
}
callViewModel.observeViewEvents {
when (it) {
is VectorCallViewEvents.ShowSoundDeviceChooser -> {
showSoundDeviceChooser(it.available, it.current)
}
else -> {
}
}
}
}
private fun showSoundDeviceChooser(available: List<CallAudioManager.SoundDevice>, current: CallAudioManager.SoundDevice) {
val soundDevices = available.map {
when (it) {
CallAudioManager.SoundDevice.WIRELESS_HEADSET -> span {
text = getString(R.string.sound_device_wireless_headset)
textStyle = if (current == it) "bold" else "normal"
}
CallAudioManager.SoundDevice.PHONE -> span {
text = getString(R.string.sound_device_phone)
textStyle = if (current == it) "bold" else "normal"
}
CallAudioManager.SoundDevice.SPEAKER -> span {
text = getString(R.string.sound_device_speaker)
textStyle = if (current == it) "bold" else "normal"
}
CallAudioManager.SoundDevice.HEADSET -> span {
text = getString(R.string.sound_device_headset)
textStyle = if (current == it) "bold" else "normal"
}
}
}
AlertDialog.Builder(requireContext())
.setItems(soundDevices.toTypedArray()) { d, n ->
d.cancel()
when (soundDevices[n].toString()) {
// TODO Make an adapter and handle multiple Bluetooth headsets. Also do not use translations.
getString(R.string.sound_device_phone) -> {
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.PHONE))
}
getString(R.string.sound_device_speaker) -> {
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.SPEAKER))
}
getString(R.string.sound_device_headset) -> {
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.HEADSET))
}
getString(R.string.sound_device_wireless_headset) -> {
callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.WIRELESS_HEADSET))
}
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun renderState(state: VectorCallViewState) {
callControlsSoundDevice.title = getString(R.string.call_select_sound_device)
callControlsSoundDevice.subTitle = when (state.soundDevice) {
CallAudioManager.SoundDevice.PHONE -> getString(R.string.sound_device_phone)
CallAudioManager.SoundDevice.SPEAKER -> getString(R.string.sound_device_speaker)
CallAudioManager.SoundDevice.HEADSET -> getString(R.string.sound_device_headset)
CallAudioManager.SoundDevice.WIRELESS_HEADSET -> getString(R.string.sound_device_wireless_headset)
}
callControlsSwitchCamera.isVisible = state.isVideoCall && state.canSwitchCamera
callControlsSwitchCamera.subTitle = getString(if (state.isFrontCamera) R.string.call_camera_front else R.string.call_camera_back)
if (state.isVideoCall) {
callControlsToggleSDHD.isVisible = true
if (state.isHD) {
callControlsToggleSDHD.title = getString(R.string.call_format_turn_hd_off)
callControlsToggleSDHD.subTitle = null
callControlsToggleSDHD.leftIcon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_hd_disabled)
} else {
callControlsToggleSDHD.title = getString(R.string.call_format_turn_hd_on)
callControlsToggleSDHD.subTitle = null
callControlsToggleSDHD.leftIcon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_hd)
}
} else {
callControlsToggleSDHD.isVisible = false
}
}
}

View File

@ -0,0 +1,160 @@
/*
* 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.util.AttributeSet
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import butterknife.BindView
import butterknife.ButterKnife
import butterknife.OnClick
import im.vector.matrix.android.api.session.call.CallState
import im.vector.riotx.R
import kotlinx.android.synthetic.main.view_call_controls.view.*
import org.webrtc.PeerConnection
class CallControlsView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
var interactionListener: InteractionListener? = null
@BindView(R.id.ringingControls)
lateinit var ringingControls: ViewGroup
@BindView(R.id.iv_icr_accept_call)
lateinit var ringingControlAccept: ImageView
@BindView(R.id.iv_icr_end_call)
lateinit var ringingControlDecline: ImageView
@BindView(R.id.connectedControls)
lateinit var connectedControls: ViewGroup
@BindView(R.id.iv_mute_toggle)
lateinit var muteIcon: ImageView
@BindView(R.id.iv_video_toggle)
lateinit var videoToggleIcon: ImageView
init {
ConstraintLayout.inflate(context, R.layout.view_call_controls, this)
// layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
ButterKnife.bind(this)
}
@OnClick(R.id.iv_icr_accept_call)
fun acceptIncomingCall() {
interactionListener?.didAcceptIncomingCall()
}
@OnClick(R.id.iv_icr_end_call)
fun declineIncomingCall() {
interactionListener?.didDeclineIncomingCall()
}
@OnClick(R.id.iv_end_call)
fun endOngoingCall() {
interactionListener?.didEndCall()
}
@OnClick(R.id.iv_mute_toggle)
fun toggleMute() {
interactionListener?.didTapToggleMute()
}
@OnClick(R.id.iv_video_toggle)
fun toggleVideo() {
interactionListener?.didTapToggleVideo()
}
@OnClick(R.id.iv_leftMiniControl)
fun returnToChat() {
interactionListener?.returnToChat()
}
@OnClick(R.id.iv_more)
fun moreControlOption() {
interactionListener?.didTapMore()
}
fun updateForState(state: VectorCallViewState) {
val callState = state.callState.invoke()
if (state.isAudioMuted) {
muteIcon.setImageResource(R.drawable.ic_microphone_off)
muteIcon.contentDescription = resources.getString(R.string.a11y_unmute_microphone)
} else {
muteIcon.setImageResource(R.drawable.ic_microphone_on)
muteIcon.contentDescription = resources.getString(R.string.a11y_mute_microphone)
}
if (state.isVideoEnabled) {
videoToggleIcon.setImageResource(R.drawable.ic_video)
videoToggleIcon.contentDescription = resources.getString(R.string.a11y_stop_camera)
} else {
videoToggleIcon.setImageResource(R.drawable.ic_video_off)
videoToggleIcon.contentDescription = resources.getString(R.string.a11y_start_camera)
}
when (callState) {
is CallState.Idle,
is CallState.Dialing,
is CallState.Answering -> {
ringingControls.isVisible = true
ringingControlAccept.isVisible = false
ringingControlDecline.isVisible = true
connectedControls.isVisible = false
}
is CallState.LocalRinging -> {
ringingControls.isVisible = true
ringingControlAccept.isVisible = true
ringingControlDecline.isVisible = true
connectedControls.isVisible = false
}
is CallState.Connected -> {
if (callState.iceConnectionState == PeerConnection.PeerConnectionState.CONNECTED) {
ringingControls.isVisible = false
connectedControls.isVisible = true
iv_video_toggle.isVisible = state.isVideoCall
} else {
ringingControls.isVisible = true
ringingControlAccept.isVisible = false
ringingControlDecline.isVisible = true
connectedControls.isVisible = false
}
}
is CallState.Terminated,
null -> {
ringingControls.isVisible = false
connectedControls.isVisible = false
}
}
}
interface InteractionListener {
fun didAcceptIncomingCall()
fun didDeclineIncomingCall()
fun didEndCall()
fun didTapToggleMute()
fun didTapToggleVideo()
fun returnToChat()
fun didTapMore()
}
}

View File

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

View File

@ -0,0 +1,32 @@
/*
* 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
enum class CameraType {
FRONT,
BACK
}
data class CameraProxy(
val name: String,
val type: CameraType
)
sealed class CaptureFormat(val width: Int, val height: Int, val fps: Int) {
object HD : CaptureFormat(1280, 720, 30)
object SD : CaptureFormat(640, 480, 30)
}

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

View File

@ -0,0 +1,481 @@
/*
* 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.content.Intent.FLAG_ACTIVITY_CLEAR_TOP
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.view.KeyEvent
import android.view.View
import android.view.Window
import android.view.WindowManager
import androidx.appcompat.app.AlertDialog
import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import butterknife.BindView
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.viewModel
import com.jakewharton.rxbinding3.view.clicks
import im.vector.matrix.android.api.session.call.CallState
import im.vector.matrix.android.api.session.call.EglUtils
import im.vector.matrix.android.api.session.call.MxCallDetail
import im.vector.matrix.android.api.session.call.TurnServerResponse
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.core.services.CallService
import im.vector.riotx.core.utils.PERMISSIONS_FOR_AUDIO_IP_CALL
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 im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.RoomDetailActivity
import im.vector.riotx.features.home.room.detail.RoomDetailArgs
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.activity_call.*
import org.webrtc.EglBase
import org.webrtc.PeerConnection
import org.webrtc.RendererCommon
import org.webrtc.SurfaceViewRenderer
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@Parcelize
data class CallArgs(
val roomId: String,
val callId: String?,
val participantUserId: String,
val isIncomingCall: Boolean,
val isVideoCall: Boolean
) : Parcelable
class VectorCallActivity : VectorBaseActivity(), CallControlsView.InteractionListener {
override fun getLayoutRes() = R.layout.activity_call
@Inject lateinit var avatarRenderer: AvatarRenderer
override fun injectWith(injector: ScreenComponent) {
super.injectWith(injector)
injector.inject(this)
}
private val callViewModel: VectorCallViewModel by viewModel()
private lateinit var callArgs: CallArgs
@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
@BindView(R.id.callControls)
lateinit var callControlsView: CallControlsView
private var rootEglBase: EglBase? = null
var systemUiVisibility = false
var surfaceRenderersAreInitialized = false
override fun doBeforeSetContentView() {
// Set window styles for fullscreen-window size. Needs to be done before adding content.
requestWindowFeature(Window.FEATURE_NO_TITLE)
hideSystemUI()
setContentView(R.layout.activity_call)
}
private fun hideSystemUI() {
systemUiVisibility = false
// Enables regular immersive mode.
// For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE.
// Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE
// Set the content to appear under the system bars so that the
// content doesn't resize when the system bars hide and show.
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
// Hide the nav bar and status bar
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN)
}
// Shows the system bars by removing all the flags
// except for the ones that make the content appear under the system bars.
private fun showSystemUI() {
systemUiVisibility = true
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)
}
private fun toggleUiSystemVisibility() {
if (systemUiVisibility) {
hideSystemUI()
} else {
showSystemUI()
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
// Rehide when bottom sheet is dismissed
if (hasFocus) {
hideSystemUI()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// This will need to be refined
ViewCompat.setOnApplyWindowInsetsListener(constraintLayout) { v, insets ->
v.updatePadding(bottom = if (systemUiVisibility) insets.systemWindowInsetBottom else 0)
insets
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (intent.hasExtra(MvRx.KEY_ARG)) {
callArgs = intent.getParcelableExtra(MvRx.KEY_ARG)!!
} else {
Timber.e("## VOIP missing callArgs for VectorCall Activity")
CallService.onNoActiveCall(this)
finish()
}
Timber.v("## VOIP EXTRA_MODE is ${intent.getStringExtra(EXTRA_MODE)}")
if (intent.getStringExtra(EXTRA_MODE) == INCOMING_RINGING) {
turnScreenOnAndKeyguardOff()
}
constraintLayout.clicks()
.throttleFirst(300, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { toggleUiSystemVisibility() }
.disposeOnDestroy()
configureCallViews()
callViewModel.subscribe(this) {
renderState(it)
}
callViewModel.viewEvents
.observe()
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
handleViewEvents(it)
}
.disposeOnDestroy()
if (callArgs.isVideoCall) {
if (checkPermissions(PERMISSIONS_FOR_VIDEO_IP_CALL, this, CAPTURE_PERMISSION_REQUEST_CODE, R.string.permissions_rationale_msg_camera_and_audio)) {
start()
}
} else {
if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, this, CAPTURE_PERMISSION_REQUEST_CODE, R.string.permissions_rationale_msg_record_audio)) {
start()
}
}
}
override fun onDestroy() {
peerConnectionManager.detachRenderers(listOf(pipRenderer, fullscreenRenderer))
if (surfaceRenderersAreInitialized) {
pipRenderer.release()
fullscreenRenderer.release()
}
turnScreenOffAndKeyguardOn()
super.onDestroy()
}
private fun renderState(state: VectorCallViewState) {
Timber.v("## VOIP renderState call $state")
if (state.callState is Fail) {
// be sure to clear notification
CallService.onNoActiveCall(this)
finish()
return
}
callControlsView.updateForState(state)
val callState = state.callState.invoke()
callConnectingProgress.isVisible = false
when (callState) {
is CallState.Idle,
is CallState.Dialing -> {
callVideoGroup.isInvisible = true
callInfoGroup.isVisible = true
callStatusText.setText(R.string.call_ring)
configureCallInfo(state)
}
is CallState.LocalRinging -> {
callVideoGroup.isInvisible = true
callInfoGroup.isVisible = true
callStatusText.text = null
configureCallInfo(state)
}
is CallState.Answering -> {
callVideoGroup.isInvisible = true
callInfoGroup.isVisible = true
callStatusText.setText(R.string.call_connecting)
callConnectingProgress.isVisible = true
configureCallInfo(state)
}
is CallState.Connected -> {
if (callState.iceConnectionState == PeerConnection.PeerConnectionState.CONNECTED) {
if (callArgs.isVideoCall) {
callVideoGroup.isVisible = true
callInfoGroup.isVisible = false
pip_video_view.isVisible = !state.isVideoCaptureInError
} else {
callVideoGroup.isInvisible = true
callInfoGroup.isVisible = true
configureCallInfo(state)
callStatusText.text = null
}
} else {
// This state is not final, if you change network, new candidates will be sent
callVideoGroup.isInvisible = true
callInfoGroup.isVisible = true
configureCallInfo(state)
callStatusText.setText(R.string.call_connecting)
callConnectingProgress.isVisible = true
}
// ensure all attached?
peerConnectionManager.attachViewRenderers(pipRenderer, fullscreenRenderer, null)
}
is CallState.Terminated -> {
finish()
}
null -> {
}
}
}
private fun configureCallInfo(state: VectorCallViewState) {
state.otherUserMatrixItem.invoke()?.let {
avatarRenderer.render(it, otherMemberAvatar)
participantNameText.text = it.getBestName()
callTypeText.setText(if (state.isVideoCall) R.string.action_video_call else R.string.action_voice_call)
}
}
private fun configureCallViews() {
callControlsView.interactionListener = this
}
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() {
rootEglBase = EglUtils.rootEglBase ?: return Unit.also {
Timber.v("## VOIP rootEglBase is null")
finish()
}
// 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 */)
peerConnectionManager.attachViewRenderers(pipRenderer, fullscreenRenderer,
intent.getStringExtra(EXTRA_MODE)?.takeIf { isFirstCreation() })
pipRenderer.setOnClickListener {
callViewModel.handle(VectorCallViewActions.ToggleCamera)
}
surfaceRenderersAreInitialized = true
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
// for newer version, it will be passed automatically to active media session
// in call service
when (keyCode) {
KeyEvent.KEYCODE_HEADSETHOOK -> {
callViewModel.handle(VectorCallViewActions.HeadSetButtonPressed)
return true
}
}
}
return super.onKeyDown(keyCode, event)
}
private fun handleViewEvents(event: VectorCallViewEvents?) {
Timber.v("## VOIP handleViewEvents $event")
when (event) {
VectorCallViewEvents.DismissNoCall -> {
CallService.onNoActiveCall(this)
finish()
}
is VectorCallViewEvents.ConnectionTimeout -> {
onErrorTimoutConnect(event.turn)
}
null -> {
}
}
}
private fun onErrorTimoutConnect(turn: TurnServerResponse?) {
Timber.d("## VOIP onErrorTimoutConnect $turn")
// TODO ask to use default stun, etc...
AlertDialog
.Builder(this)
.setTitle(R.string.call_failed_no_connection)
.setMessage(getString(R.string.call_failed_no_connection_description))
.setNegativeButton(R.string.ok) { _, _ ->
callViewModel.handle(VectorCallViewActions.EndCall)
}
.show()
}
companion object {
private const val CAPTURE_PERMISSION_REQUEST_CODE = 1
private const val EXTRA_MODE = "EXTRA_MODE"
const val OUTGOING_CREATED = "OUTGOING_CREATED"
const val INCOMING_RINGING = "INCOMING_RINGING"
const val INCOMING_ACCEPT = "INCOMING_ACCEPT"
fun newIntent(context: Context, mxCall: MxCallDetail): Intent {
return Intent(context, VectorCallActivity::class.java).apply {
// what could be the best flags?
flags = Intent.FLAG_ACTIVITY_NEW_TASK
putExtra(MvRx.KEY_ARG, CallArgs(mxCall.roomId, mxCall.callId, mxCall.otherUserId, !mxCall.isOutgoing, mxCall.isVideoCall))
putExtra(EXTRA_MODE, OUTGOING_CREATED)
}
}
fun newIntent(context: Context,
callId: String?,
roomId: String,
otherUserId: String,
isIncomingCall: Boolean,
isVideoCall: Boolean,
mode: String?): Intent {
return Intent(context, VectorCallActivity::class.java).apply {
// what could be the best flags?
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(MvRx.KEY_ARG, CallArgs(roomId, callId, otherUserId, isIncomingCall, isVideoCall))
putExtra(EXTRA_MODE, mode)
}
}
}
override fun didAcceptIncomingCall() {
callViewModel.handle(VectorCallViewActions.AcceptCall)
}
override fun didDeclineIncomingCall() {
callViewModel.handle(VectorCallViewActions.DeclineCall)
}
override fun didEndCall() {
callViewModel.handle(VectorCallViewActions.EndCall)
}
override fun didTapToggleMute() {
callViewModel.handle(VectorCallViewActions.ToggleMute)
}
override fun didTapToggleVideo() {
callViewModel.handle(VectorCallViewActions.ToggleVideo)
}
override fun returnToChat() {
val args = RoomDetailArgs(callArgs.roomId)
val intent = RoomDetailActivity.newIntent(this, args).apply {
flags = FLAG_ACTIVITY_CLEAR_TOP
}
startActivity(intent)
// is it needed?
finish()
}
override fun didTapMore() {
CallControlsBottomSheet().show(supportFragmentManager, "Controls")
}
// Needed to let you answer call when phone is locked
private fun turnScreenOnAndKeyguardOff() {
Timber.v("## VOIP turnScreenOnAndKeyguardOff")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
or WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
)
}
with(getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
requestDismissKeyguard(this@VectorCallActivity, null)
}
}
}
private fun turnScreenOffAndKeyguardOn() {
Timber.v("## VOIP turnScreenOnAndKeyguardOn")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(false)
setTurnScreenOn(false)
} else {
@Suppress("DEPRECATION")
window.clearFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
)
}
}
}

View File

@ -0,0 +1,296 @@
/*
* 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 com.airbnb.mvrx.Async
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.call.CallState
import im.vector.matrix.android.api.session.call.MxCall
import im.vector.matrix.android.api.session.call.TurnServerResponse
import im.vector.matrix.android.api.util.MatrixItem
import im.vector.matrix.android.api.util.toMatrixItem
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 org.webrtc.PeerConnection
import java.util.Timer
import java.util.TimerTask
data class VectorCallViewState(
val callId: String? = null,
val roomId: String = "",
val isVideoCall: Boolean,
val isAudioMuted: Boolean = false,
val isVideoEnabled: Boolean = true,
val isVideoCaptureInError: Boolean = false,
val isHD: Boolean = false,
val isFrontCamera: Boolean = true,
val canSwitchCamera: Boolean = true,
val soundDevice: CallAudioManager.SoundDevice = CallAudioManager.SoundDevice.PHONE,
val availableSoundDevices: List<CallAudioManager.SoundDevice> = emptyList(),
val otherUserMatrixItem: Async<MatrixItem> = Uninitialized,
val callState: Async<CallState> = Uninitialized
) : MvRxState
sealed class VectorCallViewActions : VectorViewModelAction {
object EndCall : VectorCallViewActions()
object AcceptCall : VectorCallViewActions()
object DeclineCall : VectorCallViewActions()
object ToggleMute : VectorCallViewActions()
object ToggleVideo : VectorCallViewActions()
data class ChangeAudioDevice(val device: CallAudioManager.SoundDevice) : VectorCallViewActions()
object SwitchSoundDevice : VectorCallViewActions()
object HeadSetButtonPressed : VectorCallViewActions()
object ToggleCamera : VectorCallViewActions()
object ToggleHDSD : VectorCallViewActions()
}
sealed class VectorCallViewEvents : VectorViewEvents {
object DismissNoCall : VectorCallViewEvents()
data class ConnectionTimeout(val turn: TurnServerResponse?) : VectorCallViewEvents()
data class ShowSoundDeviceChooser(
val available: List<CallAudioManager.SoundDevice>,
val current: CallAudioManager.SoundDevice
) : VectorCallViewEvents()
// data class CallAnswered(val content: CallAnswerContent) : VectorCallViewEvents()
// data class CallHangup(val content: CallHangupContent) : VectorCallViewEvents()
// object CallAccepted : VectorCallViewEvents()
}
class VectorCallViewModel @AssistedInject constructor(
@Assisted initialState: VectorCallViewState,
@Assisted val args: CallArgs,
val session: Session,
val webRtcPeerConnectionManager: WebRtcPeerConnectionManager
) : VectorViewModel<VectorCallViewState, VectorCallViewActions, VectorCallViewEvents>(initialState) {
var call: MxCall? = null
var connectionTimoutTimer: Timer? = null
var hasBeenConnectedOnce = false
private val callStateListener = object : MxCall.StateListener {
override fun onStateUpdate(call: MxCall) {
val callState = call.state
if (callState is CallState.Connected && callState.iceConnectionState == PeerConnection.PeerConnectionState.CONNECTED) {
hasBeenConnectedOnce = true
connectionTimoutTimer?.cancel()
connectionTimoutTimer = null
} else {
// do we reset as long as it's moving?
connectionTimoutTimer?.cancel()
if (hasBeenConnectedOnce) {
connectionTimoutTimer = Timer().apply {
schedule(object : TimerTask() {
override fun run() {
session.callSignalingService().getTurnServer(object : MatrixCallback<TurnServerResponse> {
override fun onFailure(failure: Throwable) {
_viewEvents.post(VectorCallViewEvents.ConnectionTimeout(null))
}
override fun onSuccess(data: TurnServerResponse) {
_viewEvents.post(VectorCallViewEvents.ConnectionTimeout(data))
}
})
}
}, 30_000)
}
}
}
setState {
copy(
callState = Success(callState)
)
}
}
}
private val currentCallListener = object : WebRtcPeerConnectionManager.CurrentCallListener {
override fun onCurrentCallChange(call: MxCall?) {
}
override fun onCaptureStateChanged(mgr: WebRtcPeerConnectionManager) {
setState {
copy(
isVideoCaptureInError = mgr.capturerIsInError,
isHD = mgr.currentCaptureFormat() is CaptureFormat.HD
)
}
}
override fun onAudioDevicesChange(mgr: WebRtcPeerConnectionManager) {
setState {
copy(
availableSoundDevices = mgr.audioManager.getAvailableSoundDevices(),
soundDevice = mgr.audioManager.getCurrentSoundDevice()
)
}
}
override fun onCameraChange(mgr: WebRtcPeerConnectionManager) {
setState {
copy(
canSwitchCamera = mgr.canSwitchCamera(),
isFrontCamera = mgr.currentCameraType() == CameraType.FRONT
)
}
}
}
init {
initialState.callId?.let {
webRtcPeerConnectionManager.addCurrentCallListener(currentCallListener)
session.callSignalingService().getCallWithId(it)?.let { mxCall ->
this.call = mxCall
mxCall.otherUserId
val item: MatrixItem? = session.getUser(mxCall.otherUserId)?.toMatrixItem()
mxCall.addListener(callStateListener)
setState {
copy(
isVideoCall = mxCall.isVideoCall,
callState = Success(mxCall.state),
otherUserMatrixItem = item?.let { Success(it) } ?: Uninitialized,
soundDevice = webRtcPeerConnectionManager.audioManager.getCurrentSoundDevice(),
availableSoundDevices = webRtcPeerConnectionManager.audioManager.getAvailableSoundDevices(),
isFrontCamera = webRtcPeerConnectionManager.currentCameraType() == CameraType.FRONT,
canSwitchCamera = webRtcPeerConnectionManager.canSwitchCamera(),
isHD = mxCall.isVideoCall && webRtcPeerConnectionManager.currentCaptureFormat() is CaptureFormat.HD
)
}
} ?: run {
setState {
copy(
callState = Fail(IllegalArgumentException("No call"))
)
}
}
}
}
override fun onCleared() {
// session.callService().removeCallListener(callServiceListener)
webRtcPeerConnectionManager.removeCurrentCallListener(currentCallListener)
this.call?.removeListener(callStateListener)
super.onCleared()
}
override fun handle(action: VectorCallViewActions) = withState { state ->
when (action) {
VectorCallViewActions.EndCall -> webRtcPeerConnectionManager.endCall()
VectorCallViewActions.AcceptCall -> {
setState {
copy(callState = Loading())
}
webRtcPeerConnectionManager.acceptIncomingCall()
}
VectorCallViewActions.DeclineCall -> {
setState {
copy(callState = Loading())
}
webRtcPeerConnectionManager.endCall()
}
VectorCallViewActions.ToggleMute -> {
val muted = state.isAudioMuted
webRtcPeerConnectionManager.muteCall(!muted)
setState {
copy(isAudioMuted = !muted)
}
}
VectorCallViewActions.ToggleVideo -> {
if (state.isVideoCall) {
val videoEnabled = state.isVideoEnabled
webRtcPeerConnectionManager.enableVideo(!videoEnabled)
setState {
copy(isVideoEnabled = !videoEnabled)
}
}
Unit
}
is VectorCallViewActions.ChangeAudioDevice -> {
webRtcPeerConnectionManager.audioManager.setCurrentSoundDevice(action.device)
setState {
copy(
soundDevice = webRtcPeerConnectionManager.audioManager.getCurrentSoundDevice()
)
}
}
VectorCallViewActions.SwitchSoundDevice -> {
_viewEvents.post(
VectorCallViewEvents.ShowSoundDeviceChooser(state.availableSoundDevices, state.soundDevice)
)
}
VectorCallViewActions.HeadSetButtonPressed -> {
if (state.callState.invoke() is CallState.LocalRinging) {
// accept call
webRtcPeerConnectionManager.acceptIncomingCall()
}
if (state.callState.invoke() is CallState.Connected) {
// end call?
webRtcPeerConnectionManager.endCall()
}
Unit
}
VectorCallViewActions.ToggleCamera -> {
webRtcPeerConnectionManager.switchCamera()
}
VectorCallViewActions.ToggleHDSD -> {
if (!state.isVideoCall) return@withState
webRtcPeerConnectionManager.setCaptureFormat(if (state.isHD) CaptureFormat.SD else CaptureFormat.HD)
}
}.exhaustive
}
@AssistedInject.Factory
interface Factory {
fun create(initialState: VectorCallViewState, args: CallArgs): VectorCallViewModel
}
companion object : MvRxViewModelFactory<VectorCallViewModel, VectorCallViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: VectorCallViewState): VectorCallViewModel? {
val callActivity: VectorCallActivity = viewModelContext.activity()
val callArgs: CallArgs = viewModelContext.args()
return callActivity.viewModelFactory.create(state, callArgs)
}
override fun initialState(viewModelContext: ViewModelContext): VectorCallViewState? {
val args: CallArgs = viewModelContext.args()
return VectorCallViewState(
callId = args.callId,
roomId = args.roomId,
isVideoCall = args.isVideoCall
)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,68 @@
/*
* 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.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import im.vector.riotx.core.di.HasVectorInjector
import im.vector.riotx.features.call.WebRtcPeerConnectionManager
import im.vector.riotx.features.notifications.NotificationUtils
import im.vector.riotx.features.settings.VectorLocale.context
import timber.log.Timber
class CallHeadsUpActionReceiver : BroadcastReceiver() {
companion object {
const val EXTRA_CALL_ACTION_KEY = "EXTRA_CALL_ACTION_KEY"
const val CALL_ACTION_REJECT = 0
}
private lateinit var peerConnectionManager: WebRtcPeerConnectionManager
private lateinit var notificationUtils: NotificationUtils
init {
val appContext = context.applicationContext
if (appContext is HasVectorInjector) {
peerConnectionManager = appContext.injector().webRtcPeerConnectionManager()
notificationUtils = appContext.injector().notificationUtils()
}
}
override fun onReceive(context: Context, intent: Intent?) {
when (intent?.getIntExtra(EXTRA_CALL_ACTION_KEY, 0)) {
CALL_ACTION_REJECT -> onCallRejectClicked()
}
// Not sure why this should be needed
// val it = Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)
// context.sendBroadcast(it)
// Close the notification after the click action is performed.
// context.stopService(Intent(context, CallHeadsUpService::class.java))
}
private fun onCallRejectClicked() {
Timber.d("onCallRejectClicked")
peerConnectionManager.endCall()
}
// private fun onCallAnswerClicked(context: Context) {
// Timber.d("onCallAnswerClicked")
// peerConnectionManager.answerCall(context)
// }
}

View File

@ -0,0 +1,112 @@
/*
* 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.telecom
import android.content.Context
import android.os.Build
import android.telecom.Connection
import android.telecom.DisconnectCause
import androidx.annotation.RequiresApi
import im.vector.riotx.features.call.VectorCallViewModel
import im.vector.riotx.features.call.WebRtcPeerConnectionManager
import timber.log.Timber
import javax.inject.Inject
@RequiresApi(Build.VERSION_CODES.M) class CallConnection(
private val context: Context,
private val roomId: String,
val callId: String
) : Connection() {
@Inject lateinit var peerConnectionManager: WebRtcPeerConnectionManager
@Inject lateinit var callViewModel: VectorCallViewModel
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
connectionProperties = PROPERTY_SELF_MANAGED
}
}
/**
* 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() {
super.onShowIncomingCallUi()
Timber.i("onShowIncomingCallUi")
/*
VectorCallActivity.newIntent(context, roomId).let {
context.startActivity(it)
}
*/
}
override fun onAnswer() {
super.onAnswer()
// startCall()
Timber.i("onShowIncomingCallUi")
}
override fun onStateChanged(state: Int) {
super.onStateChanged(state)
Timber.i("onStateChanged${stateToString(state)}")
}
override fun onReject() {
super.onReject()
Timber.i("onReject")
close()
}
override fun onDisconnect() {
onDisconnect()
Timber.i("onDisconnect")
close()
}
private fun close() {
setDisconnected(DisconnectCause(DisconnectCause.CANCELED))
destroy()
}
private fun startCall() {
/*
//peerConnectionManager.createPeerConnectionFactory()
peerConnectionManager.listener = this
val cameraIterator = if (Camera2Enumerator.isSupported(context)) Camera2Enumerator(context) else Camera1Enumerator(false)
val frontCamera = cameraIterator.deviceNames
?.firstOrNull { cameraIterator.isFrontFacing(it) }
?: cameraIterator.deviceNames?.first()
?: return
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()
*/
}
}

View File

@ -0,0 +1,29 @@
/*
* 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.telecom
import android.content.Context
import android.telephony.TelephonyManager
object TelecomUtils {
fun isLineBusy(context: Context): Boolean {
val telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
?: return false
return telephonyManager.callState != TelephonyManager.CALL_STATE_IDLE
}
}

View File

@ -0,0 +1,91 @@
/*
* 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.telecom
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.net.Uri
import android.os.Build
import android.os.IBinder
import android.telecom.Connection
import android.telecom.ConnectionRequest
import android.telecom.ConnectionService
import android.telecom.PhoneAccountHandle
import android.telecom.StatusHints
import android.telecom.TelecomManager
import androidx.annotation.RequiresApi
import im.vector.riotx.core.services.CallService
/**
* 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)
}
override fun onCreateIncomingConnection(connectionManagerPhoneAccount: PhoneAccountHandle?, request: ConnectionRequest?): Connection {
val roomId = request?.extras?.getString("MX_CALL_ROOM_ID") ?: return super.onCreateIncomingConnection(connectionManagerPhoneAccount, request)
val callId = request.extras.getString("MX_CALL_CALL_ID") ?: return super.onCreateIncomingConnection(connectionManagerPhoneAccount, request)
val connection = CallConnection(applicationContext, roomId, callId)
connection.connectionCapabilities = Connection.CAPABILITY_MUTE
connection.audioModeIsVoip = true
connection.setAddress(Uri.fromParts("tel", "+905000000000", null), TelecomManager.PRESENTATION_ALLOWED)
connection.setCallerDisplayName("RiotX Caller", TelecomManager.PRESENTATION_ALLOWED)
connection.statusHints = StatusHints("Testing Hint...", null, null)
bindService(Intent(applicationContext, CallService::class.java), CallServiceConnection(connection), 0)
connection.setInitializing()
return CallConnection(applicationContext, roomId, callId)
}
inner class CallServiceConnection(private val callConnection: CallConnection) : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
val callSrvBinder = binder as CallService.CallServiceBinder
callSrvBinder.getCallService().addConnection(callConnection)
unbindService(this)
}
override fun onServiceDisconnected(name: ComponentName?) {
}
}
companion object {
const val TAG = "TComService"
}
}

View File

@ -37,7 +37,12 @@ import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.platform.ToolbarConfigurable
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.ui.views.ActiveCallView
import im.vector.riotx.core.ui.views.ActiveCallViewHolder
import im.vector.riotx.core.ui.views.KeysBackupBanner
import im.vector.riotx.features.call.SharedActiveCallViewModel
import im.vector.riotx.features.call.VectorCallActivity
import im.vector.riotx.features.call.WebRtcPeerConnectionManager
import im.vector.riotx.features.home.room.list.RoomListFragment
import im.vector.riotx.features.home.room.list.RoomListParams
import im.vector.riotx.features.home.room.list.UnreadCounterBadgeView
@ -46,6 +51,11 @@ import im.vector.riotx.features.popup.VerificationVectorAlert
import im.vector.riotx.features.settings.VectorSettingsActivity.Companion.EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY_MANAGE_SESSIONS
import im.vector.riotx.features.workers.signout.SignOutViewModel
import kotlinx.android.synthetic.main.fragment_home_detail.*
import kotlinx.android.synthetic.main.fragment_home_detail.activeCallPiP
import kotlinx.android.synthetic.main.fragment_home_detail.activeCallPiPWrap
import kotlinx.android.synthetic.main.fragment_home_detail.activeCallView
import kotlinx.android.synthetic.main.fragment_home_detail.syncStateView
import kotlinx.android.synthetic.main.fragment_room_detail.*
import timber.log.Timber
import javax.inject.Inject
@ -56,8 +66,9 @@ private const val INDEX_ROOMS = 2
class HomeDetailFragment @Inject constructor(
val homeDetailViewModelFactory: HomeDetailViewModel.Factory,
private val avatarRenderer: AvatarRenderer,
private val alertManager: PopupAlertManager
) : VectorBaseFragment(), KeysBackupBanner.Delegate {
private val alertManager: PopupAlertManager,
private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager
) : VectorBaseFragment(), KeysBackupBanner.Delegate, ActiveCallView.Callback {
private val unreadCounterBadgeViews = arrayListOf<UnreadCounterBadgeView>()
@ -65,16 +76,21 @@ class HomeDetailFragment @Inject constructor(
private val unknownDeviceDetectorSharedViewModel: UnknownDeviceDetectorSharedViewModel by activityViewModel()
private lateinit var sharedActionViewModel: HomeSharedActionViewModel
private lateinit var sharedCallActionViewModel: SharedActiveCallViewModel
override fun getLayoutResId() = R.layout.fragment_home_detail
private val activeCallViewHolder = ActiveCallViewHolder()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sharedActionViewModel = activityViewModelProvider.get(HomeSharedActionViewModel::class.java)
sharedCallActionViewModel = activityViewModelProvider.get(SharedActiveCallViewModel::class.java)
setupBottomNavigationView()
setupToolbar()
setupKeysBackupBanner()
setupActiveCallView()
withState(viewModel) {
// Update the navigation view if needed (for when we restore the tabs)
@ -105,6 +121,13 @@ class HomeDetailFragment @Inject constructor(
}
}
}
sharedCallActionViewModel
.activeCall
.observe(viewLifecycleOwner, Observer {
activeCallViewHolder.updateCall(it, webRtcPeerConnectionManager)
invalidateOptionsMenu()
})
}
private fun promptForNewUnknownDevices(uid: String, state: UnknownDevicesState, newest: DeviceInfo) {
@ -203,6 +226,15 @@ class HomeDetailFragment @Inject constructor(
homeKeysBackupBanner.delegate = this
}
private fun setupActiveCallView() {
activeCallViewHolder.bind(
activeCallPiP,
activeCallView,
activeCallPiPWrap,
this
)
}
private fun setupToolbar() {
val parentActivity = vectorBaseActivity
if (parentActivity is ToolbarConfigurable) {
@ -283,4 +315,20 @@ class HomeDetailFragment @Inject constructor(
RoomListDisplayMode.ROOMS -> R.id.bottom_action_rooms
else -> R.id.bottom_action_home
}
override fun onTapToReturnToCall() {
sharedCallActionViewModel.activeCall.value?.let { call ->
VectorCallActivity.newIntent(
context = requireContext(),
callId = call.callId,
roomId = call.roomId,
otherUserId = call.otherUserId,
isIncomingCall = !call.isOutgoing,
isVideoCall = call.isVideoCall,
mode = null
).let {
startActivity(it)
}
}
}
}

View File

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

View File

@ -42,6 +42,7 @@ import androidx.core.util.Pair
import androidx.core.view.ViewCompat
import androidx.core.view.forEach
import androidx.core.view.isVisible
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@ -100,10 +101,14 @@ import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.intent.getMimeTypeFromUri
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.ui.views.ActiveCallView
import im.vector.riotx.core.ui.views.ActiveCallViewHolder
import im.vector.riotx.core.ui.views.JumpToReadMarkerView
import im.vector.riotx.core.ui.views.NotificationAreaView
import im.vector.riotx.core.utils.Debouncer
import im.vector.riotx.core.utils.KeyboardStateUtils
import im.vector.riotx.core.utils.PERMISSIONS_FOR_AUDIO_IP_CALL
import im.vector.riotx.core.utils.PERMISSIONS_FOR_VIDEO_IP_CALL
import im.vector.riotx.core.utils.PERMISSIONS_FOR_WRITING_FILES
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_DOWNLOAD_FILE
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_INCOMING_URI
@ -117,6 +122,8 @@ import im.vector.riotx.core.utils.createJSonViewerStyleProvider
import im.vector.riotx.core.utils.createUIHandler
import im.vector.riotx.core.utils.getColorFromUserId
import im.vector.riotx.core.utils.isValidUrl
import im.vector.riotx.core.utils.onPermissionResultAudioIpCall
import im.vector.riotx.core.utils.onPermissionResultVideoIpCall
import im.vector.riotx.core.utils.openUrlInExternalBrowser
import im.vector.riotx.core.utils.saveMedia
import im.vector.riotx.core.utils.shareMedia
@ -127,6 +134,9 @@ import im.vector.riotx.features.attachments.ContactAttachment
import im.vector.riotx.features.attachments.preview.AttachmentsPreviewActivity
import im.vector.riotx.features.attachments.preview.AttachmentsPreviewArgs
import im.vector.riotx.features.attachments.toGroupedContentAttachmentData
import im.vector.riotx.features.call.SharedActiveCallViewModel
import im.vector.riotx.features.call.VectorCallActivity
import im.vector.riotx.features.call.WebRtcPeerConnectionManager
import im.vector.riotx.features.command.Command
import im.vector.riotx.features.crypto.keysbackup.restore.KeysBackupRestoreActivity
import im.vector.riotx.features.crypto.util.toImageRes
@ -196,17 +206,22 @@ class RoomDetailFragment @Inject constructor(
val roomDetailViewModelFactory: RoomDetailViewModel.Factory,
private val eventHtmlRenderer: EventHtmlRenderer,
private val vectorPreferences: VectorPreferences,
private val colorProvider: ColorProvider) :
private val colorProvider: ColorProvider,
private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager) :
VectorBaseFragment(),
TimelineEventController.Callback,
VectorInviteView.Callback,
JumpToReadMarkerView.Callback,
AttachmentTypeSelectorView.Callback,
AttachmentsHelper.Callback,
RoomWidgetsBannerView.Callback {
RoomWidgetsBannerView.Callback,
ActiveCallView.Callback {
companion object {
private const val AUDIO_CALL_PERMISSION_REQUEST_CODE = 1
private const val VIDEO_CALL_PERMISSION_REQUEST_CODE = 2
/**
* Sanitize the display name.
*
@ -243,6 +258,8 @@ class RoomDetailFragment @Inject constructor(
override fun getMenuRes() = R.menu.menu_timeline
private lateinit var sharedActionViewModel: MessageSharedActionViewModel
private lateinit var sharedCallActionViewModel: SharedActiveCallViewModel
private lateinit var layoutManager: LinearLayoutManager
private lateinit var jumpToBottomViewVisibilityManager: JumpToBottomViewVisibilityManager
private var modelBuildListener: OnModelBuildFinishedListener? = null
@ -255,10 +272,12 @@ class RoomDetailFragment @Inject constructor(
private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView
private var lockSendButton = false
private val activeCallViewHolder = ActiveCallViewHolder()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java)
sharedCallActionViewModel = activityViewModelProvider.get(SharedActiveCallViewModel::class.java)
attachmentsHelper = AttachmentsHelper(requireContext(), this).register()
keyboardStateUtils = KeyboardStateUtils(requireActivity())
setupToolbar(roomToolbar)
@ -267,6 +286,7 @@ class RoomDetailFragment @Inject constructor(
setupInviteView()
setupNotificationView()
setupJumpToReadMarkerView()
setupActiveCallView()
setupJumpToBottomView()
setupWidgetsBannerView()
@ -281,6 +301,13 @@ class RoomDetailFragment @Inject constructor(
}
.disposeOnDestroyView()
sharedCallActionViewModel
.activeCall
.observe(viewLifecycleOwner, Observer {
activeCallViewHolder.updateCall(it, webRtcPeerConnectionManager)
invalidateOptionsMenu()
})
roomDetailViewModel.selectSubscribe(this, RoomDetailViewState::tombstoneEventHandling, uniqueOnly("tombstoneEventHandling")) {
renderTombstoneEventHandling(it)
}
@ -372,6 +399,7 @@ class RoomDetailFragment @Inject constructor(
override fun onDestroyView() {
timelineEventController.callback = null
timelineEventController.removeModelBuildListener(modelBuildListener)
activeCallView.callback = null
modelBuildListener = null
autoCompleter.clear()
debouncer.cancelAll()
@ -381,6 +409,7 @@ class RoomDetailFragment @Inject constructor(
}
override fun onDestroy() {
activeCallViewHolder.unBind(webRtcPeerConnectionManager)
roomDetailViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState)
super.onDestroy()
}
@ -410,6 +439,15 @@ class RoomDetailFragment @Inject constructor(
jumpToReadMarkerView.callback = this
}
private fun setupActiveCallView() {
activeCallViewHolder.bind(
activeCallPiP,
activeCallView,
activeCallPiPWrap,
this
)
}
private fun navigateToEvent(action: RoomDetailViewEvents.NavigateToEvent) {
val scrollPosition = timelineEventController.searchPositionOfEvent(action.eventId)
if (scrollPosition == null) {
@ -477,6 +515,29 @@ class RoomDetailFragment @Inject constructor(
}
true
}
R.id.voice_call,
R.id.video_call -> {
val activeCall = sharedCallActionViewModel.activeCall.value
val isVideoCall = item.itemId == R.id.video_call
if (activeCall != null) {
// resume existing if same room, if not prompt to kill and then restart new call?
if (activeCall.roomId == roomDetailArgs.roomId) {
onTapToReturnToCall()
}
// else {
// TODO might not work well, and should prompt
// webRtcPeerConnectionManager.endCall()
// safeStartCall(it, isVideoCall)
// }
} else {
safeStartCall(isVideoCall)
}
true
}
R.id.hangup_call -> {
roomDetailViewModel.handle(RoomDetailAction.EndCall)
true
}
else -> super.onOptionsItemSelected(item)
}
}
@ -492,6 +553,26 @@ class RoomDetailFragment @Inject constructor(
.show()
}
private fun safeStartCall(isVideoCall: Boolean) {
val startCallAction = RoomDetailAction.StartCall(isVideoCall)
roomDetailViewModel.pendingAction = startCallAction
if (isVideoCall) {
if (checkPermissions(PERMISSIONS_FOR_VIDEO_IP_CALL,
this, VIDEO_CALL_PERMISSION_REQUEST_CODE,
R.string.permissions_rationale_msg_camera_and_audio)) {
roomDetailViewModel.pendingAction = null
roomDetailViewModel.handle(startCallAction)
}
} else {
if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL,
this, AUDIO_CALL_PERMISSION_REQUEST_CODE,
R.string.permissions_rationale_msg_record_audio)) {
roomDetailViewModel.pendingAction = null
roomDetailViewModel.handle(startCallAction)
}
}
}
private fun renderRegularMode(text: String) {
autoCompleter.exitSpecialMode()
composerLayout.collapse()
@ -735,6 +816,7 @@ class RoomDetailFragment @Inject constructor(
override fun invalidate() = withState(roomDetailViewModel) { state ->
renderRoomSummary(state)
invalidateOptionsMenu()
val summary = state.asyncRoomSummary()
val inviter = state.asyncInviter()
if (summary?.membership == Membership.JOIN) {
@ -1086,6 +1168,22 @@ class RoomDetailFragment @Inject constructor(
launchAttachmentProcess(pendingType)
}
}
AUDIO_CALL_PERMISSION_REQUEST_CODE -> {
if (onPermissionResultAudioIpCall(requireContext(), grantResults)) {
(roomDetailViewModel.pendingAction as? RoomDetailAction.StartCall)?.let {
roomDetailViewModel.pendingAction = null
roomDetailViewModel.handle(it)
}
}
}
VIDEO_CALL_PERMISSION_REQUEST_CODE -> {
if (onPermissionResultVideoIpCall(requireContext(), grantResults)) {
(roomDetailViewModel.pendingAction as? RoomDetailAction.StartCall)?.let {
roomDetailViewModel.pendingAction = null
roomDetailViewModel.handle(it)
}
}
}
}
} else {
// Reset all pending data
@ -1469,4 +1567,20 @@ class RoomDetailFragment @Inject constructor(
RoomWidgetsBottomSheet.newInstance()
.show(childFragmentManager, "ROOM_WIDGETS_BOTTOM_SHEET")
}
override fun onTapToReturnToCall() {
sharedCallActionViewModel.activeCall.value?.let { call ->
VectorCallActivity.newIntent(
context = requireContext(),
callId = call.callId,
roomId = call.roomId,
otherUserId = call.otherUserId,
isIncomingCall = !call.isOutgoing,
isVideoCall = call.isVideoCall,
mode = null
).let {
startActivity(it)
}
}
}
}

View File

@ -30,6 +30,7 @@ import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.MatrixPatterns
import im.vector.matrix.android.api.NoOpMatrixCallback
import im.vector.matrix.android.api.extensions.orFalse
import im.vector.matrix.android.api.query.QueryStringValue
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.EventType
@ -66,6 +67,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.WebRtcPeerConnectionManager
import im.vector.riotx.features.command.CommandParser
import im.vector.riotx.features.command.ParsedCommand
import im.vector.riotx.features.crypto.verification.SupportedVerificationMethodsProvider
@ -96,7 +98,8 @@ class RoomDetailViewModel @AssistedInject constructor(
private val rainbowGenerator: RainbowGenerator,
private val session: Session,
private val supportedVerificationMethodsProvider: SupportedVerificationMethodsProvider,
private val stickerPickerActionHandler: StickerPickerActionHandler
private val stickerPickerActionHandler: StickerPickerActionHandler,
private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager
) : VectorViewModel<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(initialState), Timeline.Listener {
private val room = session.getRoom(initialState.roomId)!!
@ -121,8 +124,7 @@ class RoomDetailViewModel @AssistedInject constructor(
}
private var timelineEvents = PublishRelay.create<List<TimelineEvent>>()
var timeline = room.createTimeline(eventId, timelineSettings)
private set
val timeline = room.createTimeline(eventId, timelineSettings)
// Slot to keep a pending action during permission request
var pendingAction: RoomDetailAction? = null
@ -135,6 +137,7 @@ class RoomDetailViewModel @AssistedInject constructor(
private var trackUnreadMessages = AtomicBoolean(false)
private var mostRecentDisplayedEvent: TimelineEvent? = null
private var canDoCall = false
@AssistedInject.Factory
interface Factory {
@ -213,6 +216,8 @@ class RoomDetailViewModel @AssistedInject constructor(
}
}
fun getOtherUserIds() = room.roomSummary()?.otherMemberIds
override fun handle(action: RoomDetailAction) {
when (action) {
is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action)
@ -252,13 +257,25 @@ class RoomDetailViewModel @AssistedInject constructor(
is RoomDetailAction.ResumeVerification -> handleResumeRequestVerification(action)
is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action)
is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment()
}
is RoomDetailAction.StartCall -> handleStartCall(action)
is RoomDetailAction.EndCall -> handleEndCall()
}.exhaustive
}
private fun handleSendSticker(action: RoomDetailAction.SendSticker) {
room.sendEvent(EventType.STICKER, action.stickerContent.toContent())
}
private fun handleStartCall(action: RoomDetailAction.StartCall) {
room.roomSummary()?.otherMemberIds?.firstOrNull()?.let {
webRtcPeerConnectionManager.startOutgoingCall(room.roomId, it, action.isVideo)
}
}
private fun handleEndCall() {
webRtcPeerConnectionManager.endCall()
}
private fun handleSelectStickerAttachment() {
viewModelScope.launch {
val viewEvent = stickerPickerActionHandler.handle()
@ -367,11 +384,14 @@ class RoomDetailViewModel @AssistedInject constructor(
fun isMenuItemVisible(@IdRes itemId: Int) = when (itemId) {
R.id.clear_message_queue ->
/* For now always disable on production, worker cancellation is not working properly */
// For now always disable when not in developer mode, worker cancellation is not working properly
timeline.pendingEventCount() > 0 && vectorPreferences.developerMode()
R.id.resend_all -> timeline.failedToDeliverEventCount() > 0
R.id.clear_all -> timeline.failedToDeliverEventCount() > 0
R.id.open_matrix_apps -> true
R.id.voice_call,
R.id.video_call -> room.canStartCall() && webRtcPeerConnectionManager.currentCall == null
R.id.hangup_call -> webRtcPeerConnectionManager.currentCall != null
else -> false
}
@ -1000,6 +1020,8 @@ class RoomDetailViewModel @AssistedInject constructor(
val typingRoomMembers =
typingHelper.toTypingRoomMembers(async.invoke()?.typingRoomMemberIds.orEmpty(), room)
canDoCall = async.invoke()?.canStartCall.orFalse()
copy(
asyncRoomSummary = async,
typingRoomMembers = typingRoomMembers,

View File

@ -184,6 +184,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
EventType.STATE_ROOM_CANONICAL_ALIAS,
EventType.STATE_ROOM_HISTORY_VISIBILITY,
EventType.CALL_INVITE,
EventType.CALL_CANDIDATES,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> {
noticeEventFormatter.format(timelineEvent)

View File

@ -80,12 +80,15 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
EventType.KEY_VERIFICATION_START,
EventType.KEY_VERIFICATION_KEY,
EventType.KEY_VERIFICATION_READY,
EventType.KEY_VERIFICATION_MAC -> {
EventType.KEY_VERIFICATION_MAC,
EventType.CALL_CANDIDATES -> {
// TODO These are not filtered out by timeline when encrypted
// For now manually ignore
if (userPreferencesProvider.shouldShowHiddenEvents()) {
noticeItemFactory.create(event, highlight, callback)
} else null
} else {
null
}
}
EventType.KEY_VERIFICATION_CANCEL,
EventType.KEY_VERIFICATION_DONE -> {

View File

@ -29,7 +29,6 @@ import me.gujun.android.span.span
import javax.inject.Inject
class DisplayableEventFormatter @Inject constructor(
// private val sessionHolder: ActiveSessionHolder,
private val stringProvider: StringProvider,
private val colorProvider: ColorProvider,
private val noticeEventFormatter: NoticeEventFormatter

View File

@ -68,6 +68,7 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
EventType.STATE_ROOM_POWER_LEVELS -> formatRoomPowerLevels(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
EventType.CALL_INVITE,
EventType.CALL_CANDIDATES,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> formatCallEvent(type, timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
EventType.MESSAGE,
@ -237,9 +238,9 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
private fun formatCallEvent(type: String, event: Event, senderName: String?): CharSequence? {
return when (type) {
EventType.CALL_INVITE -> {
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)
@ -254,19 +255,25 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active
}
}
}
EventType.CALL_ANSWER ->
EventType.CALL_ANSWER ->
if (event.isSentByCurrentUser()) {
sp.getString(R.string.notice_answered_call_by_you)
} else {
sp.getString(R.string.notice_answered_call, senderName)
}
EventType.CALL_HANGUP ->
EventType.CALL_HANGUP ->
if (event.isSentByCurrentUser()) {
sp.getString(R.string.notice_ended_call_by_you)
} else {
sp.getString(R.string.notice_ended_call, senderName)
}
else -> null
EventType.CALL_CANDIDATES ->
if (event.isSentByCurrentUser()) {
sp.getString(R.string.notice_call_candidates_by_you)
} else {
sp.getString(R.string.notice_call_candidates, senderName)
}
else -> null
}
}

View File

@ -48,6 +48,7 @@ import im.vector.riotx.core.extensions.configureAndStart
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.call.WebRtcPeerConnectionManager
import im.vector.riotx.features.notifications.PushRuleTriggerListener
import im.vector.riotx.features.session.SessionListener
import im.vector.riotx.features.signout.soft.SoftLogoutActivity
@ -66,7 +67,8 @@ class LoginViewModel @AssistedInject constructor(
private val homeServerConnectionConfigFactory: HomeServerConnectionConfigFactory,
private val sessionListener: SessionListener,
private val reAuthHelper: ReAuthHelper,
private val stringProvider: StringProvider)
private val stringProvider: StringProvider,
private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager)
: VectorViewModel<LoginViewState, LoginAction, LoginViewEvents>(initialState) {
@AssistedInject.Factory
@ -613,6 +615,7 @@ class LoginViewModel @AssistedInject constructor(
private fun onSessionCreated(session: Session) {
activeSessionHolder.setActiveSession(session)
session.configureAndStart(applicationContext, pushRuleTriggerListener, sessionListener)
session.callSignalingService().addCallListener(webRtcPeerConnectionManager)
setState {
copy(
asyncLoginAction = Success(Unit)

View File

@ -35,11 +35,14 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import androidx.core.app.TaskStackBuilder
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.fragment.app.Fragment
import im.vector.riotx.BuildConfig
import im.vector.riotx.R
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.core.utils.startNotificationChannelSettingsIntent
import im.vector.riotx.features.call.VectorCallActivity
import im.vector.riotx.features.call.service.CallHeadsUpActionReceiver
import im.vector.riotx.features.home.HomeActivity
import im.vector.riotx.features.home.room.detail.RoomDetailActivity
import im.vector.riotx.features.home.room.detail.RoomDetailArgs
@ -263,13 +266,13 @@ class NotificationUtils @Inject constructor(private val context: Context,
*/
@SuppressLint("NewApi")
fun buildIncomingCallNotification(isVideo: Boolean,
roomName: String,
matrixId: String,
otherUserId: String,
roomId: String,
callId: String): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
val builder = NotificationCompat.Builder(context, CALL_NOTIFICATION_CHANNEL_ID)
.setContentTitle(ensureTitleNotEmpty(roomName))
.setContentTitle(ensureTitleNotEmpty(otherUserId))
.apply {
if (isVideo) {
setContentText(stringProvider.getString(R.string.incoming_video_call))
@ -280,28 +283,124 @@ class NotificationUtils @Inject constructor(private val context: Context,
.setSmallIcon(R.drawable.incoming_call_notification_transparent)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setLights(accentColor, 500, 500)
.setOngoing(true)
// Compat: Display the incoming call notification on the lock screen
builder.priority = NotificationCompat.PRIORITY_MAX
builder.priority = NotificationCompat.PRIORITY_HIGH
// clear the activity stack to home activity
val intent = Intent(context, HomeActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
// TODO .putExtra(VectorHomeActivity.EXTRA_CALL_SESSION_ID, matrixId)
// TODO .putExtra(VectorHomeActivity.EXTRA_CALL_ID, callId)
// Recreate the back stack
val stackBuilder = TaskStackBuilder.create(context)
.addParentStack(HomeActivity::class.java)
.addNextIntent(intent)
// android 4.3 issue
// use a generator for the private requestCode.
// When using 0, the intent is not created/launched when the user taps on the notification.
//
val pendingIntent = stackBuilder.getPendingIntent(Random.nextInt(1000), PendingIntent.FLAG_UPDATE_CURRENT)
val requestId = Random.nextInt(1000)
// val pendingIntent = stackBuilder.getPendingIntent(requestId, PendingIntent.FLAG_UPDATE_CURRENT)
builder.setContentIntent(pendingIntent)
val contentIntent = VectorCallActivity.newIntent(
context = context,
callId = callId,
roomId = roomId,
otherUserId = otherUserId,
isIncomingCall = true,
isVideoCall = isVideo,
mode = VectorCallActivity.INCOMING_RINGING
).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
data = Uri.parse("foobar://$callId")
}
val contentPendingIntent = PendingIntent.getActivity(context, System.currentTimeMillis().toInt(), contentIntent, 0)
val answerCallPendingIntent = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(Intent(context, HomeActivity::class.java))
.addNextIntent(VectorCallActivity.newIntent(
context = context,
callId = callId,
roomId = roomId,
otherUserId = otherUserId,
isIncomingCall = true,
isVideoCall = isVideo,
mode = VectorCallActivity.INCOMING_ACCEPT)
)
.getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT)
val rejectCallActionReceiver = Intent(context, CallHeadsUpActionReceiver::class.java).apply {
putExtra(CallHeadsUpActionReceiver.EXTRA_CALL_ACTION_KEY, CallHeadsUpActionReceiver.CALL_ACTION_REJECT)
}
// val answerCallPendingIntent = PendingIntent.getBroadcast(context, requestId, answerCallActionReceiver, PendingIntent.FLAG_UPDATE_CURRENT)
val rejectCallPendingIntent = PendingIntent.getBroadcast(
context,
requestId + 1,
rejectCallActionReceiver,
PendingIntent.FLAG_UPDATE_CURRENT
)
builder.addAction(
NotificationCompat.Action(
R.drawable.ic_call,
// IconCompat.createWithResource(applicationContext, R.drawable.ic_call)
// .setTint(ContextCompat.getColor(applicationContext, R.color.riotx_positive_accent)),
context.getString(R.string.call_notification_answer),
answerCallPendingIntent
)
)
builder.addAction(
NotificationCompat.Action(
IconCompat.createWithResource(context, R.drawable.ic_call_end).setTint(ContextCompat.getColor(context, R.color.riotx_notice)),
context.getString(R.string.call_notification_reject),
rejectCallPendingIntent)
)
builder.setFullScreenIntent(contentPendingIntent, true)
return builder.build()
}
fun buildOutgoingRingingCallNotification(isVideo: Boolean,
otherUserId: String,
roomId: String,
callId: String): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
val builder = NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID)
.setContentTitle(ensureTitleNotEmpty(otherUserId))
.apply {
setContentText(stringProvider.getString(R.string.call_ring))
}
.setSmallIcon(R.drawable.incoming_call_notification_transparent)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setLights(accentColor, 500, 500)
.setOngoing(true)
val requestId = Random.nextInt(1000)
val contentIntent = VectorCallActivity.newIntent(
context = context,
callId = callId,
roomId = roomId,
otherUserId = otherUserId,
isIncomingCall = true,
isVideoCall = isVideo,
mode = null).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
data = Uri.parse("foobar://$callId")
}
val contentPendingIntent = PendingIntent.getActivity(context, System.currentTimeMillis().toInt(), contentIntent, 0)
val rejectCallActionReceiver = Intent(context, CallHeadsUpActionReceiver::class.java).apply {
putExtra(CallHeadsUpActionReceiver.EXTRA_CALL_ACTION_KEY, CallHeadsUpActionReceiver.CALL_ACTION_REJECT)
}
val rejectCallPendingIntent = PendingIntent.getBroadcast(
context,
requestId + 1,
rejectCallActionReceiver,
PendingIntent.FLAG_UPDATE_CURRENT
)
builder.addAction(
NotificationCompat.Action(
IconCompat.createWithResource(context, R.drawable.ic_call_end).setTint(ContextCompat.getColor(context, R.color.riotx_notice)),
context.getString(R.string.call_notification_hangup),
rejectCallPendingIntent)
)
builder.setContentIntent(contentPendingIntent)
return builder.build()
}
@ -321,8 +420,9 @@ class NotificationUtils @Inject constructor(private val context: Context,
roomName: String,
roomId: String,
matrixId: String,
callId: String): Notification {
val builder = NotificationCompat.Builder(context, CALL_NOTIFICATION_CHANNEL_ID)
callId: String,
fromBg: Boolean = false): Notification {
val builder = NotificationCompat.Builder(context, if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID)
.setContentTitle(ensureTitleNotEmpty(roomName))
.apply {
if (isVideo) {
@ -334,31 +434,37 @@ class NotificationUtils @Inject constructor(private val context: Context,
.setSmallIcon(R.drawable.incoming_call_notification_transparent)
.setCategory(NotificationCompat.CATEGORY_CALL)
// Display the pending call notification on the lock screen
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
builder.priority = NotificationCompat.PRIORITY_MAX
if (fromBg) {
builder.priority = NotificationCompat.PRIORITY_LOW
builder.setOngoing(true)
}
/* TODO
// Build the pending intent for when the notification is clicked
val roomIntent = Intent(context, VectorRoomActivity::class.java)
.putExtra(VectorRoomActivity.EXTRA_ROOM_ID, roomId)
.putExtra(VectorRoomActivity.EXTRA_MATRIX_ID, matrixId)
.putExtra(VectorRoomActivity.EXTRA_START_CALL_ID, callId)
val rejectCallActionReceiver = Intent(context, CallHeadsUpActionReceiver::class.java).apply {
data = Uri.parse("mxcall://end?$callId")
putExtra(CallHeadsUpActionReceiver.EXTRA_CALL_ACTION_KEY, CallHeadsUpActionReceiver.CALL_ACTION_REJECT)
}
// Recreate the back stack
val stackBuilder = TaskStackBuilder.create(context)
.addParentStack(VectorRoomActivity::class.java)
.addNextIntent(roomIntent)
val rejectCallPendingIntent = PendingIntent.getBroadcast(
context,
System.currentTimeMillis().toInt(),
rejectCallActionReceiver,
PendingIntent.FLAG_UPDATE_CURRENT
)
// android 4.3 issue
// use a generator for the private requestCode.
// When using 0, the intent is not created/launched when the user taps on the notification.
//
val pendingIntent = stackBuilder.getPendingIntent(Random().nextInt(1000), PendingIntent.FLAG_UPDATE_CURRENT)
builder.addAction(
NotificationCompat.Action(
IconCompat.createWithResource(context, R.drawable.ic_call_end).setTint(ContextCompat.getColor(context, R.color.riotx_notice)),
context.getString(R.string.call_notification_hangup),
rejectCallPendingIntent)
)
builder.setContentIntent(pendingIntent)
*/
val contentPendingIntent = TaskStackBuilder.create(context)
.addNextIntentWithParentStack(Intent(context, HomeActivity::class.java))
// TODO other userId
.addNextIntent(VectorCallActivity.newIntent(context, callId, roomId, "otherUserId", true, isVideo, null))
.getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT)
builder.setContentIntent(contentPendingIntent)
return builder.build()
}

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,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M5.2415,18.995L3.2268,16.7576C2.8469,16.3391 2.6614,15.7796 2.7158,15.2164C2.7702,14.6532 3.0596,14.1387 3.5127,13.799C6.0368,11.9778 8.9518,10.773 12.0235,10.2815C14.8601,9.8008 17.7666,9.9501 20.5365,10.719C23.5514,11.5274 26.332,13.0348 28.6531,15.1192C29.0664,15.5022 29.2993,16.0416 29.2949,16.6055C29.2905,17.1694 29.0492,17.706 28.6301,18.0841L26.3882,20.1028C25.6447,20.7857 24.5111,20.8127 23.7386,20.1659C22.9992,19.535 22.1907,18.99 21.3284,18.5412C20.6322,18.181 20.2102,17.4482 20.2477,16.6648L20.3438,14.863C17.5987,13.9538 14.6576,13.8027 11.8307,14.4256L11.7346,16.2273C11.6884,17.0104 11.1907,17.6959 10.46,17.9828C9.5547,18.3408 8.6926,18.8 7.8901,19.3516C7.0434,19.9224 5.9044,19.7691 5.2415,18.995Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,11 @@
<vector android:autoMirrored="true" android:height="40dp"
android:viewportHeight="40" android:viewportWidth="40"
android:width="40dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:fillType="evenOdd"
android:pathData="M20,20m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0"
android:strokeColor="#00000000" android:strokeWidth="1"/>
<path android:fillColor="#FFFFFF" android:fillType="nonZero"
android:pathData="M26,14.75L23.6225,14.75L22.25,13.25L17.75,13.25L16.3775,14.75L14,14.75C13.175,14.75 12.5,15.425 12.5,16.25L12.5,25.25C12.5,26.075 13.175,26.75 14,26.75L26,26.75C26.825,26.75 27.5,26.075 27.5,25.25L27.5,16.25C27.5,15.425 26.825,14.75 26,14.75ZM20,24.5C17.93,24.5 16.25,22.82 16.25,20.75L14.75,20.75L16.625,18.875L18.5,20.75L17,20.75C17,22.4075 18.3425,23.75 20,23.75C20.435,23.75 20.8475,23.6525 21.215,23.4875L21.77,24.0425C21.2375,24.32 20.645,24.5 20,24.5ZM23.375,22.625L21.5,20.75L23,20.75C23,19.0925 21.6575,17.75 20,17.75C19.565,17.75 19.1525,17.8475 18.785,18.0125L18.23,17.465C18.7625,17.18 19.355,17 20,17C22.07,17 23.75,18.68 23.75,20.75L25.25,20.75L23.375,22.625Z"
android:strokeColor="#00000000" android:strokeWidth="1"/>
</vector>

View File

@ -0,0 +1,7 @@
<vector android:autoMirrored="true" android:height="18dp"
android:viewportHeight="18" android:viewportWidth="18"
android:width="18dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFF" android:fillType="nonZero"
android:pathData="M15,3.75L12.6225,3.75L11.25,2.25L6.75,2.25L5.3775,3.75L3,3.75C2.175,3.75 1.5,4.425 1.5,5.25L1.5,14.25C1.5,15.075 2.175,15.75 3,15.75L15,15.75C15.825,15.75 16.5,15.075 16.5,14.25L16.5,5.25C16.5,4.425 15.825,3.75 15,3.75ZM9,13.5C6.93,13.5 5.25,11.82 5.25,9.75L3.75,9.75L5.625,7.875L7.5,9.75L6,9.75C6,11.4075 7.3425,12.75 9,12.75C9.435,12.75 9.8475,12.6525 10.215,12.4875L10.77,13.0425C10.2375,13.32 9.645,13.5 9,13.5ZM12.375,11.625L10.5,9.75L12,9.75C12,8.0925 10.6575,6.75 9,6.75C8.565,6.75 8.1525,6.8475 7.785,7.0125L7.23,6.465C7.7625,6.18 8.355,6 9,6C11.07,6 12.75,7.68 12.75,9.75L14.25,9.75L12.375,11.625Z"
android:strokeColor="#00000000" android:strokeWidth="1"/>
</vector>

View File

@ -0,0 +1,11 @@
<vector android:autoMirrored="true" android:height="40dp"
android:viewportHeight="40" android:viewportWidth="40"
android:width="40dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:fillType="evenOdd"
android:pathData="M20,20m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0"
android:strokeColor="#00000000" android:strokeWidth="1"/>
<path android:fillColor="#FFFFFF" android:fillType="nonZero"
android:pathData="M25.25,19.25L23.975,19.25C23.975,19.805 23.855,20.3225 23.6525,20.7875L24.575,21.71C24.995,20.975 25.25,20.1425 25.25,19.25L25.25,19.25ZM22.235,19.3775C22.235,19.3325 22.25,19.295 22.25,19.25L22.25,14.75C22.25,13.505 21.245,12.5 20,12.5C18.755,12.5 17.75,13.505 17.75,14.75L17.75,14.885L22.235,19.3775ZM14.2025,13.25L13.25,14.2025L17.7575,18.71L17.7575,19.25C17.7575,20.495 18.755,21.5 20,21.5C20.165,21.5 20.33,21.4775 20.4875,21.44L21.7325,22.685C21.2,22.9325 20.6075,23.075 20,23.075C17.93,23.075 16.025,21.5 16.025,19.25L14.75,19.25C14.75,21.8075 16.79,23.9225 19.25,24.29L19.25,26.75L20.75,26.75L20.75,24.29C21.4325,24.1925 22.0775,23.9525 22.655,23.615L25.7975,26.75L26.75,25.7975L14.2025,13.25Z"
android:strokeColor="#00000000" android:strokeWidth="1"/>
</vector>

View File

@ -0,0 +1,7 @@
<vector android:autoMirrored="true" android:height="18dp"
android:viewportHeight="18" android:viewportWidth="18"
android:width="18dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFF" android:fillType="nonZero"
android:pathData="M14.25,8.25L12.975,8.25C12.975,8.805 12.855,9.3225 12.6525,9.7875L13.575,10.71C13.995,9.975 14.25,9.1425 14.25,8.25L14.25,8.25ZM11.235,8.3775C11.235,8.3325 11.25,8.295 11.25,8.25L11.25,3.75C11.25,2.505 10.245,1.5 9,1.5C7.755,1.5 6.75,2.505 6.75,3.75L6.75,3.885L11.235,8.3775ZM3.2025,2.25L2.25,3.2025L6.7575,7.71L6.7575,8.25C6.7575,9.495 7.755,10.5 9,10.5C9.165,10.5 9.33,10.4775 9.4875,10.44L10.7325,11.685C10.2,11.9325 9.6075,12.075 9,12.075C6.93,12.075 5.025,10.5 5.025,8.25L3.75,8.25C3.75,10.8075 5.79,12.9225 8.25,13.29L8.25,15.75L9.75,15.75L9.75,13.29C10.4325,13.1925 11.0775,12.9525 11.655,12.615L14.7975,15.75L15.75,14.7975L3.2025,2.25Z"
android:strokeColor="#00000000" android:strokeWidth="1"/>
</vector>

View File

@ -0,0 +1,11 @@
<vector android:autoMirrored="true" android:height="40dp"
android:viewportHeight="40" android:viewportWidth="40"
android:width="40dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:fillType="evenOdd"
android:pathData="M20,20m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0"
android:strokeColor="#00000000" android:strokeWidth="1"/>
<path android:fillColor="#FFFFFF" android:fillType="nonZero"
android:pathData="M13.25,17.75L13.25,22.25L16.25,22.25L20,26L20,14L16.25,17.75L13.25,17.75ZM23.375,20C23.375,18.6725 22.61,17.5325 21.5,16.9775L21.5,23.015C22.61,22.4675 23.375,21.3275 23.375,20ZM21.5,13.4225L21.5,14.9675C23.6675,15.6125 25.25,17.6225 25.25,20C25.25,22.3775 23.6675,24.3875 21.5,25.0325L21.5,26.5775C24.5075,25.895 26.75,23.21 26.75,20C26.75,16.79 24.5075,14.105 21.5,13.4225Z"
android:strokeColor="#00000000" android:strokeWidth="1"/>
</vector>

View File

@ -0,0 +1,7 @@
<vector android:autoMirrored="true" android:height="18dp"
android:viewportHeight="18" android:viewportWidth="18"
android:width="18dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFF" android:fillType="nonZero"
android:pathData="M2.25,6.75L2.25,11.25L5.25,11.25L9,15L9,3L5.25,6.75L2.25,6.75ZM12.375,9C12.375,7.6725 11.61,6.5325 10.5,5.9775L10.5,12.015C11.61,11.4675 12.375,10.3275 12.375,9ZM10.5,2.4225L10.5,3.9675C12.6675,4.6125 14.25,6.6225 14.25,9C14.25,11.3775 12.6675,13.3875 10.5,14.0325L10.5,15.5775C13.5075,14.895 15.75,12.21 15.75,9C15.75,5.79 13.5075,3.105 10.5,2.4225Z"
android:strokeColor="#00000000" android:strokeWidth="1"/>
</vector>

View File

@ -0,0 +1,11 @@
<vector android:autoMirrored="true" android:height="40dp"
android:viewportHeight="40" android:viewportWidth="40"
android:width="40dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="0.2" android:fillColor="#000000"
android:fillType="evenOdd"
android:pathData="M20,20m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0"
android:strokeColor="#00000000" android:strokeWidth="1"/>
<path android:fillColor="#FFFFFF" android:fillType="nonZero"
android:pathData="M26.75,15.875L23.75,18.875L23.75,16.25C23.75,15.8375 23.4125,15.5 23,15.5L18.365,15.5L26.75,23.885L26.75,15.875ZM13.4525,12.5L12.5,13.4525L14.5475,15.5L14,15.5C13.5875,15.5 13.25,15.8375 13.25,16.25L13.25,23.75C13.25,24.1625 13.5875,24.5 14,24.5L23,24.5C23.1575,24.5 23.2925,24.44 23.405,24.365L25.7975,26.75L26.75,25.7975L13.4525,12.5Z"
android:strokeColor="#00000000" android:strokeWidth="1"/>
</vector>

View File

@ -0,0 +1,7 @@
<vector android:autoMirrored="true" android:height="18dp"
android:viewportHeight="18" android:viewportWidth="18"
android:width="18dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFF" android:fillType="nonZero"
android:pathData="M15.75,4.875L12.75,7.875L12.75,5.25C12.75,4.8375 12.4125,4.5 12,4.5L7.365,4.5L15.75,12.885L15.75,4.875ZM2.4525,1.5L1.5,2.4525L3.5475,4.5L3,4.5C2.5875,4.5 2.25,4.8375 2.25,5.25L2.25,12.75C2.25,13.1625 2.5875,13.5 3,13.5L12,13.5C12.1575,13.5 12.2925,13.44 12.405,13.365L14.7975,15.75L15.75,14.7975L2.4525,1.5Z"
android:strokeColor="#00000000" android:strokeWidth="1"/>
</vector>

View File

@ -0,0 +1,9 @@
<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="M19,3H5C3.89,3 3,3.9 3,5V19C3,20.1 3.89,21 5,21H19C20.1,21 21,20.1 21,19V5C21,3.9 20.1,3 19,3ZM11,15H9.5V13H7.5V15H6V9H7.5V11.5H9.5V9H11V15ZM13,9H17C17.55,9 18,9.45 18,10V14C18,14.55 17.55,15 17,15H13V9ZM14.5,13.5H16.5V10.5H14.5V13.5Z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,9 @@
<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="M17.5,15V13H18.6L19.5,15H21L20.1,12.9C20.6,12.7 21,12.1 21,11.5V10.5C21,9.7 20.3,9 19.5,9H16V13.9L17.1,15H17.5ZM17.5,10.5H19.5V11.5H17.5V10.5ZM13,10.5V10.9L14.5,12.4V10.5C14.5,9.7 13.8,9 13,9H11.1L12.6,10.5H13ZM9.5,9.5L2.5,2.5L1.4,3.5L6.9,9H6.5V11H4.5V9H3V15H4.5V12.5H6.5V15H8V10.1L9.5,11.6V15H12.9L20.5,22.6L21.6,21.5L9.5,9.5Z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,41 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="25dp"
android:viewportWidth="24"
android:viewportHeight="25">
<path
android:pathData="M1,2L23,24"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
<path
android:pathData="M15,10.34V5C15.0007,4.256 14.725,3.5383 14.2264,2.9862C13.7277,2.4341 13.0417,2.0869 12.3015,2.0122C11.5613,1.9374 10.8197,2.1403 10.2207,2.5816C9.6217,3.0228 9.208,3.6709 9.06,4.4M9,10V13C9.0005,13.593 9.1768,14.1725 9.5064,14.6653C9.8361,15.1582 10.3045,15.5423 10.8523,15.7691C11.4002,15.996 12.0029,16.0554 12.5845,15.9399C13.1661,15.8243 13.7005,15.539 14.12,15.12L9,10Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
<path
android:pathData="M16.9999,17.95C16.0237,18.9464 14.7721,19.6285 13.4056,19.9086C12.039,20.1887 10.62,20.0542 9.3304,19.5223C8.0409,18.9903 6.9397,18.0853 6.1681,16.9232C5.3965,15.761 4.9897,14.3949 4.9999,13V11M18.9999,11V13C18.9996,13.4124 18.9628,13.824 18.8899,14.23"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
<path
android:pathData="M12,20V24"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
<path
android:pathData="M8,24H16"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,10 @@
<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="M12,0C10.9391,0 9.9217,0.4214 9.1716,1.1716C8.4214,1.9217 8,2.9391 8,4V12C8,13.0609 8.4214,14.0783 9.1716,14.8284C9.9217,15.5786 10.9391,16 12,16C13.0609,16 14.0783,15.5786 14.8284,14.8284C15.5786,14.0783 16,13.0609 16,12V4C16,2.9391 15.5786,1.9217 14.8284,1.1716C14.0783,0.4214 13.0609,0 12,0ZM10.5858,2.5858C10.9609,2.2107 11.4696,2 12,2C12.5304,2 13.0391,2.2107 13.4142,2.5858C13.7893,2.9609 14,3.4696 14,4V12C14,12.5304 13.7893,13.0391 13.4142,13.4142C13.0391,13.7893 12.5304,14 12,14C11.4696,14 10.9609,13.7893 10.5858,13.4142C10.2107,13.0391 10,12.5304 10,12V4C10,3.4696 10.2107,2.9609 10.5858,2.5858ZM6,10C6,9.4477 5.5523,9 5,9C4.4477,9 4,9.4477 4,10V12C4,14.1217 4.8429,16.1566 6.3432,17.6569C7.6058,18.9195 9.247,19.7165 11,19.9373V22H8C7.4477,22 7,22.4477 7,23C7,23.5523 7.4477,24 8,24H12H16C16.5523,24 17,23.5523 17,23C17,22.4477 16.5523,22 16,22H13V19.9373C14.753,19.7165 16.3942,18.9195 17.6569,17.6569C19.1571,16.1566 20,14.1217 20,12V10C20,9.4477 19.5523,9 19,9C18.4477,9 18,9.4477 18,10V12C18,13.5913 17.3679,15.1174 16.2426,16.2426C15.1174,17.3679 13.5913,18 12,18C10.4087,18 8.8826,17.3679 7.7574,16.2426C6.6321,15.1174 6,13.5913 6,12V10Z"
android:fillColor="#2E2F32"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,18 @@
<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="M12,14C13.1046,14 14,13.1046 14,12C14,10.8954 13.1046,10 12,10C10.8954,10 10,10.8954 10,12C10,13.1046 10.8954,14 12,14Z"
android:fillColor="#2E2F32"
android:fillType="evenOdd"/>
<path
android:pathData="M12,6C13.1046,6 14,5.1046 14,4C14,2.8954 13.1046,2 12,2C10.8954,2 10,2.8954 10,4C10,5.1046 10.8954,6 12,6Z"
android:fillColor="#2E2F32"
android:fillType="evenOdd"/>
<path
android:pathData="M12,22C13.1046,22 14,21.1046 14,20C14,18.8954 13.1046,18 12,18C10.8954,18 10,18.8954 10,20C10,21.1046 10.8954,22 12,22Z"
android:fillColor="#2E2F32"
android:fillType="evenOdd"/>
</vector>

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,22 @@
<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="M23,7L16,12L23,17V7Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"/>
<path
android:pathData="M1,7C1,5.8954 1.8954,5 3,5H14C15.1046,5 16,5.8954 16,7V17C16,18.1046 15.1046,19 14,19H3C1.8954,19 1,18.1046 1,17V7Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,42 @@
<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="M4,9L1,12L4,15"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"/>
<path
android:pathData="M10,15L13,12L10,9"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"/>
<path
android:pathData="M13,12H2"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"/>
<path
android:pathData="M23,7L16,12L23,17V7Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"/>
<path
android:pathData="M1,7.5V7C1,5.8954 1.8954,5 3,5H14C15.1046,5 16,5.8954 16,7V17C16,18.1046 15.1046,19 14,19H3C1.8954,19 1,18.1046 1,17V16.5"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,20 @@
<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="M10.66,5H14C14.5304,5 15.0391,5.2107 15.4142,5.5858C15.7893,5.9609 16,6.4696 16,7V10.34L17,11.34L23,7V17M16,16V17C16,17.5304 15.7893,18.0391 15.4142,18.4142C15.0391,18.7893 14.5304,19 14,19H3C2.4696,19 1.9609,18.7893 1.5858,18.4142C1.2107,18.0391 1,17.5304 1,17V7C1,6.4696 1.2107,5.9609 1.5858,5.5858C1.9609,5.2107 2.4696,5 3,5H5L16,16Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"/>
<path
android:pathData="M1,1L23,23"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M17,10.5V7c0,-0.55 -0.45,-1 -1,-1H4c-0.55,0 -1,0.45 -1,1v10c0,0.55 0.45,1 1,1h12c0.55,0 1,-0.45 1,-1v-3.5l4,4v-11l-4,4z"/>
</vector>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size
android:width="40dp"
android:height="40dp" />
<solid android:color="@color/riotx_destructive_accent" />
</shape>

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