Merge branch 'develop' into feature/fix_widget

This commit is contained in:
ganfra 2020-06-24 15:14:26 +02:00 committed by GitHub
commit 554c37febe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
150 changed files with 6857 additions and 292 deletions

View file

@ -7,6 +7,7 @@
<w>ciphertext</w>
<w>coroutine</w>
<w>decryptor</w>
<w>displayname</w>
<w>emoji</w>
<w>emojis</w>
<w>fdroid</w>

View file

@ -2,7 +2,8 @@ Changes in RiotX 0.23.0 (2020-XX-XX)
===================================================
Features ✨:
-
- Call with WebRTC support (##611)
- Add capability to change the display name (#1529)
Improvements 🙌:
- "Add Matrix app" menu is now always visible (#1495)
@ -10,6 +11,7 @@ Improvements 🙌:
Bugfix 🐛:
- Fix dark theme issue on login screen (#1097)
- Incomplete predicate in RealmCryptoStore#getOutgoingRoomKeyRequest (#1519)
- Use vendor prefix for non merged MSC (#1537)
Translations 🗣:
-
@ -22,7 +24,10 @@ Build 🧱:
- SDK is now API level 21 minimum, and so RiotX (#405)
Other changes:
-
- Use `retrofit2.Call.awaitResponse` extension provided by Retrofit 2. (#1526)
- Fix minor typo in contribution guide (#1512)
- Fix self-assignment of callback in `DefaultRoomPushRuleService#setRoomNotificationState` (#1520)
- Random housekeeping clean-ups indicated by Lint (#1520, #1541)
Changes in RiotX 0.22.0 (2020-06-15)
===================================================

View file

@ -31,7 +31,7 @@ To create a new screen:
- Then right click on the package, and select `New/New Vector/RiotX Feature`.
- Follow the Wizard, especially replace `Main` by something more relevant to your feature.
- Click on `Finish`.
- Remainning steps are described as TODO in the generated files, or will be pointed out by the compilator, or at runtime :)
- Remaining steps are described as TODO in the generated files, or will be pointed out by the compilator, or at runtime :)
Note that if the templates are modified, the only things to do is to restart Android Studio for the change to take effect.

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

@ -241,14 +241,14 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) {
val eventWireContent = event.content.toContent()
assertNotNull(eventWireContent)
assertNull(eventWireContent.get("body"))
assertEquals(MXCRYPTO_ALGORITHM_MEGOLM, eventWireContent.get("algorithm"))
assertNull(eventWireContent["body"])
assertEquals(MXCRYPTO_ALGORITHM_MEGOLM, eventWireContent["algorithm"])
assertNotNull(eventWireContent.get("ciphertext"))
assertNotNull(eventWireContent.get("session_id"))
assertNotNull(eventWireContent.get("sender_key"))
assertNotNull(eventWireContent["ciphertext"])
assertNotNull(eventWireContent["session_id"])
assertNotNull(eventWireContent["sender_key"])
assertEquals(senderSession.sessionParams.deviceId, eventWireContent.get("device_id"))
assertEquals(senderSession.sessionParams.deviceId, eventWireContent["device_id"])
assertNotNull(event.eventId)
assertEquals(roomId, event.roomId)
@ -257,7 +257,7 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) {
val eventContent = event.toContent()
assertNotNull(eventContent)
assertEquals(clearMessage, eventContent.get("body"))
assertEquals(clearMessage, eventContent["body"])
assertEquals(senderSession.myUserId, event.senderId)
}

View file

@ -252,7 +252,7 @@ class KeyShareTests : InstrumentedTest {
}
})
val txId: String = "m.testVerif12"
val txId = "m.testVerif12"
aliceVerificationService2.beginKeyVerification(VerificationMethod.SAS, aliceSession1.myUserId, aliceSession1.sessionParams.deviceId
?: "", txId)

View file

@ -144,7 +144,7 @@ class QuadSTests : InstrumentedTest {
val secretAccountData = assertAccountData(aliceSession, "secret.of.life")
val encryptedContent = secretAccountData.content.get("encrypted") as? Map<*, *>
val encryptedContent = secretAccountData.content["encrypted"] as? Map<*, *>
assertNotNull("Element should be encrypted", encryptedContent)
assertNotNull("Secret should be encrypted with default key", encryptedContent?.get(keyId))

View file

@ -69,7 +69,7 @@ data class HomeServerConnectionConfig(
*/
fun withHomeServerUri(hsUri: Uri): Builder {
if (hsUri.scheme != "http" && hsUri.scheme != "https") {
throw RuntimeException("Invalid home server URI: " + hsUri)
throw RuntimeException("Invalid home server URI: $hsUri")
}
// ensure trailing /
val hsString = hsUri.toString().ensureTrailingSlash()

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

@ -87,14 +87,13 @@ class EventMatchCondition(
// Very simple glob to regexp converter
private fun simpleGlobToRegExp(glob: String): String {
var out = "" // "^"
for (i in 0 until glob.length) {
val c = glob[i]
when (c) {
for (element in glob) {
when (element) {
'*' -> out += ".*"
'?' -> out += '.'.toString()
'.' -> out += "\\."
'\\' -> out += "\\\\"
else -> out += c
else -> out += element
}
}
out += "" // '$'.toString()

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

@ -26,5 +26,5 @@ object RelationType {
/** Lets you define an event which references an existing event.*/
const val REFERENCE = "m.reference"
/** Lets you define an event which adds a response to an existing event.*/
const val RESPONSE = "m.response"
const val RESPONSE = "org.matrix.response"
}

View file

@ -35,12 +35,19 @@ interface ProfileService {
}
/**
* Return the current dispayname for this user
* Return the current display name for this user
* @param userId the userId param to look for
*
*/
fun getDisplayName(userId: String, matrixCallback: MatrixCallback<Optional<String>>): Cancelable
/**
* Update the display name for this user
* @param userId the userId to update the display name of
* @param newDisplayName the new display name of the user
*/
fun setDisplayName(userId: String, newDisplayName: String, matrixCallback: MatrixCallback<Unit>): Cancelable
/**
* Return the current avatarUrl for this user.
* @param userId the userId param to look for

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,7 +28,7 @@ import kotlin.math.ceil
*/
object HkdfSha256 {
public fun deriveSecret(inputKeyMaterial: ByteArray, salt: ByteArray?, info: ByteArray, outputLength: Int): ByteArray {
fun deriveSecret(inputKeyMaterial: ByteArray, salt: ByteArray?, info: ByteArray, outputLength: Int): ByteArray {
return expand(extract(salt, inputKeyMaterial), info, outputLength)
}

View file

@ -273,7 +273,7 @@ internal abstract class SASDefaultVerificationTransaction(
if (keyIDNoPrefix == otherCrossSigningMasterKeyPublic) {
// Check the signature
val mac = macUsingAgreedMethod(otherCrossSigningMasterKeyPublic, baseInfo + it)
if (mac != theirMacSafe.mac.get(it)) {
if (mac != theirMacSafe.mac[it]) {
// WRONG!
Timber.e("## SAS Verification: mac mismatch for MasterKey with id $keyIDNoPrefix")
cancel(CancelCode.MismatchedKeys)

View file

@ -85,7 +85,7 @@ internal class SendVerificationMessageWorker(context: Context,
private const val OUTPUT_KEY_FAILED = "failed"
fun hasFailed(outputData: Data): Boolean {
return outputData.getBoolean(SendVerificationMessageWorker.OUTPUT_KEY_FAILED, false)
return outputData.getBoolean(OUTPUT_KEY_FAILED, false)
}
}
}

View file

@ -25,7 +25,7 @@ internal object TimelineEventFilter {
*/
internal object Content {
internal const val EDIT = """{*"m.relates_to"*"rel_type":*"m.replace"*}"""
internal const val RESPONSE = """{*"m.relates_to"*"rel_type":*"m.response"*}"""
internal const val RESPONSE = """{*"m.relates_to"*"rel_type":*"org.matrix.response"*}"""
}
/**

View file

@ -1,5 +1,5 @@
/*
* 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.
@ -22,12 +22,13 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import org.greenrobot.eventbus.EventBus
import retrofit2.Call
import retrofit2.awaitResponse
import java.io.IOException
internal suspend inline fun <DATA> executeRequest(eventBus: EventBus?,
internal suspend inline fun <DATA : Any> executeRequest(eventBus: EventBus?,
block: Request<DATA>.() -> Unit) = Request<DATA>(eventBus).apply(block).execute()
internal class Request<DATA>(private val eventBus: EventBus?) {
internal class Request<DATA : Any>(private val eventBus: EventBus?) {
var isRetryable = false
var initialDelay: Long = 100L

View file

@ -1,6 +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.
@ -26,8 +26,6 @@ import im.vector.matrix.android.internal.di.MoshiProvider
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.ResponseBody
import org.greenrobot.eventbus.EventBus
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import timber.log.Timber
import java.io.IOException
@ -35,23 +33,6 @@ import java.net.HttpURLConnection
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
internal suspend fun <T> Call<T>.awaitResponse(): Response<T> {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
cancel()
}
enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
continuation.resume(response)
}
override fun onFailure(call: Call<T>, t: Throwable) {
continuation.resumeWithException(t)
}
})
}
}
internal suspend fun okhttp3.Call.awaitResponse(): okhttp3.Response {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {

View file

@ -33,7 +33,7 @@ data class Fingerprint(
@Throws(CertificateException::class)
fun matchesCert(cert: X509Certificate): Boolean {
var o: Fingerprint? = when (hashType) {
val o: Fingerprint? = when (hashType) {
HashType.SHA256 -> newSha256Fingerprint(cert)
HashType.SHA1 -> newSha1Fingerprint(cert)
}

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

@ -34,7 +34,8 @@ import javax.inject.Inject
internal class DefaultProfileService @Inject constructor(private val taskExecutor: TaskExecutor,
private val monarchy: Monarchy,
private val refreshUserThreePidsTask: RefreshUserThreePidsTask,
private val getProfileInfoTask: GetProfileInfoTask) : ProfileService {
private val getProfileInfoTask: GetProfileInfoTask,
private val setDisplayNameTask: SetDisplayNameTask) : ProfileService {
override fun getDisplayName(userId: String, matrixCallback: MatrixCallback<Optional<String>>): Cancelable {
val params = GetProfileInfoTask.Params(userId)
@ -54,6 +55,14 @@ internal class DefaultProfileService @Inject constructor(private val taskExecuto
.executeBy(taskExecutor)
}
override fun setDisplayName(userId: String, newDisplayName: String, matrixCallback: MatrixCallback<Unit>): Cancelable {
return setDisplayNameTask
.configureWith(SetDisplayNameTask.Params(userId = userId, newDisplayName = newDisplayName)) {
callback = matrixCallback
}
.executeBy(taskExecutor)
}
override fun getAvatarUrl(userId: String, matrixCallback: MatrixCallback<Optional<String>>): Cancelable {
val params = GetProfileInfoTask.Params(userId)
return getProfileInfoTask

View file

@ -23,6 +23,7 @@ import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
internal interface ProfileAPI {
@ -42,6 +43,12 @@ internal interface ProfileAPI {
@GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "account/3pid")
fun getThreePIDs(): Call<AccountThreePidsResponse>
/**
* Change user display name
*/
@PUT(NetworkConstants.URI_API_PREFIX_PATH_R0 + "profile/{userId}/displayname")
fun setDisplayName(@Path("userId") userId: String, @Body body: SetDisplayNameBody): Call<Unit>
/**
* Bind a threePid
* Ref: https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-account-3pid-bind

View file

@ -51,4 +51,7 @@ internal abstract class ProfileModule {
@Binds
abstract fun bindUnbindThreePidsTask(task: DefaultUnbindThreePidsTask): UnbindThreePidsTask
@Binds
abstract fun bindSetDisplayNameTask(task: DefaultSetDisplayNameTask): SetDisplayNameTask
}

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.profile
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class SetDisplayNameBody(
/**
* The new display name for this user.
*/
@Json(name = "displayname")
val displayName: String
)

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.internal.session.profile
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 SetDisplayNameTask : Task<SetDisplayNameTask.Params, Unit> {
data class Params(
val userId: String,
val newDisplayName: String
)
}
internal class DefaultSetDisplayNameTask @Inject constructor(
private val profileAPI: ProfileAPI,
private val eventBus: EventBus) : SetDisplayNameTask() {
override suspend fun execute(params: Params) {
return executeRequest(eventBus) {
val body = SetDisplayNameBody(
displayName = params.newDisplayName
)
apiCall = profileAPI.setDisplayName(params.userId, body)
}
}
}

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

@ -51,7 +51,7 @@ internal class DefaultRoomPushRuleService @AssistedInject constructor(@Assisted
override fun setRoomNotificationState(roomNotificationState: RoomNotificationState, matrixCallback: MatrixCallback<Unit>): Cancelable {
return setRoomNotificationStateTask
.configureWith(SetRoomNotificationStateTask.Params(roomId, roomNotificationState)) {
this.callback = callback
this.callback = matrixCallback
}
.executeBy(taskExecutor)
}

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

@ -37,7 +37,7 @@ internal class MarkdownParser @Inject constructor(
fun parse(text: String): TextContent {
// If no special char are detected, just return plain text
if (text.contains(mdSpecialChars).not()) {
return TextContent(text.toString())
return TextContent(text)
}
val document = parser.parse(text)
@ -56,7 +56,7 @@ internal class MarkdownParser @Inject constructor(
val plainText = textContentRenderer.render(document)
TextContent(plainText, cleanHtmlText.postTreatment())
} else {
TextContent(text.toString())
TextContent(text)
}
}

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

@ -33,8 +33,8 @@
<string name="notice_display_name_set_by_you">You set your display name to %1$s</string>
<string name="notice_display_name_changed_from">%1$s changed their display name from %2$s to %3$s</string>
<string name="notice_display_name_changed_from_by_you">You changed your display name from %1$s to %2$s</string>
<string name="notice_display_name_removed">%1$s removed their display name (%2$s)</string>
<string name="notice_display_name_removed_by_you">You removed your display name (%1$s)</string>
<string name="notice_display_name_removed">%1$s removed their display name (it was %2$s)</string>
<string name="notice_display_name_removed_by_you">You removed your display name (it was %1$s)</string>
<string name="notice_room_topic_changed">%1$s changed the topic to: %2$s</string>
<string name="notice_room_topic_changed_by_you">You changed the topic to: %1$s</string>
<string name="notice_room_name_changed">%1$s changed the room name to: %2$s</string>
@ -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

@ -62,9 +62,9 @@ class ContactPicker(override val requestCode: Int) : Picker<MultiPickerContactTy
val contactId = cursor.getInt(idColumn)
var name = cursor.getString(nameColumn)
var photoUri = cursor.getString(photoUriColumn)
var phoneNumberList = mutableListOf<String>()
var emailList = mutableListOf<String>()
val photoUri = cursor.getString(photoUriColumn)
val phoneNumberList = mutableListOf<String>()
val emailList = mutableListOf<String>()
getRawContactId(context.contentResolver, contactId)?.let { rawContactId ->
val selection = ContactsContract.Data.RAW_CONTACT_ID + " = ?"

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

@ -40,10 +40,10 @@ class DefaultErrorFormatter @Inject constructor(
null -> null
is IdentityServiceError -> identityServerError(throwable)
is Failure.NetworkConnection -> {
when {
throwable.ioException is SocketTimeoutException ->
when (throwable.ioException) {
is SocketTimeoutException ->
stringProvider.getString(R.string.error_network_timeout)
throwable.ioException is UnknownHostException ->
is UnknownHostException ->
// Invalid homeserver?
// TODO Check network state, airplane mode, etc.
stringProvider.getString(R.string.login_error_unknown_host)

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) {
// 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

@ -45,7 +45,7 @@ class VectorEditTextPreference : EditTextPreference {
override fun onBindViewHolder(holder: PreferenceViewHolder) {
// display the title in multi-line to avoid ellipsis.
try {
holder.itemView.findViewById<TextView>(android.R.id.title)?.setSingleLine(false)
holder.itemView.findViewById<TextView>(android.R.id.title)?.isSingleLine = false
} catch (e: Exception) {
Timber.e(e, "onBindView")
}

View file

@ -87,7 +87,7 @@ open class VectorPreference : Preference {
val title = itemView.findViewById<TextView>(android.R.id.title)
val summary = itemView.findViewById<TextView>(android.R.id.summary)
if (title != null) {
title.setSingleLine(false)
title.isSingleLine = false
title.setTypeface(null, mTypeface)
}

View file

@ -43,7 +43,7 @@ class VectorSwitchPreference : SwitchPreference {
override fun onBindViewHolder(holder: PreferenceViewHolder) {
// display the title in multi-line to avoid ellipsis.
holder.itemView.findViewById<TextView>(android.R.id.title)?.setSingleLine(false)
holder.itemView.findViewById<TextView>(android.R.id.title)?.isSingleLine = false
super.onBindViewHolder(holder)
}

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,54 +15,120 @@
* 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_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()
else ->
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
return START_STICKY
@ -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) {
// 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,
val notification = notificationUtils.buildIncomingCallNotification(
intent.getBooleanExtra(EXTRA_IS_VIDEO, false),
intent.getStringExtra(EXTRA_ROOM_NAME),
intent.getStringExtra(EXTRA_MATRIX_ID),
callId)
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 ")
// 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,7 +275,7 @@ 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,
fun onIncomingCallRinging(context: Context,
isVideo: Boolean,
roomName: String,
roomId: String,
@ -175,7 +283,45 @@ class CallService : VectorService() {
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)
}
@ -380,6 +407,7 @@ class RoomDetailFragment @Inject constructor(
override fun onDestroyView() {
timelineEventController.callback = null
timelineEventController.removeModelBuildListener(modelBuildListener)
activeCallView.callback = null
modelBuildListener = null
autoCompleter.clear()
debouncer.cancelAll()
@ -389,6 +417,7 @@ class RoomDetailFragment @Inject constructor(
}
override fun onDestroy() {
activeCallViewHolder.unBind(webRtcPeerConnectionManager)
roomDetailViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState)
super.onDestroy()
}
@ -418,6 +447,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) {
@ -481,6 +519,29 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.handle(RoomDetailAction.OpenIntegrationManager)
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)
}
}
@ -496,6 +557,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()
@ -739,6 +820,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) {
@ -1090,6 +1172,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
@ -1473,4 +1571,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)
}
}
}
}

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