diff --git a/.idea/dictionaries/bmarty.xml b/.idea/dictionaries/bmarty.xml index 93ac86f417..9d601bff14 100644 --- a/.idea/dictionaries/bmarty.xml +++ b/.idea/dictionaries/bmarty.xml @@ -7,6 +7,7 @@ ciphertext coroutine decryptor + displayname emoji emojis fdroid diff --git a/CHANGES.md b/CHANGES.md index 52b410e2b1..5ad458878d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,37 @@ -Changes in RiotX 0.22.0 (2020-XX-XX) +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) + +Bugfix πŸ›: + - Fix dark theme issue on login screen (#1097) + - Incomplete predicate in RealmCryptoStore#getOutgoingRoomKeyRequest (#1519) + - User could not redact message that they have sent (#1543) + - Use vendor prefix for non merged MSC (#1537) + +Translations πŸ—£: + - + +SDK API changes ⚠️: + - + +Build 🧱: + - Enable code optimization (Proguard) + - SDK is now API level 21 minimum, and so RiotX (#405) + +Other changes: + - Use `SharedPreferences#edit` extension function consistently (#1545) + - 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) =================================================== Features ✨: @@ -23,15 +56,7 @@ Bugfix πŸ›: - Fix status bar icon contrast on API in [21,23[ - Wrong /query request (#1444) - Make Credentials.homeServer optional because it is deprecated (#1443) - -Translations πŸ—£: - - - -SDK API changes ⚠️: - - - -Build 🧱: - - + - Fix issue on dark themes, after alert popup dismiss Other changes: - Send plain text in the body of events containing formatted body, as per https://matrix.org/docs/spec/client_server/latest#m-room-message-msgtypes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b36843adee..a2126f25d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,11 @@ An Android Studio template has been added to the project to help creating all fi To install the template (to be done only once): - Go to folder `./tools/template`. -- Run the script `./configure.sh`. +- Mac OSX: Run the script `./configure.sh`. + + Linux: Run `ANDROID_STUDIO=/path/to/android-studio ./configure` + - e.g. `ANDROID_STUDIO=/usr/local/android-studio ./configure` + - Restart Android Studio. To create a new screen: @@ -27,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. diff --git a/README.md b/README.md index b43bcf643c..251ee3e236 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ RiotX is an Android Matrix Client currently in beta but in active development. It is a total rewrite of [Riot-Android](https://github.com/vector-im/riot-android) with a new user experience. RiotX will become the official replacement as soon as all features are implemented. -[Get it on Google Play](https://play.google.com/store/apps/details?id=im.vector.riotx) -[Get it on F-Droid](https://f-droid.org/app/im.vector.riotx) +[Get it on Google Play](https://play.google.com/store/apps/details?id=im.vector.riotx) +[Get it on F-Droid](https://f-droid.org/app/im.vector.riotx) Nightly build: [![Buildkite](https://badge.buildkite.com/657d3db27364448d69d54f66c690f7788bc6aa80a7628e37f3.svg?branch=develop)](https://buildkite.com/matrix-dot-org/riotx-android/builds?branch=develop) diff --git a/docs/voip_signaling.md b/docs/voip_signaling.md new file mode 100644 index 0000000000..ba72e1b218 --- /dev/null +++ b/docs/voip_signaling.md @@ -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 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ + β”‚ + β”‚ + β”‚ + β”” ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ \ No newline at end of file diff --git a/matrix-sdk-android-rx/build.gradle b/matrix-sdk-android-rx/build.gradle index 734ff0c130..c67d10d810 100644 --- a/matrix-sdk-android-rx/build.gradle +++ b/matrix-sdk-android-rx/build.gradle @@ -6,7 +6,7 @@ android { compileSdkVersion 29 defaultConfig { - minSdkVersion 16 + minSdkVersion 21 targetSdkVersion 29 versionCode 1 versionName "1.0" diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index abc860d1ff..982e258c3f 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -23,7 +23,7 @@ android { testOptions.unitTests.includeAndroidResources = true defaultConfig { - minSdkVersion 16 + minSdkVersion 21 targetSdkVersion 29 versionCode 1 versionName "0.0.1" @@ -35,6 +35,10 @@ android { resValue "string", "git_sdk_revision", "\"${gitRevision()}\"" resValue "string", "git_sdk_revision_unix_date", "\"${gitRevisionUnixDate()}\"" resValue "string", "git_sdk_revision_date", "\"${gitRevisionDate()}\"" + + defaultConfig { + consumerProguardFiles 'proguard-rules.pro' + } } buildTypes { @@ -49,9 +53,6 @@ android { release { buildConfigField "boolean", "LOG_PRIVATE_DATA", "false" buildConfigField "okhttp3.logging.HttpLoggingInterceptor.Level", "OKHTTP_LOGGING_LEVEL", "okhttp3.logging.HttpLoggingInterceptor.Level.NONE" - - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } @@ -113,6 +114,7 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "androidx.appcompat:appcompat:1.1.0" + implementation "androidx.core:core-ktx:1.1.0" implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" @@ -161,6 +163,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' diff --git a/matrix-sdk-android/proguard-rules.pro b/matrix-sdk-android/proguard-rules.pro index f1b424510d..3cb49420b8 100644 --- a/matrix-sdk-android/proguard-rules.pro +++ b/matrix-sdk-android/proguard-rules.pro @@ -19,3 +19,45 @@ # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile + + +### EVENT BUS ### + +-keepattributes *Annotation* +-keepclassmembers class * { + @org.greenrobot.eventbus.Subscribe ; +} +-keep enum org.greenrobot.eventbus.ThreadMode { *; } + +### MOSHI ### + +# JSR 305 annotations are for embedding nullability information. + +-dontwarn javax.annotation.** + +-keepclasseswithmembers class * { + @com.squareup.moshi.* ; +} + +-keep @com.squareup.moshi.JsonQualifier interface * + +# Enum field names are used by the integrated EnumJsonAdapter. +# values() is synthesized by the Kotlin compiler and is used by EnumJsonAdapter indirectly +# Annotate enums with @JsonClass(generateAdapter = false) to use them with Moshi. +-keepclassmembers @com.squareup.moshi.JsonClass class * extends java.lang.Enum { + ; + **[] values(); +} + +-keep class kotlin.reflect.jvm.internal.impl.builtins.BuiltInsLoaderImpl + +-keepclassmembers class kotlin.Metadata { + public ; +} + +### OKHTTP for Android Studio ### +-keep class okhttp3.Headers { *; } +-keep interface okhttp3.Interceptor.* { *; } + +### OLM JNI ### +-keep class org.matrix.olm.** { *; } \ No newline at end of file diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt index 35ad8ff4e1..4eda7c60c8 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt @@ -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) } diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt index 57ab4aaf33..444278d67e 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt @@ -90,7 +90,7 @@ class KeyShareTests : InstrumentedTest { } catch (failure: Throwable) { } - val outgoingRequestBefore = aliceSession2.cryptoService().getOutgoingRoomKeyRequest() + val outgoingRequestsBefore = aliceSession2.cryptoService().getOutgoingRoomKeyRequests() // Try to request aliceSession2.cryptoService().requestRoomKeyForEvent(receivedEvent.root) @@ -100,10 +100,10 @@ class KeyShareTests : InstrumentedTest { var outGoingRequestId: String? = null mTestHelper.retryPeriodicallyWithLatch(waitLatch) { - aliceSession2.cryptoService().getOutgoingRoomKeyRequest() + aliceSession2.cryptoService().getOutgoingRoomKeyRequests() .filter { req -> // filter out request that was known before - !outgoingRequestBefore.any { req.requestId == it.requestId } + !outgoingRequestsBefore.any { req.requestId == it.requestId } } .let { val outgoing = it.firstOrNull { it.sessionId == eventMegolmSessionId } @@ -115,10 +115,10 @@ class KeyShareTests : InstrumentedTest { Log.v("TEST", "=======> Outgoing requet Id is $outGoingRequestId") - val outgoingRequestAfter = aliceSession2.cryptoService().getOutgoingRoomKeyRequest() + val outgoingRequestAfter = aliceSession2.cryptoService().getOutgoingRoomKeyRequests() // We should have a new request - Assert.assertTrue(outgoingRequestAfter.size > outgoingRequestBefore.size) + Assert.assertTrue(outgoingRequestAfter.size > outgoingRequestsBefore.size) Assert.assertNotNull(outgoingRequestAfter.first { it.sessionId == eventMegolmSessionId }) // The first session should see an incoming request @@ -126,7 +126,7 @@ class KeyShareTests : InstrumentedTest { mTestHelper.waitWithLatch { latch -> mTestHelper.retryPeriodicallyWithLatch(latch) { // DEBUG LOGS - aliceSession.cryptoService().getIncomingRoomKeyRequest().let { + aliceSession.cryptoService().getIncomingRoomKeyRequests().let { Log.v("TEST", "Incoming request Session 1 (looking for $outGoingRequestId)") Log.v("TEST", "=========================") it.forEach { keyRequest -> @@ -135,7 +135,7 @@ class KeyShareTests : InstrumentedTest { Log.v("TEST", "=========================") } - val incoming = aliceSession.cryptoService().getIncomingRoomKeyRequest().firstOrNull { it.requestId == outGoingRequestId } + val incoming = aliceSession.cryptoService().getIncomingRoomKeyRequests().firstOrNull { it.requestId == outGoingRequestId } incoming?.state == GossipingRequestState.REJECTED } } @@ -155,7 +155,7 @@ class KeyShareTests : InstrumentedTest { mTestHelper.waitWithLatch { latch -> mTestHelper.retryPeriodicallyWithLatch(latch) { - aliceSession.cryptoService().getIncomingRoomKeyRequest().let { + aliceSession.cryptoService().getIncomingRoomKeyRequests().let { Log.v("TEST", "Incoming request Session 1") Log.v("TEST", "=========================") it.forEach { @@ -171,7 +171,7 @@ class KeyShareTests : InstrumentedTest { Thread.sleep(6_000) mTestHelper.waitWithLatch { latch -> mTestHelper.retryPeriodicallyWithLatch(latch) { - aliceSession2.cryptoService().getOutgoingRoomKeyRequest().let { + aliceSession2.cryptoService().getOutgoingRoomKeyRequests().let { it.any { it.requestBody?.sessionId == eventMegolmSessionId && it.state == OutgoingGossipingRequestState.CANCELLED } } } @@ -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) diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/ssss/QuadSTests.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/ssss/QuadSTests.kt index cbd175f53f..d93b151ded 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/ssss/QuadSTests.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/ssss/QuadSTests.kt @@ -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)) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/HomeServerConnectionConfig.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/HomeServerConnectionConfig.kt index 36b812ce16..125a7dfc1b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/HomeServerConnectionConfig.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/HomeServerConnectionConfig.kt @@ -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() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/extensions/Try.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/extensions/Try.kt index 3afcac08c1..3d80a94156 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/extensions/Try.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/extensions/Try.kt @@ -16,10 +16,15 @@ package im.vector.matrix.android.api.extensions -inline fun tryThis(operation: () -> A): A? { +import timber.log.Timber + +inline fun tryThis(message: String? = null, operation: () -> A): A? { return try { operation() } catch (any: Throwable) { + if (message != null) { + Timber.e(any, message) + } null } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/pushrules/EventMatchCondition.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/pushrules/EventMatchCondition.kt index a6d7e48b9d..ca13155542 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/pushrules/EventMatchCondition.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/pushrules/EventMatchCondition.kt @@ -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() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt index 80e1f9fe42..cce53b9e3b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt @@ -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 @@ -171,6 +172,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. diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/CallSignalingService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/CallSignalingService.kt new file mode 100644 index 0000000000..ec0cb0e4e1 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/CallSignalingService.kt @@ -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): 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? +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/CallState.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/CallState.kt new file mode 100644 index 0000000000..3b3a393026 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/CallState.kt @@ -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() +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/CallsListener.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/CallsListener.kt new file mode 100644 index 0000000000..1c51c10c0b --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/CallsListener.kt @@ -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) +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/EglUtils.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/EglUtils.kt new file mode 100644 index 0000000000..bd1d95ae6f --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/EglUtils.kt @@ -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 + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/MxCall.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/MxCall.kt new file mode 100644 index 0000000000..f6b8813188 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/MxCall.kt @@ -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) + + /** + * Send removed ICE candidates to the other participant. + */ + fun sendLocalIceCandidateRemovals(candidates: List) + + fun addListener(listener: StateListener) + fun removeListener(listener: StateListener) + + interface StateListener { + fun onStateUpdate(call: MxCall) + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/TurnServerResponse.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/TurnServerResponse.kt new file mode 100644 index 0000000000..78acff5290 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/call/TurnServerResponse.kt @@ -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?, + + /** + * Required. The time-to-live in seconds + */ + @Json(name = "ttl") val ttl: Int? +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/CryptoService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/CryptoService.kt index 2c96465313..69fc332453 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/CryptoService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/CryptoService.kt @@ -138,7 +138,9 @@ interface CryptoService { fun removeSessionListener(listener: NewSessionListener) - fun getOutgoingRoomKeyRequest(): List - fun getIncomingRoomKeyRequest(): List + fun getOutgoingRoomKeyRequests(): List + + fun getIncomingRoomKeyRequests(): List + fun getGossipingEventsTrail(): List } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/EventType.kt index a33b9e70df..801a9bb7d0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/EventType.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/EventType.kt @@ -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" diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/RelationType.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/RelationType.kt index 3be9bdb7cc..8ffb8eb63f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/RelationType.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/RelationType.kt @@ -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" } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/profile/ProfileService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/profile/ProfileService.kt index 92f9359e34..3d084336e3 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/profile/ProfileService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/profile/ProfileService.kt @@ -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>): 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): Cancelable + /** * Return the current avatarUrl for this user. * @param userId the userId param to look for diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt index 4ae61f46e1..231eaa5806 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt @@ -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 { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/call/RoomCallService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/call/RoomCallService.kt new file mode 100644 index 0000000000..b1dc0899bb --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/call/RoomCallService.kt @@ -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 +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/Membership.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/Membership.kt index 0d5ce70f80..c4e3f16e9b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/Membership.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/Membership.kt @@ -17,10 +17,12 @@ package im.vector.matrix.android.api.session.room.model import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass /** * Represents the membership of a user on a room */ +@JsonClass(generateAdapter = false) enum class Membership(val value: String) { NONE("none"), diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomDirectoryVisibility.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomDirectoryVisibility.kt index ab3407392c..3e63636fc6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomDirectoryVisibility.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomDirectoryVisibility.kt @@ -17,7 +17,9 @@ package im.vector.matrix.android.api.session.room.model import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = false) enum class RoomDirectoryVisibility { @Json(name = "private") PRIVATE, @Json(name = "public") PUBLIC diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomGuestAccessContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomGuestAccessContent.kt index 4c814f7914..36e2d769a3 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomGuestAccessContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomGuestAccessContent.kt @@ -29,6 +29,7 @@ data class RoomGuestAccessContent( @Json(name = "guest_access") val guestAccess: GuestAccess? = null ) +@JsonClass(generateAdapter = false) enum class GuestAccess(val value: String) { @Json(name = "can_join") CanJoin("can_join"), diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomHistoryVisibility.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomHistoryVisibility.kt index b0e4e2d8e5..44ad777c0e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomHistoryVisibility.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomHistoryVisibility.kt @@ -17,10 +17,12 @@ package im.vector.matrix.android.api.session.room.model import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass /** * Ref: https://matrix.org/docs/spec/client_server/latest#room-history-visibility */ +@JsonClass(generateAdapter = false) enum class RoomHistoryVisibility { /** * All events while this is the m.room.history_visibility value may be shared by any diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomJoinRules.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomJoinRules.kt index d7cf8678c3..e4ebcc7044 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomJoinRules.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomJoinRules.kt @@ -18,10 +18,12 @@ package im.vector.matrix.android.api.session.room.model import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass /** * Enum for [RoomJoinRulesContent] : https://matrix.org/docs/spec/client_server/r0.4.0#m-room-join-rules */ +@JsonClass(generateAdapter = false) enum class RoomJoinRules(val value: String) { @Json(name = "public") diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt index 351fd4ecad..6f21b9eeae 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt @@ -61,6 +61,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 } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallAnswerContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallAnswerContent.kt index 24eb68bd78..7fb575d12c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallAnswerContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallAnswerContent.kt @@ -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 ) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallCandidatesContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallCandidatesContent.kt index 5fb4db84a3..4b71320c32 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallCandidatesContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallCandidatesContent.kt @@ -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 = emptyList() + /** + * Required. Array of objects describing the candidates. + */ + @Json(name = "candidates") val candidates: List = 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 ) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallHangupContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallHangupContent.kt index eda2486aa2..1e50bc247e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallHangupContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallHangupContent.kt @@ -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 + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallInviteContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallInviteContent.kt index 29305d1420..1fad181fab 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallInviteContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/CallInviteContent.kt @@ -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 } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/SdpType.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/SdpType.kt new file mode 100644 index 0000000000..17c6d9a89f --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/call/SdpType.kt @@ -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 +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomPreset.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomPreset.kt index eafe9ef292..109ef7f9bd 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomPreset.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomPreset.kt @@ -17,7 +17,9 @@ package im.vector.matrix.android.api.session.room.model.create import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = false) enum class CreateRoomPreset { @Json(name = "private_chat") PRESET_PRIVATE_CHAT, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/WidgetPostAPIMediator.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/WidgetPostAPIMediator.kt index ac3ed8df09..bc39afda8f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/WidgetPostAPIMediator.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/WidgetPostAPIMediator.kt @@ -90,6 +90,6 @@ interface WidgetPostAPIMediator { /** * Triggered when a widget is posting */ - fun handleWidgetRequest(eventData: JsonDict): Boolean + fun handleWidgetRequest(mediator: WidgetPostAPIMediator, eventData: JsonDict): Boolean } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/WidgetService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/WidgetService.kt index 2585d0a968..1f62a335a9 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/WidgetService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/WidgetService.kt @@ -34,7 +34,8 @@ interface WidgetService { fun getWidgetURLFormatter(): WidgetURLFormatter /** - * Returns an instance of [WidgetPostAPIMediator]. + * Returns a new instance of [WidgetPostAPIMediator]. + * Be careful to call clearWebView method and setHandler to null to avoid memory leaks. * This is to be used for "admin" widgets so you can interact through JS. */ fun getWidgetPostAPIMediator(): WidgetPostAPIMediator diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/model/WidgetContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/model/WidgetContent.kt index 7e045f25c7..af70400622 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/model/WidgetContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/model/WidgetContent.kt @@ -21,6 +21,9 @@ import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import im.vector.matrix.android.api.util.JsonDict +/** + * Ref: https://github.com/matrix-org/matrix-doc/issues/1236 + */ @JsonClass(generateAdapter = true) data class WidgetContent( @Json(name = "creatorUserId") val creatorUserId: String? = null, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/model/WidgetType.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/model/WidgetType.kt index 4a265d71b7..3977a4c474 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/model/WidgetType.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/widgets/model/WidgetType.kt @@ -16,6 +16,25 @@ package im.vector.matrix.android.api.session.widgets.model +private val DEFINED_TYPES by lazy { + listOf( + WidgetType.Jitsi, + WidgetType.TradingView, + WidgetType.Spotify, + WidgetType.Video, + WidgetType.GoogleDoc, + WidgetType.GoogleCalendar, + WidgetType.Etherpad, + WidgetType.StickerPicker, + WidgetType.Grafana, + WidgetType.Custom, + WidgetType.IntegrationManager + ) +} + +/** + * Ref: https://github.com/matrix-org/matrix-doc/issues/1236 + */ sealed class WidgetType(open val preferred: String, open val legacy: String = preferred) { object Jitsi : WidgetType("m.jitsi", "jitsi") object TradingView : WidgetType("m.tradingview") @@ -30,7 +49,7 @@ sealed class WidgetType(open val preferred: String, open val legacy: String = pr object IntegrationManager : WidgetType("m.integration_manager") data class Fallback(override val preferred: String) : WidgetType(preferred) - fun matches(type: String?): Boolean { + fun matches(type: String): Boolean { return type == preferred || type == legacy } @@ -40,20 +59,6 @@ sealed class WidgetType(open val preferred: String, open val legacy: String = pr companion object { - private val DEFINED_TYPES = listOf( - Jitsi, - TradingView, - Spotify, - Video, - GoogleDoc, - GoogleCalendar, - Etherpad, - StickerPicker, - Grafana, - Custom, - IntegrationManager - ) - fun fromString(type: String): WidgetType { val matchingType = DEFINED_TYPES.firstOrNull { it.matches(type) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoModule.kt index 1efdffdb06..44092f4ae4 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoModule.kt @@ -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 } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/DefaultCryptoService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/DefaultCryptoService.kt index 6ffe294186..05f29f1660 100755 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/DefaultCryptoService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/DefaultCryptoService.kt @@ -1263,11 +1263,11 @@ internal class DefaultCryptoService @Inject constructor( return "DefaultCryptoService of $userId ($deviceId)" } - override fun getOutgoingRoomKeyRequest(): List { + override fun getOutgoingRoomKeyRequests(): List { return cryptoStore.getOutgoingRoomKeyRequests() } - override fun getIncomingRoomKeyRequest(): List { + override fun getIncomingRoomKeyRequests(): List { return cryptoStore.getIncomingRoomKeyRequests() } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/RealmCryptoStore.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/RealmCryptoStore.kt index 6af96f886d..a78ab70b72 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/RealmCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/store/db/RealmCryptoStore.kt @@ -897,9 +897,9 @@ internal class RealmCryptoStore @Inject constructor( it.toOutgoingGossipingRequest() as? OutgoingRoomKeyRequest }.firstOrNull { it.requestBody?.algorithm == requestBody.algorithm - it.requestBody?.roomId == requestBody.roomId - it.requestBody?.senderKey == requestBody.senderKey - it.requestBody?.sessionId == requestBody.sessionId + && it.requestBody?.roomId == requestBody.roomId + && it.requestBody?.senderKey == requestBody.senderKey + && it.requestBody?.sessionId == requestBody.sessionId } } @@ -1266,7 +1266,7 @@ internal class RealmCryptoStore @Inject constructor( deviceInfoEntity.trustLevelEntity = it } } else { - locallyVerified?.let { trustEntity.locallyVerified = it } + locallyVerified?.let { trustEntity.locallyVerified = it } trustEntity.crossSignedVerified = crossSignedVerified } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/SendEventTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/SendEventTask.kt new file mode 100644 index 0000000000..637db1790e --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/SendEventTask.kt @@ -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 { + 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(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 + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tools/HkdfSha256.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tools/HkdfSha256.kt index 4a24e054ac..35eecc793d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tools/HkdfSha256.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tools/HkdfSha256.kt @@ -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) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SASDefaultVerificationTransaction.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SASDefaultVerificationTransaction.kt index 7048d790a0..db97ba1052 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SASDefaultVerificationTransaction.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SASDefaultVerificationTransaction.kt @@ -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) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SendVerificationMessageWorker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SendVerificationMessageWorker.kt index 95376fb0cc..057a6732e4 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SendVerificationMessageWorker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SendVerificationMessageWorker.kt @@ -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) } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmKeysUtils.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmKeysUtils.kt index 55ced343d4..71973b1193 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmKeysUtils.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/RealmKeysUtils.kt @@ -17,6 +17,7 @@ package im.vector.matrix.android.internal.database import android.content.Context import android.util.Base64 +import androidx.core.content.edit import im.vector.matrix.android.BuildConfig import im.vector.matrix.android.internal.session.securestorage.SecretStoringUtils import io.realm.RealmConfiguration @@ -67,10 +68,9 @@ internal class RealmKeysUtils @Inject constructor(context: Context, val key = generateKeyForRealm() val encodedKey = Base64.encodeToString(key, Base64.NO_PADDING) val toStore = secretStoringUtils.securelyStoreString(encodedKey, alias) - sharedPreferences - .edit() - .putString("${ENCRYPTED_KEY_PREFIX}_$alias", Base64.encodeToString(toStore!!, Base64.NO_PADDING)) - .apply() + sharedPreferences.edit { + putString("${ENCRYPTED_KEY_PREFIX}_$alias", Base64.encodeToString(toStore!!, Base64.NO_PADDING)) + } return key } @@ -107,10 +107,9 @@ internal class RealmKeysUtils @Inject constructor(context: Context, if (hasKeyForDatabase(alias)) { secretStoringUtils.safeDeleteKey(alias) - sharedPreferences - .edit() - .remove("${ENCRYPTED_KEY_PREFIX}_$alias") - .apply() + sharedPreferences.edit { + remove("${ENCRYPTED_KEY_PREFIX}_$alias") + } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/SessionRealmConfigurationFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/SessionRealmConfigurationFactory.kt index 0b5af8397d..e3d9833e40 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/SessionRealmConfigurationFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/SessionRealmConfigurationFactory.kt @@ -17,6 +17,7 @@ package im.vector.matrix.android.internal.database import android.content.Context +import androidx.core.content.edit import im.vector.matrix.android.internal.database.model.SessionRealmModule import im.vector.matrix.android.internal.di.SessionFilesDirectory import im.vector.matrix.android.internal.di.SessionId @@ -54,10 +55,9 @@ internal class SessionRealmConfigurationFactory @Inject constructor( Timber.v("************************************************************") deleteRealmFiles() } - sharedPreferences - .edit() - .putBoolean("$REALM_SHOULD_CLEAR_FLAG_$sessionId", true) - .apply() + sharedPreferences.edit { + putBoolean("$REALM_SHOULD_CLEAR_FLAG_$sessionId", true) + } val realmConfiguration = RealmConfiguration.Builder() .compactOnLaunch() @@ -73,10 +73,9 @@ internal class SessionRealmConfigurationFactory @Inject constructor( // Try creating a realm instance and if it succeeds we can clear the flag Realm.getInstance(realmConfiguration).use { Timber.v("Successfully create realm instance") - sharedPreferences - .edit() - .putBoolean("$REALM_SHOULD_CLEAR_FLAG_$sessionId", false) - .apply() + sharedPreferences.edit { + putBoolean("$REALM_SHOULD_CLEAR_FLAG_$sessionId", false) + } } return realmConfiguration } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/TimelineEventFilter.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/TimelineEventFilter.kt index 14560ead85..f313205c4d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/TimelineEventFilter.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/TimelineEventFilter.kt @@ -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"*}""" } /** diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/Request.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/Request.kt index ea036f775b..44096fca71 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/Request.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/Request.kt @@ -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 executeRequest(eventBus: EventBus?, +internal suspend inline fun executeRequest(eventBus: EventBus?, block: Request.() -> Unit) = Request(eventBus).apply(block).execute() -internal class Request(private val eventBus: EventBus?) { +internal class Request(private val eventBus: EventBus?) { var isRetryable = false var initialDelay: Long = 100L diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/RetrofitExtensions.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/RetrofitExtensions.kt index 2a2076db6c..aa8fe46795 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/RetrofitExtensions.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/RetrofitExtensions.kt @@ -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 Call.awaitResponse(): Response { - return suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { - cancel() - } - enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - continuation.resume(response) - } - - override fun onFailure(call: Call, t: Throwable) { - continuation.resumeWithException(t) - } - }) - } -} - internal suspend fun okhttp3.Call.awaitResponse(): okhttp3.Response { return suspendCancellableCoroutine { continuation -> continuation.invokeOnCancellation { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/ssl/Fingerprint.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/ssl/Fingerprint.kt index 99c2eb9c72..dd8e70d459 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/ssl/Fingerprint.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/ssl/Fingerprint.kt @@ -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) } @@ -76,6 +76,7 @@ data class Fingerprint( } } + @JsonClass(generateAdapter = false) enum class HashType { @Json(name = "sha-1") SHA1, @Json(name = "sha-256")SHA256 diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt index da59e01907..ed9b51fc9f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt @@ -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 @@ -108,7 +109,8 @@ internal class DefaultSession @Inject constructor( private val coroutineDispatchers: MatrixCoroutineDispatchers, private val defaultIdentityService: DefaultIdentityService, private val integrationManagerService: IntegrationManagerService, - private val taskExecutor: TaskExecutor) + private val taskExecutor: TaskExecutor, + private val callSignalingService: Lazy) : Session, RoomService by roomService.get(), RoomDirectoryService by roomDirectoryService.get(), @@ -244,6 +246,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) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionComponent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionComponent.kt index 5b64f2a60a..b95595ed23 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionComponent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionComponent.kt @@ -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 diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt index e1df1d3654..7faf2ba172 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt @@ -60,6 +60,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.identity.DefaultIdentityService @@ -72,7 +73,6 @@ import im.vector.matrix.android.internal.session.securestorage.DefaultSecureStor import im.vector.matrix.android.internal.session.typing.DefaultTypingUsersTracker import im.vector.matrix.android.internal.session.user.accountdata.DefaultAccountDataService import im.vector.matrix.android.internal.session.widgets.DefaultWidgetURLFormatter -import im.vector.matrix.android.internal.session.widgets.WidgetManager import im.vector.matrix.android.internal.util.md5 import io.realm.RealmConfiguration import okhttp3.OkHttpClient @@ -263,7 +263,7 @@ internal abstract class SessionModule { @Binds @IntoSet - abstract fun bindWidgetManager(observer: WidgetManager): SessionLifecycleObserver + abstract fun bindCallEventObserver(observer: CallEventObserver): SessionLifecycleObserver @Binds @IntoSet diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventObserver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventObserver.kt new file mode 100644 index 0000000000..585ecb61ca --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventObserver.kt @@ -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(realmConfiguration) { + + override val query = Monarchy.Query { + EventEntity.whereTypes(it, listOf( + EventType.CALL_ANSWER, + EventType.CALL_CANDIDATES, + EventType.CALL_INVITE, + EventType.CALL_HANGUP, + EventType.ENCRYPTED) + ) + } + + override fun onChange(results: RealmResults, 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) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventsObserverTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventsObserverTask.kt new file mode 100644 index 0000000000..2d96bd3b23 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallEventsObserverTask.kt @@ -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.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.di.SessionDatabase +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 { + + data class Params( + val events: List, + val userId: String + ) +} + +internal class DefaultCallEventsObserverTask @Inject constructor( + @SessionDatabase 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, 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 + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallModule.kt new file mode 100644 index 0000000000..a25d198e83 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/CallModule.kt @@ -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 +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/DefaultCallSignalingService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/DefaultCallSignalingService.kt new file mode 100644 index 0000000000..b8ecd5abe4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/DefaultCallSignalingService.kt @@ -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() + + private val activeCalls = mutableListOf() + + private var cachedTurnServerResponse: TurnServerResponse? = null + + override fun getTurnServer(callback: MatrixCallback): Cancelable { + if (cachedTurnServerResponse != null) { + cachedTurnServerResponse?.let { callback.onSuccess(it) } + return NoOpCancellable + } + return turnServerTask + .configureWith(GetTurnServerTask.Params) { + this.callback = object : MatrixCallback { + 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()?.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()?.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()?.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()?.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 + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/GetTurnServerTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/GetTurnServerTask.kt new file mode 100644 index 0000000000..f644bb22e9 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/GetTurnServerTask.kt @@ -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 { + 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() + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/VoipApi.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/VoipApi.kt new file mode 100644 index 0000000000..29b9141489 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/VoipApi.kt @@ -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 +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/model/MxCallImpl.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/model/MxCallImpl.kt new file mode 100644 index 0000000000..fe9b9e447c --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/call/model/MxCallImpl.kt @@ -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() + + 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) { + 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) { + // 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) } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/DefaultProfileService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/DefaultProfileService.kt index ef344a356b..459d53607b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/DefaultProfileService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/DefaultProfileService.kt @@ -35,7 +35,8 @@ import javax.inject.Inject internal class DefaultProfileService @Inject constructor(private val taskExecutor: TaskExecutor, @SessionDatabase 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>): Cancelable { val params = GetProfileInfoTask.Params(userId) @@ -55,6 +56,14 @@ internal class DefaultProfileService @Inject constructor(private val taskExecuto .executeBy(taskExecutor) } + override fun setDisplayName(userId: String, newDisplayName: String, matrixCallback: MatrixCallback): Cancelable { + return setDisplayNameTask + .configureWith(SetDisplayNameTask.Params(userId = userId, newDisplayName = newDisplayName)) { + callback = matrixCallback + } + .executeBy(taskExecutor) + } + override fun getAvatarUrl(userId: String, matrixCallback: MatrixCallback>): Cancelable { val params = GetProfileInfoTask.Params(userId) return getProfileInfoTask diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileAPI.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileAPI.kt index 717497e582..b3b726a315 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileAPI.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileAPI.kt @@ -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 + /** + * Change user display name + */ + @PUT(NetworkConstants.URI_API_PREFIX_PATH_R0 + "profile/{userId}/displayname") + fun setDisplayName(@Path("userId") userId: String, @Body body: SetDisplayNameBody): Call + /** * Bind a threePid * Ref: https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-account-3pid-bind diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileModule.kt index 0d7ebe5b62..d83c305c10 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/ProfileModule.kt @@ -51,4 +51,7 @@ internal abstract class ProfileModule { @Binds abstract fun bindUnbindThreePidsTask(task: DefaultUnbindThreePidsTask): UnbindThreePidsTask + + @Binds + abstract fun bindSetDisplayNameTask(task: DefaultSetDisplayNameTask): SetDisplayNameTask } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/SetDisplayNameBody.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/SetDisplayNameBody.kt new file mode 100644 index 0000000000..c9bcbff688 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/SetDisplayNameBody.kt @@ -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 +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/SetDisplayNameTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/SetDisplayNameTask.kt new file mode 100644 index 0000000000..bca2f73f26 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/profile/SetDisplayNameTask.kt @@ -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 { + 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) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt index 0fa2be7055..bcaa25c88d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt @@ -21,6 +21,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 @@ -51,6 +52,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, @@ -67,6 +69,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, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt index ea6e9a86c8..d93ba76c3f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt @@ -19,6 +19,7 @@ package im.vector.matrix.android.internal.session.room import im.vector.matrix.android.api.session.crypto.CryptoService import im.vector.matrix.android.api.session.room.Room 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 @@ -49,6 +50,7 @@ internal class DefaultRoomFactory @Inject constructor(private val cryptoService: 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, @@ -69,6 +71,7 @@ internal class DefaultRoomFactory @Inject constructor(private val cryptoService: 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), diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/call/DefaultRoomCallService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/call/DefaultRoomCallService.kt new file mode 100644 index 0000000000..c23d8de37f --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/call/DefaultRoomCallService.kt @@ -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() + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/notification/DefaultRoomPushRuleService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/notification/DefaultRoomPushRuleService.kt index 1a70103b71..bc677b3720 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/notification/DefaultRoomPushRuleService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/notification/DefaultRoomPushRuleService.kt @@ -52,7 +52,7 @@ internal class DefaultRoomPushRuleService @AssistedInject constructor(@Assisted override fun setRoomNotificationState(roomNotificationState: RoomNotificationState, matrixCallback: MatrixCallback): Cancelable { return setRoomNotificationStateTask .configureWith(SetRoomNotificationStateTask.Params(roomId, roomNotificationState)) { - this.callback = callback + this.callback = matrixCallback } .executeBy(taskExecutor) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt index d60e652e12..b4593bc71b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt @@ -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, compressBeforeSending: Boolean, roomIds: Set): 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) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/MarkdownParser.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/MarkdownParser.kt index 92de583de7..3833b2f87d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/MarkdownParser.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/MarkdownParser.kt @@ -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) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/RoomEventSender.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/RoomEventSender.kt new file mode 100644 index 0000000000..4d43067ceb --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/RoomEventSender.kt @@ -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() + .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(sendWorkData, startChain) + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/widgets/DefaultWidgetPostAPIMediator.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/widgets/DefaultWidgetPostAPIMediator.kt index 345ef39edd..5bf2d02e3c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/widgets/DefaultWidgetPostAPIMediator.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/widgets/DefaultWidgetPostAPIMediator.kt @@ -78,7 +78,7 @@ internal class DefaultWidgetPostAPIMediator @Inject constructor(private val mosh private fun onWidgetMessage(eventData: JsonDict) { try { - if (handler?.handleWidgetRequest(eventData) == false) { + if (handler?.handleWidgetRequest(this, eventData) == false) { sendError("", eventData) } } catch (e: Exception) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/widgets/DefaultWidgetService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/widgets/DefaultWidgetService.kt index 424168bc62..ab5f1e8858 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/widgets/DefaultWidgetService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/widgets/DefaultWidgetService.kt @@ -26,10 +26,11 @@ import im.vector.matrix.android.api.session.widgets.WidgetURLFormatter import im.vector.matrix.android.api.session.widgets.model.Widget import im.vector.matrix.android.api.util.Cancelable import javax.inject.Inject +import javax.inject.Provider internal class DefaultWidgetService @Inject constructor(private val widgetManager: WidgetManager, private val widgetURLFormatter: WidgetURLFormatter, - private val widgetPostAPIMediator: WidgetPostAPIMediator) + private val widgetPostAPIMediator: Provider) : WidgetService { override fun getWidgetURLFormatter(): WidgetURLFormatter { @@ -37,7 +38,7 @@ internal class DefaultWidgetService @Inject constructor(private val widgetManage } override fun getWidgetPostAPIMediator(): WidgetPostAPIMediator { - return widgetPostAPIMediator + return widgetPostAPIMediator.get() } override fun getRoomWidgets( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/widgets/token/GetScalarTokenTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/widgets/token/GetScalarTokenTask.kt index f6d1ecb23b..7afff00d64 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/widgets/token/GetScalarTokenTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/widgets/token/GetScalarTokenTask.kt @@ -67,8 +67,7 @@ internal class DefaultGetScalarTokenTask @Inject constructor(private val widgets throw IllegalStateException("Scalar token is null") } scalarTokenStore.setToken(serverUrl, registerWidgetResponse.scalarToken) - widgetsAPI.validateToken(registerWidgetResponse.scalarToken, WIDGET_API_VERSION) - return registerWidgetResponse.scalarToken + return validateToken(widgetsAPI, serverUrl, registerWidgetResponse.scalarToken) } private suspend fun validateToken(widgetsAPI: WidgetsAPI, serverUrl: String, scalarToken: String): String { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/CompatUtil.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/CompatUtil.kt index fa97d6a9eb..e8283c359f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/CompatUtil.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/CompatUtil.kt @@ -27,6 +27,7 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 import androidx.annotation.RequiresApi +import androidx.core.content.edit import timber.log.Timber import java.io.IOException import java.io.InputStream @@ -152,9 +153,9 @@ object CompatUtil { .build()) key = generator.generateKey() - sharedPreferences.edit() - .putInt(SHARED_KEY_ANDROID_VERSION_WHEN_KEY_HAS_BEEN_GENERATED, Build.VERSION.SDK_INT) - .apply() + sharedPreferences.edit { + putInt(SHARED_KEY_ANDROID_VERSION_WHEN_KEY_HAS_BEEN_GENERATED, Build.VERSION.SDK_INT) + } } } } else { @@ -188,10 +189,10 @@ object CompatUtil { cipher.init(Cipher.WRAP_MODE, keyPair.public) val wrappedAesKey = cipher.wrap(key) - sharedPreferences.edit() - .putString(AES_WRAPPED_PROTECTION_KEY_SHARED_PREFERENCE, Base64.encodeToString(wrappedAesKey, 0)) - .putInt(SHARED_KEY_ANDROID_VERSION_WHEN_KEY_HAS_BEEN_GENERATED, Build.VERSION.SDK_INT) - .apply() + sharedPreferences.edit { + putString(AES_WRAPPED_PROTECTION_KEY_SHARED_PREFERENCE, Base64.encodeToString(wrappedAesKey, 0)) + putInt(SHARED_KEY_ANDROID_VERSION_WHEN_KEY_HAS_BEEN_GENERATED, Build.VERSION.SDK_INT) + } } } diff --git a/matrix-sdk-android/src/main/res/values/strings.xml b/matrix-sdk-android/src/main/res/values/strings.xml index 3cd7674253..4e8e5abc96 100644 --- a/matrix-sdk-android/src/main/res/values/strings.xml +++ b/matrix-sdk-android/src/main/res/values/strings.xml @@ -33,8 +33,8 @@ You set your display name to %1$s %1$s changed their display name from %2$s to %3$s You changed your display name from %1$s to %2$s - %1$s removed their display name (%2$s) - You removed your display name (%1$s) + %1$s removed their display name (it was %2$s) + You removed your display name (it was %1$s) %1$s changed the topic to: %2$s You changed the topic to: %1$s %1$s changed the room name to: %2$s @@ -43,6 +43,8 @@ You placed a video call. %s placed a voice call. You placed a voice call. + %s sent data to setup the call. + You sent data to setup the call. %s answered the call. You answered the call. %s ended the call. @@ -362,4 +364,8 @@ %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. + Accept + Decline + Hang Up + diff --git a/multipicker/src/main/java/im/vector/riotx/multipicker/ContactPicker.kt b/multipicker/src/main/java/im/vector/riotx/multipicker/ContactPicker.kt index b0ae0e4cda..a2026475d0 100644 --- a/multipicker/src/main/java/im/vector/riotx/multipicker/ContactPicker.kt +++ b/multipicker/src/main/java/im/vector/riotx/multipicker/ContactPicker.kt @@ -62,9 +62,9 @@ class ContactPicker(override val requestCode: Int) : Picker() - var emailList = mutableListOf() + val photoUri = cursor.getString(photoUriColumn) + val phoneNumberList = mutableListOf() + val emailList = mutableListOf() getRawContactId(context.contentResolver, contactId)?.let { rawContactId -> val selection = ContactsContract.Data.RAW_CONTACT_ID + " = ?" diff --git a/resources/img/f-droid-badge.png b/resources/img/f-droid-badge.png new file mode 100644 index 0000000000..0fcd0eb5f8 Binary files /dev/null and b/resources/img/f-droid-badge.png differ diff --git a/resources/img/google-play-badge.png b/resources/img/google-play-badge.png new file mode 100644 index 0000000000..9499b2dca0 Binary files /dev/null and b/resources/img/google-play-badge.png differ diff --git a/tools/templates/configure.sh b/tools/templates/configure.sh index eb2aa0dbec..de7fe7da81 100755 --- a/tools/templates/configure.sh +++ b/tools/templates/configure.sh @@ -17,8 +17,9 @@ # echo "Configure RiotX Template..." +if [ -z ${ANDROID_STUDIO+x} ]; then ANDROID_STUDIO="/Applications/Android Studio.app/Contents"; fi { -ln -s $(pwd)/RiotXFeature /Applications/Android\ Studio.app/Contents/plugins/android/lib/templates/other +ln -s $(pwd)/RiotXFeature "${ANDROID_STUDIO%/}/plugins/android/lib/templates/other" } && { echo "Please restart Android Studio." } diff --git a/vector/build.gradle b/vector/build.gradle index a20ec23205..f253501177 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -15,7 +15,7 @@ androidExtensions { } ext.versionMajor = 0 -ext.versionMinor = 22 +ext.versionMinor = 23 ext.versionPatch = 0 static def getGitTimestamp() { @@ -107,9 +107,8 @@ android { compileSdkVersion 29 defaultConfig { applicationId "im.vector.riotx" - // Set to API 19 because motionLayout is min API 18. - // In the future we may consider using an alternative of MotionLayout to support API 16. But for security reason, maybe not. - minSdkVersion 19 + // Set to API 21: see #405 + minSdkVersion 21 targetSdkVersion 29 multiDexEnabled true @@ -192,8 +191,14 @@ android { resValue "bool", "debug_mode", "false" buildConfigField "boolean", "LOW_PRIVACY_LOG_ENABLE", "false" - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + postprocessing { + removeUnusedCode true + removeUnusedResources true + // We do not activate obfuscation as it makes it hard then to read crash reports, and it's a bit useless on an open source project :) + obfuscate false + optimizeCode true + proguardFiles 'proguard-rules.pro' + } } } @@ -385,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' diff --git a/vector/proguard-rules.pro b/vector/proguard-rules.pro index f1b424510d..56d3b95510 100644 --- a/vector/proguard-rules.pro +++ b/vector/proguard-rules.pro @@ -19,3 +19,5 @@ # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile + +-keep class im.vector.riotx.features.** { *; } \ No newline at end of file diff --git a/vector/src/gplay/java/im/vector/riotx/push/fcm/FcmHelper.kt b/vector/src/gplay/java/im/vector/riotx/push/fcm/FcmHelper.kt index d62aaf0f3d..b04f9b3703 100755 --- a/vector/src/gplay/java/im/vector/riotx/push/fcm/FcmHelper.kt +++ b/vector/src/gplay/java/im/vector/riotx/push/fcm/FcmHelper.kt @@ -21,6 +21,7 @@ import android.app.Activity import android.content.Context import androidx.preference.PreferenceManager import android.widget.Toast +import androidx.core.content.edit import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.firebase.iid.FirebaseInstanceId @@ -57,10 +58,9 @@ object FcmHelper { */ fun storeFcmToken(context: Context, token: String?) { - PreferenceManager.getDefaultSharedPreferences(context) - .edit() - .putString(PREFS_KEY_FCM_TOKEN, token) - .apply() + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString(PREFS_KEY_FCM_TOKEN, token) + } } /** diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 833267483a..6cfe02dd0f 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -3,13 +3,30 @@ xmlns:tools="http://schemas.android.com/tools" package="im.vector.riotx"> + + + + + + + + + + + + + + + + + @@ -180,20 +198,47 @@ + android:exported="false" > + + + + + + + + + + + + + + + + + + + + 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) - else -> + else -> stringProvider.getString(R.string.error_no_network) } } diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/FragmentManager.kt b/vector/src/main/java/im/vector/riotx/core/extensions/FragmentManager.kt index caf1bf90f8..83ac540830 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/FragmentManager.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/FragmentManager.kt @@ -17,9 +17,14 @@ package im.vector.riotx.core.extensions import androidx.fragment.app.FragmentTransaction +import im.vector.matrix.android.api.extensions.tryThis inline fun androidx.fragment.app.FragmentManager.commitTransactionNow(func: FragmentTransaction.() -> FragmentTransaction) { - beginTransaction().func().commitNow() + // Could throw and make the app crash + // e.g sharedActionViewModel.observe() + tryThis("Failed to commitTransactionNow") { + beginTransaction().func().commitNow() + } } inline fun androidx.fragment.app.FragmentManager.commitTransaction(func: FragmentTransaction.() -> FragmentTransaction) { diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt index eb3fca66c6..29b169ffd4 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt @@ -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) { diff --git a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt index 270e67cf34..ba9e7320d2 100644 --- a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt +++ b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt @@ -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() diff --git a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseBottomSheetDialogFragment.kt b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseBottomSheetDialogFragment.kt index fbc4bc5292..393642139e 100644 --- a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseBottomSheetDialogFragment.kt +++ b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseBottomSheetDialogFragment.kt @@ -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 * ========================================================================================== */ diff --git a/vector/src/main/java/im/vector/riotx/core/preference/VectorEditTextPreference.kt b/vector/src/main/java/im/vector/riotx/core/preference/VectorEditTextPreference.kt index 90246dbb92..e2c61118a7 100644 --- a/vector/src/main/java/im/vector/riotx/core/preference/VectorEditTextPreference.kt +++ b/vector/src/main/java/im/vector/riotx/core/preference/VectorEditTextPreference.kt @@ -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(android.R.id.title)?.setSingleLine(false) + holder.itemView.findViewById(android.R.id.title)?.isSingleLine = false } catch (e: Exception) { Timber.e(e, "onBindView") } diff --git a/vector/src/main/java/im/vector/riotx/core/preference/VectorPreference.kt b/vector/src/main/java/im/vector/riotx/core/preference/VectorPreference.kt index 396bf3054f..048625ded6 100755 --- a/vector/src/main/java/im/vector/riotx/core/preference/VectorPreference.kt +++ b/vector/src/main/java/im/vector/riotx/core/preference/VectorPreference.kt @@ -87,7 +87,7 @@ open class VectorPreference : Preference { val title = itemView.findViewById(android.R.id.title) val summary = itemView.findViewById(android.R.id.summary) if (title != null) { - title.setSingleLine(false) + title.isSingleLine = false title.setTypeface(null, mTypeface) } diff --git a/vector/src/main/java/im/vector/riotx/core/preference/VectorSwitchPreference.kt b/vector/src/main/java/im/vector/riotx/core/preference/VectorSwitchPreference.kt index e6292d102b..9a2064d741 100644 --- a/vector/src/main/java/im/vector/riotx/core/preference/VectorSwitchPreference.kt +++ b/vector/src/main/java/im/vector/riotx/core/preference/VectorSwitchPreference.kt @@ -43,7 +43,7 @@ class VectorSwitchPreference : SwitchPreference { override fun onBindViewHolder(holder: PreferenceViewHolder) { // display the title in multi-line to avoid ellipsis. - holder.itemView.findViewById(android.R.id.title)?.setSingleLine(false) + holder.itemView.findViewById(android.R.id.title)?.isSingleLine = false super.onBindViewHolder(holder) } diff --git a/vector/src/main/java/im/vector/riotx/core/services/BluetoothHeadsetReceiver.kt b/vector/src/main/java/im/vector/riotx/core/services/BluetoothHeadsetReceiver.kt new file mode 100644 index 0000000000..a56c4c73c6 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/services/BluetoothHeadsetReceiver.kt @@ -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? = 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.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) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/core/services/CallRingPlayer.kt b/vector/src/main/java/im/vector/riotx/core/services/CallRingPlayer.kt new file mode 100644 index 0000000000..f7f64d65f5 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/services/CallRingPlayer.kt @@ -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 + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/core/services/CallService.kt b/vector/src/main/java/im/vector/riotx/core/services/CallService.kt index 30ab62d5b2..723cfe3add 100644 --- a/vector/src/main/java/im/vector/riotx/core/services/CallService.kt +++ b/vector/src/main/java/im/vector/riotx/core/services/CallService.kt @@ -1,5 +1,6 @@ /* * Copyright 2019 New Vector Ltd + * Copyright 2020 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,53 +15,119 @@ * limitations under the License. */ -@file:Suppress("UNUSED_PARAMETER") - package im.vector.riotx.core.services import android.content.Context import android.content.Intent +import android.os.Binder +import android.support.v4.media.session.MediaSessionCompat +import android.view.KeyEvent import androidx.core.content.ContextCompat +import androidx.media.session.MediaButtonReceiver import im.vector.riotx.core.extensions.vectorComponent +import im.vector.riotx.features.call.WebRtcPeerConnectionManager +import im.vector.riotx.features.call.telecom.CallConnection import im.vector.riotx.features.notifications.NotificationUtils import timber.log.Timber /** * Foreground service to manage calls */ -class CallService : VectorService() { +class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListener, BluetoothHeadsetReceiver.EventListener { - /** - * call in progress (foreground notification) - */ - private var mCallIdInProgress: String? = null + private val connections = mutableMapOf() 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(Intent.EXTRA_KEY_EVENT) ?: return false + if (keyEvent.keyCode == KeyEvent.KEYCODE_HEADSETHOOK) { + webRtcPeerConnectionManager.headSetButtonTapped() + return true + } + return false + } + } override fun onCreate() { super.onCreate() notificationUtils = vectorComponent().notificationUtils() + webRtcPeerConnectionManager = vectorComponent().webRtcPeerConnectionManager() + callRingPlayer = CallRingPlayer(applicationContext) + wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(this, this) + bluetoothHeadsetStateReceiver = BluetoothHeadsetReceiver.createAndRegister(this, this) + } + + override fun onDestroy() { + super.onDestroy() + callRingPlayer?.stop() + wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(this, it) } + wiredHeadsetStateReceiver = null + bluetoothHeadsetStateReceiver?.let { BluetoothHeadsetReceiver.unRegister(this, it) } + bluetoothHeadsetStateReceiver = null + mediaSession?.release() + mediaSession = null } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Timber.v("## VOIP onStartCommand $intent") + if (mediaSession == null) { + mediaSession = MediaSessionCompat(applicationContext, CallService::class.java.name).apply { + setCallback(mediaSessionButtonCallback) + } + } if (intent == null) { // Service started again by the system. // TODO What do we do here? return START_STICKY } + mediaSession?.let { + // This ensures that the correct callbacks to MediaSessionCompat.Callback + // will be triggered based on the incoming KeyEvent. + MediaButtonReceiver.handleIntent(it, intent) + } when (intent.action) { - ACTION_INCOMING_CALL -> displayIncomingCallNotification(intent) - ACTION_PENDING_CALL -> displayCallInProgressNotification(intent) - ACTION_NO_ACTIVE_CALL -> hideCallNotifications() - else -> + ACTION_INCOMING_RINGING_CALL -> { + mediaSession?.isActive = true + callRingPlayer?.start() + displayIncomingCallNotification(intent) + } + ACTION_OUTGOING_RINGING_CALL -> { + mediaSession?.isActive = true + callRingPlayer?.start() + displayOutgoingRingingCallNotification(intent) + } + ACTION_ONGOING_CALL -> { + callRingPlayer?.stop() + displayCallInProgressNotification(intent) + } + ACTION_NO_ACTIVE_CALL -> hideCallNotifications() + ACTION_CALL_CONNECTING -> { + // lower notification priority + displayCallInProgressNotification(intent) + // stop ringing + callRingPlayer?.stop() + } + ACTION_ONGOING_CALL_BG -> { + // there is an ongoing call but call activity is in background + displayCallOnGoingInBackground(intent) + } + else -> { // Should not happen + callRingPlayer?.stop() myStopSelf() + } } // We want the system to restore the service if killed @@ -80,54 +147,65 @@ class CallService : VectorService() { * @param callId the callId */ private fun displayIncomingCallNotification(intent: Intent) { - Timber.v("displayIncomingCallNotification") - - // TODO - /* + Timber.v("## VOIP displayIncomingCallNotification $intent") // the incoming call in progress is already displayed - if (!TextUtils.isEmpty(mIncomingCallId)) { - Timber.v("displayIncomingCallNotification : the incoming call in progress is already displayed") - } else if (!TextUtils.isEmpty(mCallIdInProgress)) { - Timber.v("displayIncomingCallNotification : a 'call in progress' notification is displayed") - } else if (null == CallsManager.getSharedInstance().activeCall) { - val callId = intent.getStringExtra(EXTRA_CALL_ID) +// if (!TextUtils.isEmpty(mIncomingCallId)) { +// Timber.v("displayIncomingCallNotification : the incoming call in progress is already displayed") +// } else if (!TextUtils.isEmpty(mCallIdInProgress)) { +// Timber.v("displayIncomingCallNotification : a 'call in progress' notification is displayed") +// } else +// // if (null == webRtcPeerConnectionManager.currentCall) +// { + val callId = intent.getStringExtra(EXTRA_CALL_ID) - Timber.v("displayIncomingCallNotification : display the dedicated notification") - val notification = NotificationUtils.buildIncomingCallNotification( - this, - intent.getBooleanExtra(EXTRA_IS_VIDEO, false), - intent.getStringExtra(EXTRA_ROOM_NAME), - intent.getStringExtra(EXTRA_MATRIX_ID), - callId) - startForeground(NOTIFICATION_ID, notification) + Timber.v("displayIncomingCallNotification : display the dedicated notification") + val notification = notificationUtils.buildIncomingCallNotification( + intent.getBooleanExtra(EXTRA_IS_VIDEO, false), + intent.getStringExtra(EXTRA_ROOM_NAME) ?: "", + intent.getStringExtra(EXTRA_ROOM_ID) ?: "", + callId ?: "") + startForeground(NOTIFICATION_ID, notification) - mIncomingCallId = callId +// mIncomingCallId = callId - // turn the screen on for 3 seconds - if (Matrix.getInstance(VectorApp.getInstance())!!.pushManager.isScreenTurnedOn) { - try { - val pm = getSystemService(Context.POWER_SERVICE) as PowerManager - val wl = pm.newWakeLock( - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or PowerManager.ACQUIRE_CAUSES_WAKEUP, - CallService::class.java.simpleName) - wl.acquire(3000) - wl.release() - } catch (re: RuntimeException) { - Timber.e(re, "displayIncomingCallNotification : failed to turn screen on ") - } + // turn the screen on for 3 seconds +// if (Matrix.getInstance(VectorApp.getInstance())!!.pushManager.isScreenTurnedOn) { +// try { +// val pm = getSystemService(Context.POWER_SERVICE) as PowerManager +// val wl = pm.newWakeLock( +// WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or PowerManager.ACQUIRE_CAUSES_WAKEUP, +// CallService::class.java.simpleName) +// wl.acquire(3000) +// wl.release() +// } catch (re: RuntimeException) { +// Timber.e(re, "displayIncomingCallNotification : failed to turn screen on ") +// } +// +// } +// } +// else { +// Timber.i("displayIncomingCallNotification : do not display the incoming call notification because there is a pending call") +// } + } - } - } else { - Timber.i("displayIncomingCallNotification : do not display the incoming call notification because there is a pending call") - }// test if there is no active call - */ + private fun displayOutgoingRingingCallNotification(intent: Intent) { + val callId = intent.getStringExtra(EXTRA_CALL_ID) + + Timber.v("displayOutgoingCallNotification : display the dedicated notification") + val notification = notificationUtils.buildOutgoingRingingCallNotification( + intent.getBooleanExtra(EXTRA_IS_VIDEO, false), + intent.getStringExtra(EXTRA_ROOM_NAME) ?: "", + intent.getStringExtra(EXTRA_ROOM_ID) ?: "", + callId ?: "") + startForeground(NOTIFICATION_ID, notification) } /** * Display a call in progress notification. */ private fun displayCallInProgressNotification(intent: Intent) { + Timber.v("## VOIP displayCallInProgressNotification") val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: "" val notification = notificationUtils.buildPendingCallNotification( @@ -139,7 +217,27 @@ class CallService : VectorService() { startForeground(NOTIFICATION_ID, notification) - mCallIdInProgress = callId + // mCallIdInProgress = callId + } + + /** + * Display a call in progress notification. + */ + private fun displayCallOnGoingInBackground(intent: Intent) { + Timber.v("## VOIP displayCallInProgressNotification") + val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: "" + + val notification = notificationUtils.buildPendingCallNotification( + isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, false), + roomName = intent.getStringExtra(EXTRA_ROOM_NAME) ?: "", + roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: "", + matrixId = intent.getStringExtra(EXTRA_MATRIX_ID) ?: "", + callId = callId, + fromBg = true) + + startForeground(NOTIFICATION_ID, notification) + + // mCallIdInProgress = callId } /** @@ -148,18 +246,28 @@ class CallService : VectorService() { private fun hideCallNotifications() { val notification = notificationUtils.buildCallEndedNotification() + mediaSession?.isActive = false // It's mandatory to startForeground to avoid crash startForeground(NOTIFICATION_ID, notification) myStopSelf() } + fun addConnection(callConnection: CallConnection) { + connections[callConnection.callId] = callConnection + } + companion object { private const val NOTIFICATION_ID = 6480 - private const val ACTION_INCOMING_CALL = "im.vector.riotx.core.services.CallService.INCOMING_CALL" - private const val ACTION_PENDING_CALL = "im.vector.riotx.core.services.CallService.PENDING_CALL" + private const val ACTION_INCOMING_RINGING_CALL = "im.vector.riotx.core.services.CallService.ACTION_INCOMING_RINGING_CALL" + private const val ACTION_OUTGOING_RINGING_CALL = "im.vector.riotx.core.services.CallService.ACTION_OUTGOING_RINGING_CALL" + private const val ACTION_CALL_CONNECTING = "im.vector.riotx.core.services.CallService.ACTION_CALL_CONNECTING" + private const val ACTION_ONGOING_CALL = "im.vector.riotx.core.services.CallService.ACTION_ONGOING_CALL" + private const val ACTION_ONGOING_CALL_BG = "im.vector.riotx.core.services.CallService.ACTION_ONGOING_CALL_BG" private const val ACTION_NO_ACTIVE_CALL = "im.vector.riotx.core.services.CallService.NO_ACTIVE_CALL" +// private const val ACTION_ACTIVITY_VISIBLE = "im.vector.riotx.core.services.CallService.ACTION_ACTIVITY_VISIBLE" +// private const val ACTION_STOP_RINGING = "im.vector.riotx.core.services.CallService.ACTION_STOP_RINGING" private const val EXTRA_IS_VIDEO = "EXTRA_IS_VIDEO" private const val EXTRA_ROOM_NAME = "EXTRA_ROOM_NAME" @@ -167,15 +275,53 @@ class CallService : VectorService() { private const val EXTRA_MATRIX_ID = "EXTRA_MATRIX_ID" private const val EXTRA_CALL_ID = "EXTRA_CALL_ID" - fun onIncomingCall(context: Context, - isVideo: Boolean, - roomName: String, - roomId: String, - matrixId: String, - callId: String) { + fun onIncomingCallRinging(context: Context, + isVideo: Boolean, + roomName: String, + roomId: String, + matrixId: String, + callId: String) { val intent = Intent(context, CallService::class.java) .apply { - action = ACTION_INCOMING_CALL + action = ACTION_INCOMING_RINGING_CALL + putExtra(EXTRA_IS_VIDEO, isVideo) + putExtra(EXTRA_ROOM_NAME, roomName) + putExtra(EXTRA_ROOM_ID, roomId) + putExtra(EXTRA_MATRIX_ID, matrixId) + putExtra(EXTRA_CALL_ID, callId) + } + + ContextCompat.startForegroundService(context, intent) + } + + fun onOnGoingCallBackground(context: Context, + isVideo: Boolean, + roomName: String, + roomId: String, + matrixId: String, + callId: String) { + val intent = Intent(context, CallService::class.java) + .apply { + action = ACTION_ONGOING_CALL_BG + putExtra(EXTRA_IS_VIDEO, isVideo) + putExtra(EXTRA_ROOM_NAME, roomName) + putExtra(EXTRA_ROOM_ID, roomId) + putExtra(EXTRA_MATRIX_ID, matrixId) + putExtra(EXTRA_CALL_ID, callId) + } + + ContextCompat.startForegroundService(context, intent) + } + + fun onOutgoingCallRinging(context: Context, + isVideo: Boolean, + roomName: String, + roomId: String, + matrixId: String, + callId: String) { + val intent = Intent(context, CallService::class.java) + .apply { + action = ACTION_OUTGOING_RINGING_CALL putExtra(EXTRA_IS_VIDEO, isVideo) putExtra(EXTRA_ROOM_NAME, roomName) putExtra(EXTRA_ROOM_ID, roomId) @@ -194,7 +340,7 @@ class CallService : VectorService() { callId: String) { val intent = Intent(context, CallService::class.java) .apply { - action = ACTION_PENDING_CALL + action = ACTION_ONGOING_CALL putExtra(EXTRA_IS_VIDEO, isVideo) putExtra(EXTRA_ROOM_NAME, roomName) putExtra(EXTRA_ROOM_ID, roomId) @@ -214,4 +360,20 @@ class CallService : VectorService() { ContextCompat.startForegroundService(context, intent) } } + + inner class CallServiceBinder : Binder() { + fun getCallService(): CallService { + return this@CallService + } + } + + override fun onHeadsetEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) { + Timber.v("## VOIP: onHeadsetEvent $event") + webRtcPeerConnectionManager.onWiredDeviceEvent(event) + } + + override fun onBTHeadsetEvent(event: BluetoothHeadsetReceiver.BTHeadsetPlugEvent) { + Timber.v("## VOIP: onBTHeadsetEvent $event") + webRtcPeerConnectionManager.onWirelessDeviceEvent(event) + } } diff --git a/vector/src/main/java/im/vector/riotx/core/services/WiredHeadsetStateReceiver.kt b/vector/src/main/java/im/vector/riotx/core/services/WiredHeadsetStateReceiver.kt new file mode 100644 index 0000000000..e63c7f5049 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/services/WiredHeadsetStateReceiver.kt @@ -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? = 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) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/ActiveCallView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/ActiveCallView.kt new file mode 100644 index 0000000000..9507a4daf8 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/ActiveCallView.kt @@ -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() } + } +} diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/ActiveCallViewHolder.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/ActiveCallViewHolder.kt new file mode 100644 index 0000000000..6a5519adbc --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/ActiveCallViewHolder.kt @@ -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 + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/CallAudioManager.kt b/vector/src/main/java/im/vector/riotx/features/call/CallAudioManager.kt new file mode 100644 index 0000000000..b25a11b5b3 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/CallAudioManager.kt @@ -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 { + return ArrayList().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) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/CallControlsBottomSheet.kt b/vector/src/main/java/im/vector/riotx/features/call/CallControlsBottomSheet.kt new file mode 100644 index 0000000000..cf506a031f --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/CallControlsBottomSheet.kt @@ -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, 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 + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/CallControlsView.kt b/vector/src/main/java/im/vector/riotx/features/call/CallControlsView.kt new file mode 100644 index 0000000000..e3b9f12f67 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/CallControlsView.kt @@ -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() + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/CameraEventsHandlerAdapter.kt b/vector/src/main/java/im/vector/riotx/features/call/CameraEventsHandlerAdapter.kt new file mode 100644 index 0000000000..48f4b9b27b --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/CameraEventsHandlerAdapter.kt @@ -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") + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/Cameras.kt b/vector/src/main/java/im/vector/riotx/features/call/Cameras.kt new file mode 100644 index 0000000000..07d563ca9c --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/Cameras.kt @@ -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) +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/PeerConnectionObserverAdapter.kt b/vector/src/main/java/im/vector/riotx/features/call/PeerConnectionObserverAdapter.kt new file mode 100644 index 0000000000..ffc90d47fa --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/PeerConnectionObserverAdapter.kt @@ -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?) { + 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?) { + Timber.v("## VOIP onAddTrack $p0 / out: $p1") + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/SdpObserverAdapter.kt b/vector/src/main/java/im/vector/riotx/features/call/SdpObserverAdapter.kt new file mode 100644 index 0000000000..4c9964a4c4 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/SdpObserverAdapter.kt @@ -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") + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/SharedActiveCallViewModel.kt b/vector/src/main/java/im/vector/riotx/features/call/SharedActiveCallViewModel.kt new file mode 100644 index 0000000000..71f5ad3877 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/SharedActiveCallViewModel.kt @@ -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 = 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() + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/VectorCallActivity.kt b/vector/src/main/java/im/vector/riotx/features/call/VectorCallActivity.kt new file mode 100644 index 0000000000..bbfd8b20fc --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/VectorCallActivity.kt @@ -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, 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 + ) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/VectorCallViewModel.kt b/vector/src/main/java/im/vector/riotx/features/call/VectorCallViewModel.kt new file mode 100644 index 0000000000..595aa41292 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/VectorCallViewModel.kt @@ -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 = emptyList(), + val otherUserMatrixItem: Async = Uninitialized, + val callState: Async = 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, + 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(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 { + 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 { + + @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 + ) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt b/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt new file mode 100644 index 0000000000..05f14ae4f2 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/WebRtcPeerConnectionManager.kt @@ -0,0 +1,1051 @@ +/* + * 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.hardware.camera2.CameraManager +import android.os.Build +import androidx.annotation.RequiresApi +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.extensions.tryThis +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.EglUtils +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.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.riotx.core.di.ActiveSessionHolder +import im.vector.riotx.core.services.BluetoothHeadsetReceiver +import im.vector.riotx.core.services.CallService +import im.vector.riotx.core.services.WiredHeadsetStateReceiver +import io.reactivex.disposables.Disposable +import io.reactivex.subjects.PublishSubject +import io.reactivex.subjects.ReplaySubject +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.Camera1Enumerator +import org.webrtc.Camera2Enumerator +import org.webrtc.CameraVideoCapturer +import org.webrtc.DataChannel +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpReceiver +import org.webrtc.SessionDescription +import org.webrtc.SurfaceTextureHelper +import org.webrtc.SurfaceViewRenderer +import org.webrtc.VideoSource +import org.webrtc.VideoTrack +import timber.log.Timber +import java.lang.ref.WeakReference +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manage peerConnectionFactory & Peer connections outside of activity lifecycle to resist configuration changes + * Use app context + */ +@Singleton +class WebRtcPeerConnectionManager @Inject constructor( + private val context: Context, + private val sessionHolder: ActiveSessionHolder +) : CallsListener { + + interface CurrentCallListener { + fun onCurrentCallChange(call: MxCall?) + fun onCaptureStateChanged(mgr: WebRtcPeerConnectionManager) {} + fun onAudioDevicesChange(mgr: WebRtcPeerConnectionManager) {} + fun onCameraChange(mgr: WebRtcPeerConnectionManager) {} + } + + private val currentCallsListeners = emptyList().toMutableList() + fun addCurrentCallListener(listener: CurrentCallListener) { + currentCallsListeners.add(listener) + } + + fun removeCurrentCallListener(listener: CurrentCallListener) { + currentCallsListeners.remove(listener) + } + + val audioManager = CallAudioManager(context.applicationContext) { + currentCallsListeners.forEach { + tryThis { it.onAudioDevicesChange(this) } + } + } + + data class CallContext( + val mxCall: MxCall, + + var peerConnection: PeerConnection? = null, + + var localMediaStream: MediaStream? = null, + var remoteMediaStream: MediaStream? = null, + + var localAudioSource: AudioSource? = null, + var localAudioTrack: AudioTrack? = null, + + var localVideoSource: VideoSource? = null, + var localVideoTrack: VideoTrack? = null, + + var remoteVideoTrack: VideoTrack? = null + ) { + + var offerSdp: CallInviteContent.Offer? = null + + val iceCandidateSource: PublishSubject = PublishSubject.create() + private val iceCandidateDisposable = iceCandidateSource + .buffer(300, TimeUnit.MILLISECONDS) + .subscribe { + // omit empty :/ + if (it.isNotEmpty()) { + Timber.v("## Sending local ice candidates to call") + // it.forEach { peerConnection?.addIceCandidate(it) } + mxCall.sendLocalIceCandidates(it) + } + } + + var remoteCandidateSource: ReplaySubject? = null + var remoteIceCandidateDisposable: Disposable? = null + + // We register an availability callback if we loose access to camera + var cameraAvailabilityCallback: CameraRestarter? = null + + fun release() { + remoteIceCandidateDisposable?.dispose() + iceCandidateDisposable?.dispose() + + peerConnection?.close() + peerConnection?.dispose() + + localAudioSource?.dispose() + localVideoSource?.dispose() + + localAudioSource = null + localAudioTrack = null + localVideoSource = null + localVideoTrack = null + localMediaStream = null + remoteMediaStream = null + } + } + +// var localMediaStream: MediaStream? = null + + private val executor = Executors.newSingleThreadExecutor() + + private val rootEglBase by lazy { EglUtils.rootEglBase } + + private var peerConnectionFactory: PeerConnectionFactory? = null + + private var videoCapturer: CameraVideoCapturer? = null + + private val availableCamera = ArrayList() + private var cameraInUse: CameraProxy? = null + + private var currentCaptureMode: CaptureFormat = CaptureFormat.HD + + var capturerIsInError = false + set(value) { + field = value + currentCallsListeners.forEach { + tryThis { it.onCaptureStateChanged(this) } + } + } + + var localSurfaceRenderer: MutableList> = ArrayList() + var remoteSurfaceRenderer: MutableList> = ArrayList() + + fun addIfNeeded(renderer: SurfaceViewRenderer?, list: MutableList>) { + if (renderer == null) return + val exists = list.firstOrNull() { + it.get() == renderer + } != null + if (!exists) { + list.add(WeakReference(renderer)) + } + } + + fun removeIfNeeded(renderer: SurfaceViewRenderer?, list: MutableList>) { + if (renderer == null) return + val exists = list.indexOfFirst { + it.get() == renderer + } + if (exists != -1) { + list.add(WeakReference(renderer)) + } + } + + var currentCall: CallContext? = null + set(value) { + field = value + currentCallsListeners.forEach { + tryThis { it.onCurrentCallChange(value?.mxCall) } + } + } + + fun headSetButtonTapped() { + Timber.v("## VOIP headSetButtonTapped") + val call = currentCall?.mxCall ?: return + if (call.state is CallState.LocalRinging) { + // accept call + acceptIncomingCall() + } + if (call.state is CallState.Connected) { + // end call? + endCall() + } + } + + private fun createPeerConnectionFactory() { + if (peerConnectionFactory != null) return + Timber.v("## VOIP createPeerConnectionFactory") + val eglBaseContext = rootEglBase?.eglBaseContext ?: return Unit.also { + Timber.e("## VOIP No EGL BASE") + } + + Timber.v("## VOIP PeerConnectionFactory.initialize") + PeerConnectionFactory.initialize(PeerConnectionFactory + .InitializationOptions.builder(context.applicationContext) + .createInitializationOptions() + ) + + val options = PeerConnectionFactory.Options() + val defaultVideoEncoderFactory = DefaultVideoEncoderFactory( + eglBaseContext, + /* enableIntelVp8Encoder */ + true, + /* enableH264HighProfile */ + true) + val defaultVideoDecoderFactory = DefaultVideoDecoderFactory(eglBaseContext) + Timber.v("## VOIP PeerConnectionFactory.createPeerConnectionFactory ...") + peerConnectionFactory = PeerConnectionFactory.builder() + .setOptions(options) + .setVideoEncoderFactory(defaultVideoEncoderFactory) + .setVideoDecoderFactory(defaultVideoDecoderFactory) + .createPeerConnectionFactory() + + // attachViewRenderersInternal() + } + + private fun createPeerConnection(callContext: CallContext, turnServerResponse: TurnServerResponse?) { + val iceServers = mutableListOf().apply { + turnServerResponse?.let { server -> + server.uris?.forEach { uri -> + add( + PeerConnection + .IceServer + .builder(uri) + .setUsername(server.username) + .setPassword(server.password) + .createIceServer() + ) + } + } + } + Timber.v("## VOIP creating peer connection...with iceServers $iceServers ") + callContext.peerConnection = peerConnectionFactory?.createPeerConnection(iceServers, StreamObserver(callContext)) + } + + private fun sendSdpOffer(callContext: CallContext) { + val constraints = MediaConstraints() + // These are deprecated options +// constraints.mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) +// constraints.mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", if (currentCall?.mxCall?.isVideoCall == true) "true" else "false")) + + Timber.v("## VOIP creating offer...") + callContext.peerConnection?.createOffer(object : SdpObserverAdapter() { + override fun onCreateSuccess(p0: SessionDescription?) { + if (p0 == null) return +// localSdp = p0 + callContext.peerConnection?.setLocalDescription(object : SdpObserverAdapter() {}, p0) + // send offer to peer + currentCall?.mxCall?.offerSdp(p0) + } + }, constraints) + } + + private fun getTurnServer(callback: ((TurnServerResponse?) -> Unit)) { + sessionHolder.getActiveSession().callSignalingService().getTurnServer(object : MatrixCallback { + override fun onSuccess(data: TurnServerResponse?) { + callback(data) + } + + override fun onFailure(failure: Throwable) { + callback(null) + } + }) + } + + fun attachViewRenderers(localViewRenderer: SurfaceViewRenderer?, remoteViewRenderer: SurfaceViewRenderer, mode: String?) { + Timber.v("## VOIP attachViewRenderers localRendeder $localViewRenderer / $remoteViewRenderer") +// this.localSurfaceRenderer = WeakReference(localViewRenderer) +// this.remoteSurfaceRenderer = WeakReference(remoteViewRenderer) + addIfNeeded(localViewRenderer, this.localSurfaceRenderer) + addIfNeeded(remoteViewRenderer, this.remoteSurfaceRenderer) + + // The call is going to resume from background, we can reduce notif + currentCall?.mxCall + ?.takeIf { it.state is CallState.Connected } + ?.let { mxCall -> + val name = sessionHolder.getSafeActiveSession()?.getUser(mxCall.otherUserId)?.getBestName() + ?: mxCall.roomId + // Start background service with notification + CallService.onPendingCall( + context = context, + isVideo = mxCall.isVideoCall, + roomName = name, + roomId = mxCall.roomId, + matrixId = sessionHolder.getSafeActiveSession()?.myUserId ?: "", + callId = mxCall.callId) + } + + getTurnServer { turnServer -> + val call = currentCall ?: return@getTurnServer + when (mode) { + VectorCallActivity.INCOMING_ACCEPT -> { + internalAcceptIncomingCall(call, turnServer) + } + VectorCallActivity.INCOMING_RINGING -> { + // wait until accepted to create peer connection + // TODO eventually we could already display local stream in PIP? + } + VectorCallActivity.OUTGOING_CREATED -> { + executor.execute { + // 1. Create RTCPeerConnection + createPeerConnection(call, turnServer) + + // 2. Access camera (if video call) + microphone, create local stream + createLocalStream(call) + + // 3. add local stream + call.localMediaStream?.let { call.peerConnection?.addStream(it) } + attachViewRenderersInternal() + + // create an offer, set local description and send via signaling + sendSdpOffer(call) + + Timber.v("## VOIP remoteCandidateSource ${call.remoteCandidateSource}") + call.remoteIceCandidateDisposable = call.remoteCandidateSource?.subscribe({ + Timber.v("## VOIP adding remote ice candidate $it") + call.peerConnection?.addIceCandidate(it) + }, { + Timber.v("## VOIP failed to add remote ice candidate $it") + }) + } + } + else -> { + // Fallback for old android, try to restart capture when attached + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP && capturerIsInError && call.mxCall.isVideoCall) { + // try to restart capture? + videoCapturer?.startCapture(currentCaptureMode.width, currentCaptureMode.height, currentCaptureMode.fps) + } + // sink existing tracks (configuration change, e.g screen rotation) + attachViewRenderersInternal() + } + } + } + } + + private fun internalAcceptIncomingCall(callContext: CallContext, turnServerResponse: TurnServerResponse?) { + val mxCall = callContext.mxCall + // Update service state + + val name = sessionHolder.getSafeActiveSession()?.getUser(mxCall.otherUserId)?.getBestName() + ?: mxCall.roomId + CallService.onPendingCall( + context = context, + isVideo = mxCall.isVideoCall, + roomName = name, + roomId = mxCall.roomId, + matrixId = sessionHolder.getSafeActiveSession()?.myUserId ?: "", + callId = mxCall.callId + ) + executor.execute { + // 1) create peer connection + createPeerConnection(callContext, turnServerResponse) + + // create sdp using offer, and set remote description + // the offer has beed stored when invite was received + callContext.offerSdp?.sdp?.let { + SessionDescription(SessionDescription.Type.OFFER, it) + }?.let { + callContext.peerConnection?.setRemoteDescription(SdpObserverAdapter(), it) + } + // 2) Access camera + microphone, create local stream + createLocalStream(callContext) + + // 2) add local stream + currentCall?.localMediaStream?.let { callContext.peerConnection?.addStream(it) } + attachViewRenderersInternal() + + // create a answer, set local description and send via signaling + createAnswer() + + Timber.v("## VOIP remoteCandidateSource ${callContext.remoteCandidateSource}") + callContext.remoteIceCandidateDisposable = callContext.remoteCandidateSource?.subscribe({ + Timber.v("## VOIP adding remote ice candidate $it") + callContext.peerConnection?.addIceCandidate(it) + }, { + Timber.v("## VOIP failed to add remote ice candidate $it") + }) + } + } + + private fun createLocalStream(callContext: CallContext) { + if (callContext.localMediaStream != null) { + Timber.e("## VOIP localMediaStream already created") + return + } + if (peerConnectionFactory == null) { + Timber.e("## VOIP peerConnectionFactory is null") + return + } + val audioSource = peerConnectionFactory!!.createAudioSource(DEFAULT_AUDIO_CONSTRAINTS) + val localAudioTrack = peerConnectionFactory!!.createAudioTrack(AUDIO_TRACK_ID, audioSource) + localAudioTrack?.setEnabled(true) + + callContext.localAudioSource = audioSource + callContext.localAudioTrack = localAudioTrack + + val localMediaStream = peerConnectionFactory!!.createLocalMediaStream("ARDAMS") // magic value? + + // Add audio track + localMediaStream?.addTrack(localAudioTrack) + + callContext.localMediaStream = localMediaStream + + // add video track if needed + if (callContext.mxCall.isVideoCall) { + availableCamera.clear() + + val cameraIterator = if (Camera2Enumerator.isSupported(context)) Camera2Enumerator(context) else Camera1Enumerator(false) + + // I don't realy know how that works if there are 2 front or 2 back cameras + val frontCamera = cameraIterator.deviceNames + ?.firstOrNull { cameraIterator.isFrontFacing(it) } + ?.let { + CameraProxy(it, CameraType.FRONT).also { availableCamera.add(it) } + } + + val backCamera = cameraIterator.deviceNames + ?.firstOrNull { cameraIterator.isBackFacing(it) } + ?.let { + CameraProxy(it, CameraType.BACK).also { availableCamera.add(it) } + } + + val camera = frontCamera?.also { cameraInUse = frontCamera } + ?: backCamera?.also { cameraInUse = backCamera } + ?: null.also { cameraInUse = null } + + if (camera != null) { + val videoCapturer = cameraIterator.createCapturer(camera.name, object : CameraEventsHandlerAdapter() { + override fun onFirstFrameAvailable() { + super.onFirstFrameAvailable() + capturerIsInError = false + } + + override fun onCameraClosed() { + // This could happen if you open the camera app in chat + // We then register in order to restart capture as soon as the camera is available again + Timber.v("## VOIP onCameraClosed") + this@WebRtcPeerConnectionManager.capturerIsInError = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val restarter = CameraRestarter(cameraInUse?.name ?: "", callContext.mxCall.callId) + callContext.cameraAvailabilityCallback = restarter + val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + cameraManager.registerAvailabilityCallback(restarter, null) + } + } + }) + + val videoSource = peerConnectionFactory!!.createVideoSource(videoCapturer.isScreencast) + val surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", rootEglBase!!.eglBaseContext) + Timber.v("## VOIP Local video source created") + + videoCapturer.initialize(surfaceTextureHelper, context.applicationContext, videoSource!!.capturerObserver) + // HD + videoCapturer.startCapture(currentCaptureMode.width, currentCaptureMode.height, currentCaptureMode.fps) + this.videoCapturer = videoCapturer + + val localVideoTrack = peerConnectionFactory!!.createVideoTrack("ARDAMSv0", videoSource) + Timber.v("## VOIP Local video track created") + localVideoTrack?.setEnabled(true) + + callContext.localVideoSource = videoSource + callContext.localVideoTrack = localVideoTrack + + localMediaStream?.addTrack(localVideoTrack) + } + } + } + + private fun attachViewRenderersInternal() { + // render local video in pip view + localSurfaceRenderer.forEach { + it.get()?.let { pipSurface -> + pipSurface.setMirror(true) + // no need to check if already added, addSink is checking that + currentCall?.localVideoTrack?.addSink(pipSurface) + } + } + + // If remote track exists, then sink it to surface + remoteSurfaceRenderer.forEach { + it.get()?.let { participantSurface -> + currentCall?.remoteVideoTrack?.let { + // no need to check if already added, addSink is checking that + it.addSink(participantSurface) + } + } + } + } + + fun acceptIncomingCall() { + Timber.v("## VOIP acceptIncomingCall from state ${currentCall?.mxCall?.state}") + val mxCall = currentCall?.mxCall + if (mxCall?.state == CallState.LocalRinging) { + getTurnServer { turnServer -> + internalAcceptIncomingCall(currentCall!!, turnServer) + } + } + } + + fun detachRenderers(renderes: List?) { + Timber.v("## VOIP detachRenderers") + // currentCall?.localMediaStream?.let { currentCall?.peerConnection?.removeStream(it) } + if (renderes.isNullOrEmpty()) { + // remove all sinks + localSurfaceRenderer.forEach { + if (it.get() != null) currentCall?.localVideoTrack?.removeSink(it.get()) + } + remoteSurfaceRenderer.forEach { + if (it.get() != null) currentCall?.remoteVideoTrack?.removeSink(it.get()) + } + localSurfaceRenderer.clear() + remoteSurfaceRenderer.clear() + } else { + renderes.forEach { + removeIfNeeded(it, localSurfaceRenderer) + removeIfNeeded(it, remoteSurfaceRenderer) + // no need to check if it's in the track, removeSink is doing it + currentCall?.localVideoTrack?.removeSink(it) + currentCall?.remoteVideoTrack?.removeSink(it) + } + } + + if (remoteSurfaceRenderer.isEmpty()) { + // The call is going to continue in background, so ensure notification is visible + currentCall?.mxCall + ?.takeIf { it.state is CallState.Connected } + ?.let { mxCall -> + // Start background service with notification + + val name = sessionHolder.getSafeActiveSession()?.getUser(mxCall.otherUserId)?.getBestName() + ?: mxCall.otherUserId + CallService.onOnGoingCallBackground( + context = context, + isVideo = mxCall.isVideoCall, + roomName = name, + roomId = mxCall.roomId, + matrixId = sessionHolder.getSafeActiveSession()?.myUserId ?: "", + callId = mxCall.callId + ) + } + } + } + + fun close() { + Timber.v("## VOIP WebRtcPeerConnectionManager close() >") + CallService.onNoActiveCall(context) + audioManager.stop() + val callToEnd = currentCall + currentCall = null + // This must be done in this thread + videoCapturer?.stopCapture() + videoCapturer?.dispose() + videoCapturer = null + executor.execute { + callToEnd?.release() + + if (currentCall == null) { + Timber.v("## VOIP Dispose peerConnectionFactory as there is no need to keep one") + peerConnectionFactory?.dispose() + peerConnectionFactory = null + } + + Timber.v("## VOIP WebRtcPeerConnectionManager close() executor done") + } + } + + companion object { + + private const val AUDIO_TRACK_ID = "ARDAMSa0" + + private val DEFAULT_AUDIO_CONSTRAINTS = MediaConstraints().apply { + // add all existing audio filters to avoid having echos +// mandatory.add(MediaConstraints.KeyValuePair("googEchoCancellation", "true")) +// mandatory.add(MediaConstraints.KeyValuePair("googEchoCancellation2", "true")) +// mandatory.add(MediaConstraints.KeyValuePair("googDAEchoCancellation", "true")) +// +// mandatory.add(MediaConstraints.KeyValuePair("googTypingNoiseDetection", "true")) +// +// mandatory.add(MediaConstraints.KeyValuePair("googAutoGainControl", "true")) +// mandatory.add(MediaConstraints.KeyValuePair("googAutoGainControl2", "true")) +// +// mandatory.add(MediaConstraints.KeyValuePair("googNoiseSuppression", "true")) +// mandatory.add(MediaConstraints.KeyValuePair("googNoiseSuppression2", "true")) +// +// mandatory.add(MediaConstraints.KeyValuePair("googAudioMirroring", "false")) +// mandatory.add(MediaConstraints.KeyValuePair("googHighpassFilter", "true")) + } + } + + fun startOutgoingCall(signalingRoomId: String, otherUserId: String, isVideoCall: Boolean) { + executor.execute { + if (peerConnectionFactory == null) { + createPeerConnectionFactory() + } + } + + Timber.v("## VOIP startOutgoingCall in room $signalingRoomId to $otherUserId isVideo $isVideoCall") + val createdCall = sessionHolder.getSafeActiveSession()?.callSignalingService()?.createOutgoingCall(signalingRoomId, otherUserId, isVideoCall) ?: return + val callContext = CallContext(createdCall) + + audioManager.startForCall(createdCall) + currentCall = callContext + + val name = sessionHolder.getSafeActiveSession()?.getUser(createdCall.otherUserId)?.getBestName() + ?: createdCall.otherUserId + CallService.onOutgoingCallRinging( + context = context.applicationContext, + isVideo = createdCall.isVideoCall, + roomName = name, + roomId = createdCall.roomId, + matrixId = sessionHolder.getSafeActiveSession()?.myUserId ?: "", + callId = createdCall.callId) + + executor.execute { + callContext.remoteCandidateSource = ReplaySubject.create() + } + + // start the activity now + context.applicationContext.startActivity(VectorCallActivity.newIntent(context, createdCall)) + } + + override fun onCallIceCandidateReceived(mxCall: MxCall, iceCandidatesContent: CallCandidatesContent) { + Timber.v("## VOIP onCallIceCandidateReceived for call ${mxCall.callId}") + if (currentCall?.mxCall?.callId != mxCall.callId) return Unit.also { + Timber.w("## VOIP ignore ice candidates from other call") + } + val callContext = currentCall ?: return + + executor.execute { + iceCandidatesContent.candidates.forEach { + Timber.v("## VOIP onCallIceCandidateReceived for call ${mxCall.callId} sdp: ${it.candidate}") + val iceCandidate = IceCandidate(it.sdpMid, it.sdpMLineIndex, it.candidate) + callContext.remoteCandidateSource?.onNext(iceCandidate) + } + } + } + + override fun onCallInviteReceived(mxCall: MxCall, callInviteContent: CallInviteContent) { + Timber.v("## VOIP onCallInviteReceived callId ${mxCall.callId}") + // to simplify we only treat one call at a time, and ignore others + if (currentCall != null) { + Timber.w("## VOIP receiving incoming call while already in call?") + // Just ignore, maybe we could answer from other session? + return + } + executor.execute { + if (peerConnectionFactory == null) { + createPeerConnectionFactory() + } + } + + val callContext = CallContext(mxCall) + currentCall = callContext + audioManager.startForCall(mxCall) + executor.execute { + callContext.remoteCandidateSource = ReplaySubject.create() + } + + // Start background service with notification + val name = sessionHolder.getSafeActiveSession()?.getUser(mxCall.otherUserId)?.getBestName() + ?: mxCall.otherUserId + CallService.onIncomingCallRinging( + context = context, + isVideo = mxCall.isVideoCall, + roomName = name, + roomId = mxCall.roomId, + matrixId = sessionHolder.getSafeActiveSession()?.myUserId ?: "", + callId = mxCall.callId + ) + + callContext.offerSdp = callInviteContent.offer + } + + private fun createAnswer() { + Timber.w("## VOIP createAnswer") + val call = currentCall ?: return + val constraints = MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", if (call.mxCall.isVideoCall) "true" else "false")) + } + executor.execute { + call.peerConnection?.createAnswer(object : SdpObserverAdapter() { + override fun onCreateSuccess(p0: SessionDescription?) { + if (p0 == null) return + call.peerConnection?.setLocalDescription(object : SdpObserverAdapter() {}, p0) + // Now need to send it + call.mxCall.accept(p0) + } + }, constraints) + } + } + + fun muteCall(muted: Boolean) { + currentCall?.localAudioTrack?.setEnabled(!muted) + } + + fun enableVideo(enabled: Boolean) { + currentCall?.localVideoTrack?.setEnabled(enabled) + } + + fun switchCamera() { + Timber.v("## VOIP switchCamera") + if (!canSwitchCamera()) return + if (currentCall != null && currentCall?.mxCall?.state is CallState.Connected && currentCall?.mxCall?.isVideoCall == true) { + videoCapturer?.switchCamera(object : CameraVideoCapturer.CameraSwitchHandler { + // Invoked on success. |isFrontCamera| is true if the new camera is front facing. + override fun onCameraSwitchDone(isFrontCamera: Boolean) { + Timber.v("## VOIP onCameraSwitchDone isFront $isFrontCamera") + cameraInUse = availableCamera.first { if (isFrontCamera) it.type == CameraType.FRONT else it.type == CameraType.BACK } + currentCallsListeners.forEach { + tryThis { it.onCameraChange(this@WebRtcPeerConnectionManager) } + } + } + + override fun onCameraSwitchError(errorDescription: String?) { + Timber.v("## VOIP onCameraSwitchError isFront $errorDescription") + } + }) + } + } + + fun canSwitchCamera(): Boolean { + return availableCamera.size > 0 + } + + fun currentCameraType(): CameraType? { + return cameraInUse?.type + } + + fun setCaptureFormat(format: CaptureFormat) { + Timber.v("## VOIP setCaptureFormat $format") + currentCall ?: return + executor.execute { + // videoCapturer?.stopCapture() + videoCapturer?.changeCaptureFormat(format.width, format.height, format.fps) + currentCaptureMode = format + currentCallsListeners.forEach { tryThis { it.onCaptureStateChanged(this) } } + } + } + + fun currentCaptureFormat(): CaptureFormat { + return currentCaptureMode + } + + fun endCall(originatedByMe: Boolean = true) { + // Update service state + CallService.onNoActiveCall(context) + // close tracks ASAP + currentCall?.localVideoTrack?.setEnabled(false) + currentCall?.localVideoTrack?.setEnabled(false) + + currentCall?.cameraAvailabilityCallback?.let { cameraAvailabilityCallback -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + cameraManager.unregisterAvailabilityCallback(cameraAvailabilityCallback) + } + } + + if (originatedByMe) { + // send hang up event + currentCall?.mxCall?.hangUp() + } + close() + } + + fun onWiredDeviceEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) { + Timber.v("## VOIP onWiredDeviceEvent $event") + currentCall ?: return + // sometimes we received un-wanted unplugged... + audioManager.wiredStateChange(event) + } + + fun onWirelessDeviceEvent(event: BluetoothHeadsetReceiver.BTHeadsetPlugEvent) { + Timber.v("## VOIP onWirelessDeviceEvent $event") + audioManager.bluetoothStateChange(event.plugged) + } + + override fun onCallAnswerReceived(callAnswerContent: CallAnswerContent) { + val call = currentCall ?: return + if (call.mxCall.callId != callAnswerContent.callId) return Unit.also { + Timber.w("onCallAnswerReceived for non active call? ${callAnswerContent.callId}") + } + val mxCall = call.mxCall + // Update service state + val name = sessionHolder.getSafeActiveSession()?.getUser(mxCall.otherUserId)?.getBestName() + ?: mxCall.otherUserId + CallService.onPendingCall( + context = context, + isVideo = mxCall.isVideoCall, + roomName = name, + roomId = mxCall.roomId, + matrixId = sessionHolder.getSafeActiveSession()?.myUserId ?: "", + callId = mxCall.callId + ) + executor.execute { + Timber.v("## VOIP onCallAnswerReceived ${callAnswerContent.callId}") + val sdp = SessionDescription(SessionDescription.Type.ANSWER, callAnswerContent.answer.sdp) + call.peerConnection?.setRemoteDescription(object : SdpObserverAdapter() { + }, sdp) + } + } + + override fun onCallHangupReceived(callHangupContent: CallHangupContent) { + val call = currentCall ?: return + // Remote echos are filtered, so it's only remote hangups that i will get here + if (call.mxCall.callId != callHangupContent.callId) return Unit.also { + Timber.w("onCallHangupReceived for non active call? ${callHangupContent.callId}") + } + call.mxCall.state = CallState.Terminated + endCall(false) + } + + override fun onCallManagedByOtherSession(callId: String) { + Timber.v("## VOIP onCallManagedByOtherSession: $callId") + currentCall = null + CallService.onNoActiveCall(context) + } + + private inner class StreamObserver(val callContext: CallContext) : PeerConnection.Observer { + + override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) { + Timber.v("## VOIP StreamObserver onConnectionChange: $newState") + when (newState) { + /** + * Every ICE transport used by the connection is either in use (state "connected" or "completed") + * or is closed (state "closed"); in addition, at least one transport is either "connected" or "completed" + */ + PeerConnection.PeerConnectionState.CONNECTED -> { + callContext.mxCall.state = CallState.Connected(newState) + } + /** + * One or more of the ICE transports on the connection is in the "failed" state. + */ + PeerConnection.PeerConnectionState.FAILED -> { + // This can be temporary, e.g when other ice not yet received... + // callContext.mxCall.state = CallState.ERROR + callContext.mxCall.state = CallState.Connected(newState) + } + /** + * At least one of the connection's ICE transports (RTCIceTransports or RTCDtlsTransports) are in the "new" state, + * and none of them are in one of the following states: "connecting", "checking", "failed", or "disconnected", + * or all of the connection's transports are in the "closed" state. + */ + PeerConnection.PeerConnectionState.NEW, + + /** + * One or more of the ICE transports are currently in the process of establishing a connection; + * that is, their RTCIceConnectionState is either "checking" or "connected", and no transports are in the "failed" state + */ + PeerConnection.PeerConnectionState.CONNECTING -> { + callContext.mxCall.state = CallState.Connected(PeerConnection.PeerConnectionState.CONNECTING) + } + /** + * The RTCPeerConnection is closed. + * This value was in the RTCSignalingState enum (and therefore found by reading the value of the signalingState) + * property until the May 13, 2016 draft of the specification. + */ + PeerConnection.PeerConnectionState.CLOSED, + /** + * At least one of the ICE transports for the connection is in the "disconnected" state and none of + * the other transports are in the state "failed", "connecting", or "checking". + */ + PeerConnection.PeerConnectionState.DISCONNECTED -> { + callContext.mxCall.state = CallState.Connected(newState) + } + null -> { + } + } + } + + override fun onIceCandidate(iceCandidate: IceCandidate) { + Timber.v("## VOIP StreamObserver onIceCandidate: $iceCandidate") + callContext.iceCandidateSource.onNext(iceCandidate) + } + + override fun onDataChannel(dc: DataChannel) { + Timber.v("## VOIP StreamObserver onDataChannel: ${dc.state()}") + } + + override fun onIceConnectionReceivingChange(receiving: Boolean) { + Timber.v("## VOIP StreamObserver onIceConnectionReceivingChange: $receiving") + } + + override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState) { + Timber.v("## VOIP StreamObserver onIceConnectionChange IceConnectionState:$newState") + when (newState) { + /** + * the ICE agent is gathering addresses or is waiting to be given remote candidates through + * calls to RTCPeerConnection.addIceCandidate() (or both). + */ + PeerConnection.IceConnectionState.NEW -> { + } + /** + * The ICE agent has been given one or more remote candidates and is checking pairs of local and remote candidates + * against one another to try to find a compatible match, but has not yet found a pair which will allow + * the peer connection to be made. It's possible that gathering of candidates is also still underway. + */ + PeerConnection.IceConnectionState.CHECKING -> { + } + + /** + * A usable pairing of local and remote candidates has been found for all components of the connection, + * and the connection has been established. + * It's possible that gathering is still underway, and it's also possible that the ICE agent is still checking + * candidates against one another looking for a better connection to use. + */ + PeerConnection.IceConnectionState.CONNECTED -> { + } + /** + * Checks to ensure that components are still connected failed for at least one component of the RTCPeerConnection. + * This is a less stringent test than "failed" and may trigger intermittently and resolve just as spontaneously on less reliable networks, + * or during temporary disconnections. When the problem resolves, the connection may return to the "connected" state. + */ + PeerConnection.IceConnectionState.DISCONNECTED -> { + } + /** + * The ICE candidate has checked all candidates pairs against one another and has failed to find + * compatible matches for all components of the connection. + * It is, however, possible that the ICE agent did find compatible connections for some components. + */ + PeerConnection.IceConnectionState.FAILED -> { + // I should not hangup here.. + // because new candidates could arrive + // callContext.mxCall.hangUp() + } + /** + * The ICE agent has finished gathering candidates, has checked all pairs against one another, and has found a connection for all components. + */ + PeerConnection.IceConnectionState.COMPLETED -> { + } + /** + * The ICE agent for this RTCPeerConnection has shut down and is no longer handling requests. + */ + PeerConnection.IceConnectionState.CLOSED -> { + } + } + } + + override fun onAddStream(stream: MediaStream) { + Timber.v("## VOIP StreamObserver onAddStream: $stream") + executor.execute { + // reportError("Weird-looking stream: " + stream); + if (stream.audioTracks.size > 1 || stream.videoTracks.size > 1) { + Timber.e("## VOIP StreamObserver weird looking stream: $stream") + // TODO maybe do something more?? + callContext.mxCall.hangUp() + return@execute + } + + if (stream.videoTracks.size == 1) { + val remoteVideoTrack = stream.videoTracks.first() + remoteVideoTrack.setEnabled(true) + callContext.remoteVideoTrack = remoteVideoTrack + // sink to renderer if attached + remoteSurfaceRenderer.forEach { it.get()?.let { remoteVideoTrack.addSink(it) } } + } + } + } + + override fun onRemoveStream(stream: MediaStream) { + Timber.v("## VOIP StreamObserver onRemoveStream") + executor.execute { + // remoteSurfaceRenderer?.get()?.let { +// callContext.remoteVideoTrack?.removeSink(it) +// } + remoteSurfaceRenderer + .mapNotNull { it.get() } + .forEach { callContext.remoteVideoTrack?.removeSink(it) } + callContext.remoteVideoTrack = null + } + } + + override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState) { + Timber.v("## VOIP StreamObserver onIceGatheringChange: $newState") + } + + override fun onSignalingChange(newState: PeerConnection.SignalingState) { + Timber.v("## VOIP StreamObserver onSignalingChange: $newState") + } + + override fun onIceCandidatesRemoved(candidates: Array) { + Timber.v("## VOIP StreamObserver onIceCandidatesRemoved: ${candidates.contentToString()}") + } + + override fun onRenegotiationNeeded() { + Timber.v("## VOIP StreamObserver onRenegotiationNeeded") + // Should not do anything, for now we follow a pre-agreed-upon + // signaling/negotiation protocol. + } + + /** + * This happens when a new track of any kind is added to the media stream. + * This event is fired when the browser adds a track to the stream + * (such as when a RTCPeerConnection is renegotiated or a stream being captured using HTMLMediaElement.captureStream() + * gets a new set of tracks because the media element being captured loaded a new source. + */ + override fun onAddTrack(p0: RtpReceiver?, p1: Array?) { + Timber.v("## VOIP StreamObserver onAddTrack") + } + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + inner class CameraRestarter(val cameraId: String, val callId: String) : CameraManager.AvailabilityCallback() { + + override fun onCameraAvailable(cameraId: String) { + if (this.cameraId == cameraId && currentCall?.mxCall?.callId == callId) { + // re-start the capture + // TODO notify that video is enabled + videoCapturer?.startCapture(currentCaptureMode.width, currentCaptureMode.height, currentCaptureMode.fps) + (context.getSystemService(Context.CAMERA_SERVICE) as? CameraManager) + ?.unregisterAvailabilityCallback(this) + } + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/service/CallHeadsUpActionReceiver.kt b/vector/src/main/java/im/vector/riotx/features/call/service/CallHeadsUpActionReceiver.kt new file mode 100644 index 0000000000..199dcd3b14 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/service/CallHeadsUpActionReceiver.kt @@ -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) +// } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/telecom/CallConnection.kt b/vector/src/main/java/im/vector/riotx/features/call/telecom/CallConnection.kt new file mode 100644 index 0000000000..6273abddfd --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/telecom/CallConnection.kt @@ -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().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() + */ + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/telecom/TelecomUtils.kt b/vector/src/main/java/im/vector/riotx/features/call/telecom/TelecomUtils.kt new file mode 100644 index 0000000000..61bb8980bf --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/telecom/TelecomUtils.kt @@ -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 + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/call/telecom/VectorConnectionService.kt b/vector/src/main/java/im/vector/riotx/features/call/telecom/VectorConnectionService.kt new file mode 100644 index 0000000000..8185c9fc49 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/call/telecom/VectorConnectionService.kt @@ -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: + * + *
+ *     * 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.
+ *
+ */ +@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" + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt index 4e5d37af6c..c92c28079f 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt @@ -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() @@ -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) + } + } + } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt index fba4f9e79e..2cca3c013d 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt @@ -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() @@ -76,4 +78,5 @@ sealed class RoomDetailAction : VectorViewModelAction { data class ReRequestKeys(val eventId: String) : RoomDetailAction() object SelectStickerAttachment : RoomDetailAction() + object OpenIntegrationManager: RoomDetailAction() } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 0ce99ce1dd..4995c16bf9 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -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 @@ -134,7 +144,6 @@ import im.vector.riotx.features.crypto.verification.VerificationBottomSheet import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.composer.TextComposerView import im.vector.riotx.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet -import im.vector.riotx.features.home.room.detail.sticker.StickerPickerConstants import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.action.EventSharedAction import im.vector.riotx.features.home.room.detail.timeline.action.MessageActionsBottomSheet @@ -149,6 +158,7 @@ import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.riotx.features.home.room.detail.timeline.reactions.ViewReactionsBottomSheet import im.vector.riotx.features.home.room.detail.widget.RoomWidgetsBannerView import im.vector.riotx.features.home.room.detail.widget.RoomWidgetsBottomSheet +import im.vector.riotx.features.home.room.detail.widget.WidgetRequestCodes import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.html.PillImageSpan import im.vector.riotx.features.invite.VectorInviteView @@ -159,6 +169,7 @@ import im.vector.riotx.features.permalink.NavigationInterceptor import im.vector.riotx.features.permalink.PermalinkHandler import im.vector.riotx.features.reactions.EmojiReactionPickerActivity import im.vector.riotx.features.settings.VectorPreferences +import im.vector.riotx.features.settings.VectorSettingsActivity import im.vector.riotx.features.share.SharedData import im.vector.riotx.features.themes.ThemeUtils import im.vector.riotx.features.widgets.WidgetActivity @@ -195,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. * @@ -242,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 @@ -254,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) @@ -266,6 +286,7 @@ class RoomDetailFragment @Inject constructor( setupInviteView() setupNotificationView() setupJumpToReadMarkerView() + setupActiveCallView() setupJumpToBottomView() setupWidgetsBannerView() @@ -280,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) } @@ -302,22 +330,33 @@ class RoomDetailFragment @Inject constructor( roomDetailViewModel.observeViewEvents { when (it) { - is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable) - is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds) - is RoomDetailViewEvents.ActionSuccess -> displayRoomDetailActionSuccess(it) - is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it) - is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message, Snackbar.LENGTH_LONG) - is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it) - is RoomDetailViewEvents.FileTooBigError -> displayFileTooBigError(it) - is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it) - is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) - is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(it) - RoomDetailViewEvents.DisplayPromptForIntegrationManager -> displayPromptForIntegrationManager() - is RoomDetailViewEvents.OpenStickerPicker -> openStickerPicker(it) + is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable) + is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds) + is RoomDetailViewEvents.ActionSuccess -> displayRoomDetailActionSuccess(it) + is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it) + is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message, Snackbar.LENGTH_LONG) + is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it) + is RoomDetailViewEvents.FileTooBigError -> displayFileTooBigError(it) + is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it) + is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) + is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(it) + RoomDetailViewEvents.DisplayPromptForIntegrationManager -> displayPromptForIntegrationManager() + is RoomDetailViewEvents.OpenStickerPicker -> openStickerPicker(it) + is RoomDetailViewEvents.DisplayEnableIntegrationsWarning -> displayDisabledIntegrationDialog() + is RoomDetailViewEvents.OpenIntegrationManager -> openIntegrationManager() }.exhaustive } } + private fun openIntegrationManager(screen: String? = null) { + navigator.openIntegrationManager( + fragment = this, + roomId = roomDetailArgs.roomId, + integId = null, + screen = screen + ) + } + private fun setupWidgetsBannerView() { roomWidgetsBannerView.callback = this } @@ -334,10 +373,7 @@ class RoomDetailFragment @Inject constructor( .setView(v) .setPositiveButton(R.string.yes) { _, _ -> // Open integration manager, to the sticker installation page - navigator.openIntegrationManager( - context = requireContext(), - roomId = roomDetailArgs.roomId, - integId = null, + openIntegrationManager( screen = WidgetType.StickerPicker.preferred ) } @@ -371,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() @@ -380,6 +417,7 @@ class RoomDetailFragment @Inject constructor( } override fun onDestroy() { + activeCallViewHolder.unBind(webRtcPeerConnectionManager) roomDetailViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState) super.onDestroy() } @@ -409,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) { @@ -469,13 +516,67 @@ class RoomDetailFragment @Inject constructor( true } R.id.open_matrix_apps -> { - navigator.openIntegrationManager(requireContext(), roomDetailArgs.roomId, null, null) + 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) } } + private fun displayDisabledIntegrationDialog() { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.disabled_integration_dialog_title) + .setMessage(R.string.disabled_integration_dialog_content) + .setPositiveButton(R.string.settings) { _, _ -> + navigator.openSettings(requireActivity(), VectorSettingsActivity.EXTRA_DIRECT_ACCESS_GENERAL) + } + .setNegativeButton(R.string.cancel, null) + .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() @@ -548,16 +649,16 @@ class RoomDetailFragment @Inject constructor( val hasBeenHandled = attachmentsHelper.onActivityResult(requestCode, resultCode, data) if (!hasBeenHandled && resultCode == RESULT_OK && data != null) { when (requestCode) { - AttachmentsPreviewActivity.REQUEST_CODE -> { + AttachmentsPreviewActivity.REQUEST_CODE -> { val sendData = AttachmentsPreviewActivity.getOutput(data) val keepOriginalSize = AttachmentsPreviewActivity.getKeepOriginalSize(data) roomDetailViewModel.handle(RoomDetailAction.SendMedia(sendData, !keepOriginalSize)) } - REACTION_SELECT_REQUEST_CODE -> { + REACTION_SELECT_REQUEST_CODE -> { val (eventId, reaction) = EmojiReactionPickerActivity.getOutput(data) ?: return roomDetailViewModel.handle(RoomDetailAction.SendReaction(eventId, reaction)) } - StickerPickerConstants.STICKER_PICKER_REQUEST_CODE -> { + WidgetRequestCodes.STICKER_PICKER_REQUEST_CODE -> { val content = WidgetActivity.getOutput(data).toModel() ?: return roomDetailViewModel.handle(RoomDetailAction.SendSticker(content)) } @@ -719,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) { @@ -1070,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 @@ -1453,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) + } + } + } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewEvents.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewEvents.kt index 73ce95eda2..560da2e116 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewEvents.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewEvents.kt @@ -52,8 +52,12 @@ sealed class RoomDetailViewEvents : VectorViewEvents { object DisplayPromptForIntegrationManager: RoomDetailViewEvents() + object DisplayEnableIntegrationsWarning: RoomDetailViewEvents() + data class OpenStickerPicker(val widget: Widget): RoomDetailViewEvents() + object OpenIntegrationManager: RoomDetailViewEvents() + object MessageSent : SendMessageResult() data class JoinRoomCommandSuccess(val roomId: String) : SendMessageResult() class SlashCommandError(val command: Command) : SendMessageResult() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index cdea939be3..529218216c 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -30,6 +30,7 @@ import com.squareup.inject.assisted.AssistedInject import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixPatterns import im.vector.matrix.android.api.NoOpMatrixCallback +import im.vector.matrix.android.api.extensions.orFalse import im.vector.matrix.android.api.query.QueryStringValue import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.events.model.EventType @@ -68,6 +69,7 @@ import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.resources.UserPreferencesProvider import im.vector.riotx.core.utils.subscribeLogError +import im.vector.riotx.features.call.WebRtcPeerConnectionManager import im.vector.riotx.features.command.CommandParser import im.vector.riotx.features.command.ParsedCommand import im.vector.riotx.features.crypto.verification.SupportedVerificationMethodsProvider @@ -82,7 +84,9 @@ import io.reactivex.Observable import io.reactivex.functions.BiFunction import io.reactivex.rxkotlin.subscribeBy import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import timber.log.Timber @@ -100,7 +104,8 @@ class RoomDetailViewModel @AssistedInject constructor( private val session: Session, private val supportedVerificationMethodsProvider: SupportedVerificationMethodsProvider, private val stickerPickerActionHandler: StickerPickerActionHandler, - private val roomSummaryHolder: RoomSummaryHolder + private val roomSummaryHolder: RoomSummaryHolder, + private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager ) : VectorViewModel(initialState), Timeline.Listener { private val room = session.getRoom(initialState.roomId)!! @@ -125,8 +130,7 @@ class RoomDetailViewModel @AssistedInject constructor( } private var timelineEvents = PublishRelay.create>() - var timeline = room.createTimeline(eventId, timelineSettings) - private set + val timeline = room.createTimeline(eventId, timelineSettings) // Slot to keep a pending action during permission request var pendingAction: RoomDetailAction? = null @@ -218,6 +222,8 @@ class RoomDetailViewModel @AssistedInject constructor( } } + fun getOtherUserIds() = room.roomSummary()?.otherMemberIds + override fun handle(action: RoomDetailAction) { when (action) { is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action) @@ -257,13 +263,26 @@ class RoomDetailViewModel @AssistedInject constructor( is RoomDetailAction.ResumeVerification -> handleResumeRequestVerification(action) is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() - } + is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() + is RoomDetailAction.StartCall -> handleStartCall(action) + is RoomDetailAction.EndCall -> handleEndCall() + }.exhaustive } private fun handleSendSticker(action: RoomDetailAction.SendSticker) { room.sendEvent(EventType.STICKER, action.stickerContent.toContent()) } + private fun handleStartCall(action: RoomDetailAction.StartCall) { + room.roomSummary()?.otherMemberIds?.firstOrNull()?.let { + webRtcPeerConnectionManager.startOutgoingCall(room.roomId, it, action.isVideo) + } + } + + private fun handleEndCall() { + webRtcPeerConnectionManager.endCall() + } + private fun handleSelectStickerAttachment() { viewModelScope.launch { val viewEvent = stickerPickerActionHandler.handle() @@ -271,6 +290,19 @@ class RoomDetailViewModel @AssistedInject constructor( } } + private fun handleOpenIntegrationManager() { + viewModelScope.launch { + val viewEvent = withContext(Dispatchers.Default) { + if (isIntegrationEnabled()) { + RoomDetailViewEvents.OpenIntegrationManager + } else { + RoomDetailViewEvents.DisplayEnableIntegrationsWarning + } + } + _viewEvents.post(viewEvent) + } + } + private fun startTrackingUnreadMessages() { trackUnreadMessages.set(true) setState { copy(canShowJumpToReadMarker = false) } @@ -370,13 +402,18 @@ class RoomDetailViewModel @AssistedInject constructor( } } + private fun isIntegrationEnabled() = session.integrationManagerService().isIntegrationEnabled() + fun isMenuItemVisible(@IdRes itemId: Int) = when (itemId) { R.id.clear_message_queue -> - /* For now always disable on production, worker cancellation is not working properly */ + // For now always disable when not in developer mode, worker cancellation is not working properly timeline.pendingEventCount() > 0 && vectorPreferences.developerMode() R.id.resend_all -> timeline.failedToDeliverEventCount() > 0 R.id.clear_all -> timeline.failedToDeliverEventCount() > 0 - R.id.open_matrix_apps -> session.integrationManagerService().isIntegrationEnabled() + R.id.open_matrix_apps -> true + R.id.voice_call, + R.id.video_call -> room.canStartCall() && webRtcPeerConnectionManager.currentCall == null + R.id.hangup_call -> webRtcPeerConnectionManager.currentCall != null else -> false } @@ -1003,8 +1040,7 @@ class RoomDetailViewModel @AssistedInject constructor( .unwrap() .execute { async -> copy( - asyncRoomSummary = async, - typingRoomMembers = typingRoomMembers + asyncRoomSummary = async ) } } @@ -1075,6 +1111,7 @@ class RoomDetailViewModel @AssistedInject constructor( private fun observeSummaryState() { asyncSubscribe(RoomDetailViewState::asyncRoomSummary) { summary -> + roomSummaryHolder.set(summary) if (summary.membership == Membership.INVITE) { summary.inviterId?.let { inviterId -> diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt index 44e42e761c..b7268e7c61 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt @@ -57,7 +57,6 @@ data class RoomDetailViewState( val asyncInviter: Async = Uninitialized, val asyncRoomSummary: Async = Uninitialized, val activeRoomWidgets: Async> = Uninitialized, - val typingRoomMembers: List? = null, val typingMessage: String? = null, val sendMode: SendMode = SendMode.REGULAR(""), val tombstoneEvent: Event? = null, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt index 33e18595a0..4616cb4b25 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomMessageTouchHelperCallback.kt @@ -33,6 +33,7 @@ import com.airbnb.epoxy.EpoxyTouchHelperCallback import com.airbnb.epoxy.EpoxyViewHolder import timber.log.Timber import kotlin.math.abs +import kotlin.math.min class RoomMessageTouchHelperCallback(private val context: Context, @DrawableRes actionIcon: Int, @@ -92,7 +93,7 @@ class RoomMessageTouchHelperCallback(private val context: Context, setTouchListener(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) } val size = triggerDistance - if (Math.abs(viewHolder.itemView.translationX) < size || dX > this.dX /*going back*/) { + if (abs(viewHolder.itemView.translationX) < size || dX > this.dX /*going back*/) { super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) this.dX = dX startTracking = true @@ -127,9 +128,9 @@ class RoomMessageTouchHelperCallback(private val context: Context, private fun drawReplyButton(canvas: Canvas, itemView: View) { // Timber.v("drawReplyButton") - val translationX = Math.abs(itemView.translationX) + val translationX = abs(itemView.translationX) val newTime = System.currentTimeMillis() - val dt = Math.min(17, newTime - lastReplyButtonAnimationTime) + val dt = min(17, newTime - lastReplyButtonAnimationTime) lastReplyButtonAnimationTime = newTime val showing = translationX >= minShowDistance if (showing) { @@ -163,10 +164,10 @@ class RoomMessageTouchHelperCallback(private val context: Context, } else { 1.2f - 0.2f * ((replyButtonProgress - 0.8f) / 0.2f) } - alpha = Math.min(255f, 255 * (replyButtonProgress / 0.8f)).toInt() + alpha = min(255f, 255 * (replyButtonProgress / 0.8f)).toInt() } else { scale = replyButtonProgress - alpha = Math.min(255f, 255 * replyButtonProgress).toInt() + alpha = min(255f, 255 * replyButtonProgress).toInt() } imageDrawable.alpha = alpha diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/sticker/StickerPickerActionHandler.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/sticker/StickerPickerActionHandler.kt index 3b939892b5..ebae583d8c 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/sticker/StickerPickerActionHandler.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/sticker/StickerPickerActionHandler.kt @@ -27,6 +27,10 @@ class StickerPickerActionHandler @Inject constructor(private val session: Sessio suspend fun handle(): RoomDetailViewEvents = withContext(Dispatchers.Default) { // Search for the sticker picker widget in the user account + val integrationsEnabled = session.integrationManagerService().isIntegrationEnabled() + if (!integrationsEnabled) { + return@withContext RoomDetailViewEvents.DisplayEnableIntegrationsWarning + } val stickerWidget = session.widgetService().getUserWidgets(WidgetType.StickerPicker.values()).firstOrNull { it.isActive } if (stickerWidget == null || stickerWidget.computedUrl.isNullOrBlank()) { RoomDetailViewEvents.DisplayPromptForIntegrationManager diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionState.kt index c12306c2d0..4eeed1292e 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionState.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionState.kt @@ -52,7 +52,7 @@ data class MessageActionState( val actions: List = emptyList(), val expendedReportContentMenu: Boolean = false, val actionPermissions: ActionPermissions = ActionPermissions() - ) : MvRxState { +) : MvRxState { constructor(args: TimelineEventFragmentArgs) : this(roomId = args.roomId, eventId = args.eventId, informationData = args.informationData) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index 909169e7b0..6c192105d7 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -45,9 +45,9 @@ import im.vector.riotx.core.platform.EmptyViewEvents import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.features.home.room.detail.timeline.format.NoticeEventFormatter -import im.vector.riotx.features.powerlevel.PowerLevelsObservableFactory import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.html.VectorHtmlCompressor +import im.vector.riotx.features.powerlevel.PowerLevelsObservableFactory import im.vector.riotx.features.reactions.data.EmojiDataSource import im.vector.riotx.features.settings.VectorPreferences @@ -184,6 +184,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted EventType.STATE_ROOM_CANONICAL_ALIAS, EventType.STATE_ROOM_HISTORY_VISIBILITY, EventType.CALL_INVITE, + EventType.CALL_CANDIDATES, EventType.CALL_HANGUP, EventType.CALL_ANSWER -> { noticeEventFormatter.format(timelineEvent) @@ -356,6 +357,9 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted private fun canRedact(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean { // Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment if (event.root.getClearType() != EventType.MESSAGE) return false + // Message sent by the current user can always be redacted + if (event.root.senderId == session.myUserId) return true + // Check permission for messages sent by other users return actionPermissions.canRedact } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index c81a945bc7..462caf8e97 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -80,12 +80,15 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.KEY_VERIFICATION_START, EventType.KEY_VERIFICATION_KEY, EventType.KEY_VERIFICATION_READY, - EventType.KEY_VERIFICATION_MAC -> { + EventType.KEY_VERIFICATION_MAC, + EventType.CALL_CANDIDATES -> { // TODO These are not filtered out by timeline when encrypted // For now manually ignore if (userPreferencesProvider.shouldShowHiddenEvents()) { noticeItemFactory.create(event, highlight, callback) - } else null + } else { + null + } } EventType.KEY_VERIFICATION_CANCEL, EventType.KEY_VERIFICATION_DONE -> { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt index 1d178054ac..c931c155b1 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt @@ -29,7 +29,6 @@ import me.gujun.android.span.span import javax.inject.Inject class DisplayableEventFormatter @Inject constructor( -// private val sessionHolder: ActiveSessionHolder, private val stringProvider: StringProvider, private val colorProvider: ColorProvider, private val noticeEventFormatter: NoticeEventFormatter diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt index 33079a8f33..37debace89 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt @@ -68,6 +68,7 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) EventType.STATE_ROOM_POWER_LEVELS -> formatRoomPowerLevels(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) EventType.CALL_INVITE, + EventType.CALL_CANDIDATES, EventType.CALL_HANGUP, EventType.CALL_ANSWER -> formatCallEvent(type, timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) EventType.MESSAGE, @@ -237,9 +238,9 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active private fun formatCallEvent(type: String, event: Event, senderName: String?): CharSequence? { return when (type) { - EventType.CALL_INVITE -> { + EventType.CALL_INVITE -> { val content = event.getClearContent().toModel() ?: return null - val isVideoCall = content.offer.sdp == CallInviteContent.Offer.SDP_VIDEO + val isVideoCall = content.offer?.sdp == CallInviteContent.Offer.SDP_VIDEO return if (isVideoCall) { if (event.isSentByCurrentUser()) { sp.getString(R.string.notice_placed_video_call_by_you) @@ -254,19 +255,25 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active } } } - EventType.CALL_ANSWER -> + EventType.CALL_ANSWER -> if (event.isSentByCurrentUser()) { sp.getString(R.string.notice_answered_call_by_you) } else { sp.getString(R.string.notice_answered_call, senderName) } - EventType.CALL_HANGUP -> + EventType.CALL_HANGUP -> if (event.isSentByCurrentUser()) { sp.getString(R.string.notice_ended_call_by_you) } else { sp.getString(R.string.notice_ended_call, senderName) } - else -> null + EventType.CALL_CANDIDATES -> + if (event.isSentByCurrentUser()) { + sp.getString(R.string.notice_call_candidates_by_you) + } else { + sp.getString(R.string.notice_call_candidates, senderName) + } + else -> null } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/PollResultLineView.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/PollResultLineView.kt index a0ad3466f7..c52b863658 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/PollResultLineView.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/PollResultLineView.kt @@ -64,8 +64,8 @@ class PollResultLineView @JvmOverloads constructor( set(value) { field = value // Text in main color - labelTextView.setTypeface(labelTextView.getTypeface(), if (value) Typeface.BOLD else Typeface.NORMAL) - percentTextView.setTypeface(percentTextView.getTypeface(), if (value) Typeface.BOLD else Typeface.NORMAL) + labelTextView.setTypeface(labelTextView.typeface, if (value) Typeface.BOLD else Typeface.NORMAL) + percentTextView.setTypeface(percentTextView.typeface, if (value) Typeface.BOLD else Typeface.NORMAL) } init { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/sticker/StickerPickerConstants.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/widget/WidgetRequestCodes.kt similarity index 82% rename from vector/src/main/java/im/vector/riotx/features/home/room/detail/sticker/StickerPickerConstants.kt rename to vector/src/main/java/im/vector/riotx/features/home/room/detail/widget/WidgetRequestCodes.kt index 8068eafc85..6fdfa598e4 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/sticker/StickerPickerConstants.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/widget/WidgetRequestCodes.kt @@ -14,8 +14,9 @@ * limitations under the License. */ -package im.vector.riotx.features.home.room.detail.sticker +package im.vector.riotx.features.home.room.detail.widget -object StickerPickerConstants { +object WidgetRequestCodes { const val STICKER_PICKER_REQUEST_CODE = 16000 + const val INTEGRATION_MANAGER_REQUEST_CODE = 16001 } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomCategoryItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomCategoryItem.kt index 0c4ae4d61d..c3b1dc27eb 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomCategoryItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomCategoryItem.kt @@ -44,7 +44,7 @@ abstract class RoomCategoryItem : VectorEpoxyModel() { DrawableCompat.setTint(it, tintColor) } holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted)) - holder.titleView.setCompoundDrawablesWithIntrinsicBounds(expandedArrowDrawable, null, null, null) + holder.titleView.setCompoundDrawablesWithIntrinsicBounds(null, null, expandedArrowDrawable, null) holder.titleView.text = title holder.rootView.setOnClickListener { listener?.invoke() } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryController.kt index c9724bf971..b06cb8a4bb 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryController.kt @@ -141,7 +141,7 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri val showHighlighted = summaries.any { it.highlightCount > 0 } roomCategoryItem { id(titleRes) - title(stringProvider.getString(titleRes).toUpperCase()) + title(stringProvider.getString(titleRes)) expanded(isExpanded) unreadNotificationCount(unreadCount) showHighlighted(showHighlighted) diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt index f15cb801ed..abdea9698f 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt @@ -48,6 +48,7 @@ import im.vector.riotx.core.extensions.configureAndStart import im.vector.riotx.core.extensions.exhaustive import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.features.call.WebRtcPeerConnectionManager import im.vector.riotx.features.notifications.PushRuleTriggerListener import im.vector.riotx.features.session.SessionListener import im.vector.riotx.features.signout.soft.SoftLogoutActivity @@ -66,7 +67,8 @@ class LoginViewModel @AssistedInject constructor( private val homeServerConnectionConfigFactory: HomeServerConnectionConfigFactory, private val sessionListener: SessionListener, private val reAuthHelper: ReAuthHelper, - private val stringProvider: StringProvider) + private val stringProvider: StringProvider, + private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager) : VectorViewModel(initialState) { @AssistedInject.Factory @@ -613,6 +615,7 @@ class LoginViewModel @AssistedInject constructor( private fun onSessionCreated(session: Session) { activeSessionHolder.setActiveSession(session) session.configureAndStart(applicationContext, pushRuleTriggerListener, sessionListener) + session.callSignalingService().addCallListener(webRtcPeerConnectionManager) setState { copy( asyncLoginAction = Success(Unit) diff --git a/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsFragment.kt index 88f4fc2f5f..49abc9ba81 100755 --- a/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsFragment.kt @@ -21,17 +21,17 @@ import android.os.Parcelable import android.view.View import butterknife.OnClick import com.airbnb.mvrx.args +import im.vector.matrix.android.internal.auth.registration.LocalizedFlowDataLoginTerms import im.vector.riotx.R import im.vector.riotx.core.extensions.cleanup import im.vector.riotx.core.extensions.configureWith import im.vector.riotx.core.extensions.toReducedUrl -import im.vector.riotx.core.utils.openUrlInExternalBrowser +import im.vector.riotx.core.utils.openUrlInChromeCustomTab import im.vector.riotx.features.login.AbstractLoginFragment import im.vector.riotx.features.login.LoginAction import im.vector.riotx.features.login.LoginViewState import kotlinx.android.parcel.Parcelize import kotlinx.android.synthetic.main.fragment_login_terms.* -import im.vector.matrix.android.internal.auth.registration.LocalizedFlowDataLoginTerms import javax.inject.Inject @Parcelize @@ -95,7 +95,7 @@ class LoginTermsFragment @Inject constructor( localizedFlowDataLoginTerms.localizedUrl ?.takeIf { it.isNotBlank() } ?.let { - openUrlInExternalBrowser(requireContext(), it) + openUrlInChromeCustomTab(requireContext(), null, it) } } diff --git a/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt index 6a0094520a..a909e5becf 100644 --- a/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt @@ -30,8 +30,8 @@ import androidx.fragment.app.Fragment import im.vector.matrix.android.api.session.crypto.verification.IncomingSasVerificationTransaction import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom import im.vector.matrix.android.api.session.terms.TermsService -import im.vector.matrix.android.api.util.MatrixItem import im.vector.matrix.android.api.session.widgets.model.Widget +import im.vector.matrix.android.api.util.MatrixItem import im.vector.riotx.R import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.core.error.fatalError @@ -46,7 +46,7 @@ import im.vector.riotx.features.crypto.verification.VerificationBottomSheet import im.vector.riotx.features.debug.DebugMenuActivity import im.vector.riotx.features.home.room.detail.RoomDetailActivity import im.vector.riotx.features.home.room.detail.RoomDetailArgs -import im.vector.riotx.features.home.room.detail.sticker.StickerPickerConstants +import im.vector.riotx.features.home.room.detail.widget.WidgetRequestCodes import im.vector.riotx.features.home.room.filtered.FilteredRoomsActivity import im.vector.riotx.features.invite.InviteUsersToRoomActivity import im.vector.riotx.features.media.BigImageViewerActivity @@ -230,12 +230,13 @@ class DefaultNavigator @Inject constructor( override fun openStickerPicker(fragment: Fragment, roomId: String, widget: Widget, requestCode: Int) { val widgetArgs = widgetArgsBuilder.buildStickerPickerArgs(roomId, widget) val intent = WidgetActivity.newIntent(fragment.requireContext(), widgetArgs) - fragment.startActivityForResult(intent, StickerPickerConstants.STICKER_PICKER_REQUEST_CODE) + fragment.startActivityForResult(intent, WidgetRequestCodes.STICKER_PICKER_REQUEST_CODE) } - override fun openIntegrationManager(context: Context, roomId: String, integId: String?, screen: String?) { + override fun openIntegrationManager(fragment: Fragment, roomId: String, integId: String?, screen: String?) { val widgetArgs = widgetArgsBuilder.buildIntegrationManagerArgs(roomId, integId, screen) - context.startActivity(WidgetActivity.newIntent(context, widgetArgs)) + val intent = WidgetActivity.newIntent(fragment.requireContext(), widgetArgs) + fragment.startActivityForResult(intent, WidgetRequestCodes.INTEGRATION_MANAGER_REQUEST_CODE) } override fun openRoomWidget(context: Context, roomId: String, widget: Widget) { diff --git a/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt index 35ace87b6b..916a46c041 100644 --- a/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt +++ b/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt @@ -25,7 +25,7 @@ import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom import im.vector.matrix.android.api.session.terms.TermsService import im.vector.matrix.android.api.util.MatrixItem import im.vector.matrix.android.api.session.widgets.model.Widget -import im.vector.riotx.features.home.room.detail.sticker.StickerPickerConstants +import im.vector.riotx.features.home.room.detail.widget.WidgetRequestCodes import im.vector.riotx.features.media.ImageContentRenderer import im.vector.riotx.features.media.VideoContentRenderer import im.vector.riotx.features.settings.VectorSettingsActivity @@ -85,9 +85,9 @@ interface Navigator { fun openStickerPicker(fragment: Fragment, roomId: String, widget: Widget, - requestCode: Int = StickerPickerConstants.STICKER_PICKER_REQUEST_CODE) + requestCode: Int = WidgetRequestCodes.STICKER_PICKER_REQUEST_CODE) - fun openIntegrationManager(context: Context, roomId: String, integId: String?, screen: String?) + fun openIntegrationManager(fragment: Fragment, roomId: String, integId: String?, screen: String?) fun openRoomWidget(context: Context, roomId: String, widget: Widget) diff --git a/vector/src/main/java/im/vector/riotx/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/riotx/features/notifications/NotificationUtils.kt index 178235ab5f..9dc518bbc9 100755 --- a/vector/src/main/java/im/vector/riotx/features/notifications/NotificationUtils.kt +++ b/vector/src/main/java/im/vector/riotx/features/notifications/NotificationUtils.kt @@ -35,11 +35,14 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.app.RemoteInput import androidx.core.app.TaskStackBuilder import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.IconCompat import androidx.fragment.app.Fragment import im.vector.riotx.BuildConfig import im.vector.riotx.R import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.utils.startNotificationChannelSettingsIntent +import im.vector.riotx.features.call.VectorCallActivity +import im.vector.riotx.features.call.service.CallHeadsUpActionReceiver import im.vector.riotx.features.home.HomeActivity import im.vector.riotx.features.home.room.detail.RoomDetailActivity import im.vector.riotx.features.home.room.detail.RoomDetailArgs @@ -263,13 +266,13 @@ class NotificationUtils @Inject constructor(private val context: Context, */ @SuppressLint("NewApi") fun buildIncomingCallNotification(isVideo: Boolean, - roomName: String, - matrixId: String, + otherUserId: String, + roomId: String, callId: String): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) val builder = NotificationCompat.Builder(context, CALL_NOTIFICATION_CHANNEL_ID) - .setContentTitle(ensureTitleNotEmpty(roomName)) + .setContentTitle(ensureTitleNotEmpty(otherUserId)) .apply { if (isVideo) { setContentText(stringProvider.getString(R.string.incoming_video_call)) @@ -280,28 +283,124 @@ class NotificationUtils @Inject constructor(private val context: Context, .setSmallIcon(R.drawable.incoming_call_notification_transparent) .setCategory(NotificationCompat.CATEGORY_CALL) .setLights(accentColor, 500, 500) + .setOngoing(true) // Compat: Display the incoming call notification on the lock screen - builder.priority = NotificationCompat.PRIORITY_MAX + builder.priority = NotificationCompat.PRIORITY_HIGH - // clear the activity stack to home activity - val intent = Intent(context, HomeActivity::class.java) - .setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) - // TODO .putExtra(VectorHomeActivity.EXTRA_CALL_SESSION_ID, matrixId) - // TODO .putExtra(VectorHomeActivity.EXTRA_CALL_ID, callId) - - // Recreate the back stack - val stackBuilder = TaskStackBuilder.create(context) - .addParentStack(HomeActivity::class.java) - .addNextIntent(intent) - - // android 4.3 issue - // use a generator for the private requestCode. - // When using 0, the intent is not created/launched when the user taps on the notification. // - val pendingIntent = stackBuilder.getPendingIntent(Random.nextInt(1000), PendingIntent.FLAG_UPDATE_CURRENT) + val requestId = Random.nextInt(1000) +// val pendingIntent = stackBuilder.getPendingIntent(requestId, PendingIntent.FLAG_UPDATE_CURRENT) - builder.setContentIntent(pendingIntent) + val contentIntent = VectorCallActivity.newIntent( + context = context, + callId = callId, + roomId = roomId, + otherUserId = otherUserId, + isIncomingCall = true, + isVideoCall = isVideo, + mode = VectorCallActivity.INCOMING_RINGING + ).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + data = Uri.parse("foobar://$callId") + } + val contentPendingIntent = PendingIntent.getActivity(context, System.currentTimeMillis().toInt(), contentIntent, 0) + + val answerCallPendingIntent = TaskStackBuilder.create(context) + .addNextIntentWithParentStack(Intent(context, HomeActivity::class.java)) + .addNextIntent(VectorCallActivity.newIntent( + context = context, + callId = callId, + roomId = roomId, + otherUserId = otherUserId, + isIncomingCall = true, + isVideoCall = isVideo, + mode = VectorCallActivity.INCOMING_ACCEPT) + ) + .getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT) + + val rejectCallActionReceiver = Intent(context, CallHeadsUpActionReceiver::class.java).apply { + putExtra(CallHeadsUpActionReceiver.EXTRA_CALL_ACTION_KEY, CallHeadsUpActionReceiver.CALL_ACTION_REJECT) + } + // val answerCallPendingIntent = PendingIntent.getBroadcast(context, requestId, answerCallActionReceiver, PendingIntent.FLAG_UPDATE_CURRENT) + val rejectCallPendingIntent = PendingIntent.getBroadcast( + context, + requestId + 1, + rejectCallActionReceiver, + PendingIntent.FLAG_UPDATE_CURRENT + ) + + builder.addAction( + NotificationCompat.Action( + R.drawable.ic_call, + // IconCompat.createWithResource(applicationContext, R.drawable.ic_call) + // .setTint(ContextCompat.getColor(applicationContext, R.color.riotx_positive_accent)), + context.getString(R.string.call_notification_answer), + answerCallPendingIntent + ) + ) + + builder.addAction( + NotificationCompat.Action( + IconCompat.createWithResource(context, R.drawable.ic_call_end).setTint(ContextCompat.getColor(context, R.color.riotx_notice)), + context.getString(R.string.call_notification_reject), + rejectCallPendingIntent) + ) + + builder.setFullScreenIntent(contentPendingIntent, true) + + return builder.build() + } + + fun buildOutgoingRingingCallNotification(isVideo: Boolean, + otherUserId: String, + roomId: String, + callId: String): Notification { + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + + val builder = NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID) + .setContentTitle(ensureTitleNotEmpty(otherUserId)) + .apply { + setContentText(stringProvider.getString(R.string.call_ring)) + } + .setSmallIcon(R.drawable.incoming_call_notification_transparent) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setLights(accentColor, 500, 500) + .setOngoing(true) + + val requestId = Random.nextInt(1000) + + val contentIntent = VectorCallActivity.newIntent( + context = context, + callId = callId, + roomId = roomId, + otherUserId = otherUserId, + isIncomingCall = true, + isVideoCall = isVideo, + mode = null).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + data = Uri.parse("foobar://$callId") + } + val contentPendingIntent = PendingIntent.getActivity(context, System.currentTimeMillis().toInt(), contentIntent, 0) + + val rejectCallActionReceiver = Intent(context, CallHeadsUpActionReceiver::class.java).apply { + putExtra(CallHeadsUpActionReceiver.EXTRA_CALL_ACTION_KEY, CallHeadsUpActionReceiver.CALL_ACTION_REJECT) + } + + val rejectCallPendingIntent = PendingIntent.getBroadcast( + context, + requestId + 1, + rejectCallActionReceiver, + PendingIntent.FLAG_UPDATE_CURRENT + ) + + builder.addAction( + NotificationCompat.Action( + IconCompat.createWithResource(context, R.drawable.ic_call_end).setTint(ContextCompat.getColor(context, R.color.riotx_notice)), + context.getString(R.string.call_notification_hangup), + rejectCallPendingIntent) + ) + builder.setContentIntent(contentPendingIntent) return builder.build() } @@ -321,8 +420,9 @@ class NotificationUtils @Inject constructor(private val context: Context, roomName: String, roomId: String, matrixId: String, - callId: String): Notification { - val builder = NotificationCompat.Builder(context, CALL_NOTIFICATION_CHANNEL_ID) + callId: String, + fromBg: Boolean = false): Notification { + val builder = NotificationCompat.Builder(context, if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID) .setContentTitle(ensureTitleNotEmpty(roomName)) .apply { if (isVideo) { @@ -334,31 +434,37 @@ class NotificationUtils @Inject constructor(private val context: Context, .setSmallIcon(R.drawable.incoming_call_notification_transparent) .setCategory(NotificationCompat.CATEGORY_CALL) - // Display the pending call notification on the lock screen - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - builder.priority = NotificationCompat.PRIORITY_MAX + if (fromBg) { + builder.priority = NotificationCompat.PRIORITY_LOW + builder.setOngoing(true) } - /* TODO - // Build the pending intent for when the notification is clicked - val roomIntent = Intent(context, VectorRoomActivity::class.java) - .putExtra(VectorRoomActivity.EXTRA_ROOM_ID, roomId) - .putExtra(VectorRoomActivity.EXTRA_MATRIX_ID, matrixId) - .putExtra(VectorRoomActivity.EXTRA_START_CALL_ID, callId) + val rejectCallActionReceiver = Intent(context, CallHeadsUpActionReceiver::class.java).apply { + data = Uri.parse("mxcall://end?$callId") + putExtra(CallHeadsUpActionReceiver.EXTRA_CALL_ACTION_KEY, CallHeadsUpActionReceiver.CALL_ACTION_REJECT) + } - // Recreate the back stack - val stackBuilder = TaskStackBuilder.create(context) - .addParentStack(VectorRoomActivity::class.java) - .addNextIntent(roomIntent) + val rejectCallPendingIntent = PendingIntent.getBroadcast( + context, + System.currentTimeMillis().toInt(), + rejectCallActionReceiver, + PendingIntent.FLAG_UPDATE_CURRENT + ) - // android 4.3 issue - // use a generator for the private requestCode. - // When using 0, the intent is not created/launched when the user taps on the notification. - // - val pendingIntent = stackBuilder.getPendingIntent(Random().nextInt(1000), PendingIntent.FLAG_UPDATE_CURRENT) + builder.addAction( + NotificationCompat.Action( + IconCompat.createWithResource(context, R.drawable.ic_call_end).setTint(ContextCompat.getColor(context, R.color.riotx_notice)), + context.getString(R.string.call_notification_hangup), + rejectCallPendingIntent) + ) - builder.setContentIntent(pendingIntent) - */ + val contentPendingIntent = TaskStackBuilder.create(context) + .addNextIntentWithParentStack(Intent(context, HomeActivity::class.java)) + // TODO other userId + .addNextIntent(VectorCallActivity.newIntent(context, callId, roomId, "otherUserId", true, isVideo, null)) + .getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT) + + builder.setContentIntent(contentPendingIntent) return builder.build() } diff --git a/vector/src/main/java/im/vector/riotx/features/popup/PopupAlertManager.kt b/vector/src/main/java/im/vector/riotx/features/popup/PopupAlertManager.kt index 1876d83617..78a0cece41 100644 --- a/vector/src/main/java/im/vector/riotx/features/popup/PopupAlertManager.kt +++ b/vector/src/main/java/im/vector/riotx/features/popup/PopupAlertManager.kt @@ -15,6 +15,7 @@ */ package im.vector.riotx.features.popup +import android.annotation.SuppressLint import android.app.Activity import android.os.Build import android.os.Handler @@ -26,6 +27,7 @@ import com.tapadoo.alerter.OnHideAlertListener import dagger.Lazy import im.vector.riotx.R import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.themes.ThemeUtils import timber.log.Timber import java.lang.ref.WeakReference import javax.inject.Inject @@ -139,24 +141,32 @@ class PopupAlertManager @Inject constructor(private val avatarRenderer: Lazy= Build.VERSION_CODES.M && view != null) { - var flags = view.systemUiVisibility - flags = flags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() - view.systemUiVisibility = flags - } + weakCurrentActivity?.get() + ?.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.M } + // Do not change anything on Dark themes + ?.takeIf { ThemeUtils.isLightTheme(it) } + ?.let { it.window?.decorView } + ?.let { view -> + var flags = view.systemUiVisibility + flags = flags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() + view.systemUiVisibility = flags + } } + @SuppressLint("InlinedApi") private fun setLightStatusBar() { - val view = weakCurrentActivity?.get()?.window?.decorView - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && view != null) { - var flags = view.systemUiVisibility - flags = flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR - view.systemUiVisibility = flags - } + weakCurrentActivity?.get() + ?.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.M } + // Do not change anything on Dark themes + ?.takeIf { ThemeUtils.isLightTheme(it) } + ?.let { it.window?.decorView } + ?.let { view -> + var flags = view.systemUiVisibility + flags = flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + view.systemUiVisibility = flags + } } private fun showAlert(alert: VectorAlert, activity: Activity, animate: Boolean = true) { diff --git a/vector/src/main/java/im/vector/riotx/features/rageshake/VectorFileLogger.kt b/vector/src/main/java/im/vector/riotx/features/rageshake/VectorFileLogger.kt index bcc48fb96c..d4ad4efb6f 100644 --- a/vector/src/main/java/im/vector/riotx/features/rageshake/VectorFileLogger.kt +++ b/vector/src/main/java/im/vector/riotx/features/rageshake/VectorFileLogger.kt @@ -39,7 +39,7 @@ private const val SIZE_20MB = 20 * 1024 * 1024 private const val SIZE_50MB = 50 * 1024 * 1024 @Singleton -class VectorFileLogger @Inject constructor(val context: Context, private val vectorPreferences: VectorPreferences) : Timber.DebugTree() { +class VectorFileLogger @Inject constructor(val context: Context, private val vectorPreferences: VectorPreferences) : Timber.Tree() { private val maxLogSizeByte: Int private val logRotationCount: Int diff --git a/vector/src/main/java/im/vector/riotx/features/reactions/widget/DotsView.kt b/vector/src/main/java/im/vector/riotx/features/reactions/widget/DotsView.kt index 292b17ce9f..a0dc4a4ae5 100644 --- a/vector/src/main/java/im/vector/riotx/features/reactions/widget/DotsView.kt +++ b/vector/src/main/java/im/vector/riotx/features/reactions/widget/DotsView.kt @@ -22,6 +22,8 @@ import android.graphics.Paint import android.util.AttributeSet import android.util.Property import android.view.View +import kotlin.math.cos +import kotlin.math.sin /** * This view will draw dots floating around the center of it's view @@ -84,16 +86,16 @@ class DotsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? private fun drawOuterDotsFrame(canvas: Canvas) { for (i in 0 until DOTS_COUNT) { - val cX = (centerX + currentRadius1 * Math.cos(i.toDouble() * OUTER_DOTS_POSITION_ANGLE.toDouble() * Math.PI / 180)).toFloat() - val cY = (centerY + currentRadius1 * Math.sin(i.toDouble() * OUTER_DOTS_POSITION_ANGLE.toDouble() * Math.PI / 180)).toFloat() + val cX = (centerX + currentRadius1 * cos(i.toDouble() * OUTER_DOTS_POSITION_ANGLE.toDouble() * Math.PI / 180)).toFloat() + val cY = (centerY + currentRadius1 * sin(i.toDouble() * OUTER_DOTS_POSITION_ANGLE.toDouble() * Math.PI / 180)).toFloat() canvas.drawCircle(cX, cY, currentDotSize1, circlePaints[i % circlePaints.size]) } } private fun drawInnerDotsFrame(canvas: Canvas) { for (i in 0 until DOTS_COUNT) { - val cX = (centerX + currentRadius2 * Math.cos((i * OUTER_DOTS_POSITION_ANGLE - 10) * Math.PI / 180)).toFloat() - val cY = (centerY + currentRadius2 * Math.sin((i * OUTER_DOTS_POSITION_ANGLE - 10) * Math.PI / 180)).toFloat() + val cX = (centerX + currentRadius2 * cos((i * OUTER_DOTS_POSITION_ANGLE - 10) * Math.PI / 180)).toFloat() + val cY = (centerY + currentRadius2 * sin((i * OUTER_DOTS_POSITION_ANGLE - 10) * Math.PI / 180)).toFloat() canvas.drawCircle(cX, cY, currentDotSize2, circlePaints[(i + 1) % circlePaints.size]) } } diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt index beae0a623e..5f245c883d 100755 --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorPreferences.kt @@ -74,7 +74,6 @@ class VectorPreferences @Inject constructor(private val context: Context) { const val SETTINGS_SECURE_MESSAGE_RECOVERY_PREFERENCE_KEY = "SETTINGS_SECURE_MESSAGE_RECOVERY_PREFERENCE_KEY" // user - const val SETTINGS_DISPLAY_NAME_PREFERENCE_KEY = "SETTINGS_DISPLAY_NAME_PREFERENCE_KEY" const val SETTINGS_PROFILE_PICTURE_PREFERENCE_KEY = "SETTINGS_PROFILE_PICTURE_PREFERENCE_KEY" // contacts diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsActivity.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsActivity.kt index 0c73c0f5d3..bbe6358bd9 100755 --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsActivity.kt @@ -59,6 +59,8 @@ class VectorSettingsActivity : VectorBaseActivity(), if (isFirstCreation()) { // display the fragment when (intent.getIntExtra(EXTRA_DIRECT_ACCESS, EXTRA_DIRECT_ACCESS_ROOT)) { + EXTRA_DIRECT_ACCESS_GENERAL -> + replaceFragment(R.id.vector_settings_page, VectorSettingsGeneralFragment::class.java, null, FRAGMENT_TAG) EXTRA_DIRECT_ACCESS_ADVANCED_SETTINGS -> replaceFragment(R.id.vector_settings_page, VectorSettingsAdvancedSettingsFragment::class.java, null, FRAGMENT_TAG) EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY -> @@ -137,6 +139,7 @@ class VectorSettingsActivity : VectorBaseActivity(), const val EXTRA_DIRECT_ACCESS_ADVANCED_SETTINGS = 1 const val EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY = 2 const val EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY_MANAGE_SESSIONS = 3 + const val EXTRA_DIRECT_ACCESS_GENERAL = 4 private const val FRAGMENT_TAG = "VectorSettingsPreferencesFragment" } diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt index 926d285f7b..6d73abf873 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt @@ -79,7 +79,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() { findPreference(VectorPreferences.SETTINGS_PROFILE_PICTURE_PREFERENCE_KEY)!! } private val mDisplayNamePreference by lazy { - findPreference(VectorPreferences.SETTINGS_DISPLAY_NAME_PREFERENCE_KEY)!! + findPreference("SETTINGS_DISPLAY_NAME_PREFERENCE_KEY")!! } private val mPasswordPreference by lazy { findPreference(VectorPreferences.SETTINGS_CHANGE_PASSWORD_PREFERENCE_KEY)!! @@ -122,7 +122,9 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() { it.summary = session.getUser(session.myUserId)?.displayName ?: "" it.text = it.summary.toString() it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - onDisplayNameClick(newValue?.let { (it as String).trim() }) + newValue + ?.let { value -> (value as? String)?.trim() } + ?.let { value -> onDisplayNameChanged(value) } false } } @@ -857,45 +859,25 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() { /** * Update the displayname. */ - private fun onDisplayNameClick(value: String?) { - notImplemented() - /* TODO - if (!TextUtils.equals(session.myUser.displayname, value)) { + private fun onDisplayNameChanged(value: String) { + val currentDisplayName = session.getUser(session.myUserId)?.displayName ?: "" + if (currentDisplayName != value) { displayLoadingView() - session.myUser.updateDisplayName(value, object : MatrixCallback { - override fun onSuccess(info: Void?) { + session.setDisplayName(session.myUserId, value, object : MatrixCallback { + override fun onSuccess(data: Unit) { + if (!isAdded) return // refresh the settings value - PreferenceManager.getDefaultSharedPreferences(activity).edit { - putString(VectorPreferences.SETTINGS_DISPLAY_NAME_PREFERENCE_KEY, value) - } - + mDisplayNamePreference.summary = value onCommonDone(null) - - refreshDisplay() } - override fun onNetworkError(e: Exception) { - onCommonDone(e.localizedMessage) - } - - override fun onMatrixError(e: MatrixError) { - if (MatrixError.M_CONSENT_NOT_GIVEN == e.errcode) { - activity?.runOnUiThread { - hideLoadingView() - (activity as VectorAppCompatActivity).consentNotGivenHelper.displayDialog(e) - } - } else { - onCommonDone(e.localizedMessage) - } - } - - override fun onUnexpectedError(e: Exception) { - onCommonDone(e.localizedMessage) + override fun onFailure(failure: Throwable) { + if (!isAdded) return + onCommonDone(failure.localizedMessage) } }) } - */ } companion object { diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt index 76065b63ea..dc8c17b08b 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsSecurityPrivacyFragment.kt @@ -337,7 +337,7 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor( * @param aMyDeviceInfo the device info */ private fun refreshCryptographyPreference(devices: List) { - showDeviceListPref.isEnabled = devices.size > 0 + showDeviceListPref.isEnabled = devices.isNotEmpty() showDeviceListPref.summary = resources.getQuantityString(R.plurals.settings_active_sessions_count, devices.size, devices.size) // val userId = session.myUserId // val deviceId = session.sessionParams.deviceId diff --git a/vector/src/main/java/im/vector/riotx/features/settings/devtools/GossipingEventsEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/settings/devtools/GossipingEventsEpoxyController.kt index 8760f9ebb2..722115adf3 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/devtools/GossipingEventsEpoxyController.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/devtools/GossipingEventsEpoxyController.kt @@ -179,7 +179,7 @@ class GossipingEventsEpoxyController @Inject constructor( } private fun buildOutgoing(data: KeyRequestListViewState?) { - data?.outgoingRoomKeyRequest?.let { async -> + data?.outgoingRoomKeyRequests?.let { async -> when (async) { is Uninitialized, is Loading -> { diff --git a/vector/src/main/java/im/vector/riotx/features/settings/devtools/KeyRequestEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/settings/devtools/KeyRequestEpoxyController.kt index b5e1303d89..7721b67118 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/devtools/KeyRequestEpoxyController.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/devtools/KeyRequestEpoxyController.kt @@ -107,7 +107,7 @@ class KeyRequestEpoxyController @Inject constructor( } private fun buildOutgoing(data: KeyRequestListViewState?) { - data?.outgoingRoomKeyRequest?.let { async -> + data?.outgoingRoomKeyRequests?.let { async -> when (async) { is Uninitialized, is Loading -> { diff --git a/vector/src/main/java/im/vector/riotx/features/settings/devtools/KeyRequestListViewModel.kt b/vector/src/main/java/im/vector/riotx/features/settings/devtools/KeyRequestListViewModel.kt index 06d9ffcf7d..db4b4f7d60 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/devtools/KeyRequestListViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/devtools/KeyRequestListViewModel.kt @@ -36,7 +36,7 @@ import kotlinx.coroutines.launch data class KeyRequestListViewState( val incomingRequests: Async> = Uninitialized, - val outgoingRoomKeyRequest: Async> = Uninitialized + val outgoingRoomKeyRequests: Async> = Uninitialized ) : MvRxState class KeyRequestListViewModel @AssistedInject constructor(@Assisted initialState: KeyRequestListViewState, @@ -49,14 +49,14 @@ class KeyRequestListViewModel @AssistedInject constructor(@Assisted initialState fun refresh() { viewModelScope.launch { - session.cryptoService().getOutgoingRoomKeyRequest().let { + session.cryptoService().getOutgoingRoomKeyRequests().let { setState { copy( - outgoingRoomKeyRequest = Success(it) + outgoingRoomKeyRequests = Success(it) ) } } - session.cryptoService().getIncomingRoomKeyRequest().let { + session.cryptoService().getIncomingRoomKeyRequests().let { setState { copy( incomingRequests = Success(it) diff --git a/vector/src/main/java/im/vector/riotx/features/terms/ReviewTermsFragment.kt b/vector/src/main/java/im/vector/riotx/features/terms/ReviewTermsFragment.kt index ecf5818300..0adfa049fe 100644 --- a/vector/src/main/java/im/vector/riotx/features/terms/ReviewTermsFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/terms/ReviewTermsFragment.kt @@ -30,7 +30,7 @@ import im.vector.riotx.core.extensions.configureWith import im.vector.riotx.core.extensions.exhaustive import im.vector.riotx.core.platform.VectorBaseActivity import im.vector.riotx.core.platform.VectorBaseFragment -import im.vector.riotx.core.utils.openUrlInExternalBrowser +import im.vector.riotx.core.utils.openUrlInChromeCustomTab import kotlinx.android.synthetic.main.fragment_review_terms.* import javax.inject.Inject @@ -106,6 +106,6 @@ class ReviewTermsFragment @Inject constructor( } override fun review(term: Term) { - openUrlInExternalBrowser(requireContext(), term.url) + openUrlInChromeCustomTab(requireContext(), null, term.url) } } diff --git a/vector/src/main/java/im/vector/riotx/features/terms/ReviewTermsViewModel.kt b/vector/src/main/java/im/vector/riotx/features/terms/ReviewTermsViewModel.kt index 69197b7b59..e19b8e7675 100644 --- a/vector/src/main/java/im/vector/riotx/features/terms/ReviewTermsViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/terms/ReviewTermsViewModel.kt @@ -139,7 +139,7 @@ class ReviewTermsViewModel @AssistedInject constructor( ) } } catch (failure: Throwable) { - Timber.e(failure, "Failed to agree to terms") + Timber.e(failure, "Failed to load terms") setState { copy( termsList = Uninitialized diff --git a/vector/src/main/java/im/vector/riotx/features/themes/ThemeUtils.kt b/vector/src/main/java/im/vector/riotx/features/themes/ThemeUtils.kt index 45e64465d6..4878134375 100644 --- a/vector/src/main/java/im/vector/riotx/features/themes/ThemeUtils.kt +++ b/vector/src/main/java/im/vector/riotx/features/themes/ThemeUtils.kt @@ -44,6 +44,17 @@ object ThemeUtils { private val mColorByAttr = HashMap() + /** + * @return true if current theme is Light or Status + */ + fun isLightTheme(context: Context): Boolean { + return when (getApplicationTheme(context)) { + THEME_LIGHT_VALUE, + THEME_STATUS_VALUE -> true + else -> false + } + } + /** * Provides the selected application theme * diff --git a/vector/src/main/java/im/vector/riotx/features/widgets/WidgetFragment.kt b/vector/src/main/java/im/vector/riotx/features/widgets/WidgetFragment.kt index 14b25d0439..b162149ffe 100644 --- a/vector/src/main/java/im/vector/riotx/features/widgets/WidgetFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/widgets/WidgetFragment.kt @@ -40,6 +40,7 @@ import im.vector.riotx.R import im.vector.riotx.core.platform.OnBackPressed import im.vector.riotx.core.platform.VectorBaseFragment import im.vector.riotx.core.utils.openUrlInExternalBrowser +import im.vector.riotx.features.home.room.detail.widget.WidgetRequestCodes import im.vector.riotx.features.terms.ReviewTermsActivity import im.vector.riotx.features.webview.WebViewEventListener import im.vector.riotx.features.widgets.webview.clearAfterWidget @@ -77,7 +78,7 @@ class WidgetFragment @Inject constructor() : VectorBaseFragment(), WebViewEventL Timber.v("Observed view events: $it") when (it) { is WidgetViewEvents.DisplayTerms -> displayTerms(it) - is WidgetViewEvents.LoadFormattedURL -> loadFormattedUrl(it) + is WidgetViewEvents.OnURLFormatted -> loadFormattedUrl(it) is WidgetViewEvents.DisplayIntegrationManager -> displayIntegrationManager(it) is WidgetViewEvents.Failure -> displayErrorDialog(it.throwable) } @@ -86,11 +87,17 @@ class WidgetFragment @Inject constructor() : VectorBaseFragment(), WebViewEventL } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == ReviewTermsActivity.TERMS_REQUEST_CODE) { - if (resultCode == Activity.RESULT_OK) { - viewModel.handle(WidgetAction.OnTermsReviewed) - } else { - vectorBaseActivity.finish() + when (requestCode) { + ReviewTermsActivity.TERMS_REQUEST_CODE -> { + Timber.v("On terms results") + if (resultCode == Activity.RESULT_OK) { + viewModel.handle(WidgetAction.OnTermsReviewed) + } else { + vectorBaseActivity.finish() + } + } + WidgetRequestCodes.INTEGRATION_MANAGER_REQUEST_CODE -> { + viewModel.handle(WidgetAction.LoadFormattedUrl) } } } @@ -139,7 +146,7 @@ class WidgetFragment @Inject constructor() : VectorBaseFragment(), WebViewEventL override fun onOptionsItemSelected(item: MenuItem): Boolean = withState(viewModel) { state -> when (item.itemId) { R.id.action_edit -> { - navigator.openIntegrationManager(requireContext(), state.roomId, state.widgetId, state.widgetKind.screenId) + navigator.openIntegrationManager(this, state.roomId, state.widgetId, state.widgetKind.screenId) return@withState true } R.id.action_delete -> { @@ -261,9 +268,9 @@ class WidgetFragment @Inject constructor() : VectorBaseFragment(), WebViewEventL ) } - private fun loadFormattedUrl(loadFormattedUrl: WidgetViewEvents.LoadFormattedURL) { + private fun loadFormattedUrl(event: WidgetViewEvents.OnURLFormatted) { widgetWebView.clearHistory() - widgetWebView.loadUrl(loadFormattedUrl.formattedURL) + widgetWebView.loadUrl(event.formattedURL) } private fun setStateError(message: String?) { @@ -280,7 +287,7 @@ class WidgetFragment @Inject constructor() : VectorBaseFragment(), WebViewEventL private fun displayIntegrationManager(event: WidgetViewEvents.DisplayIntegrationManager) { navigator.openIntegrationManager( - context = vectorBaseActivity, + fragment = this, roomId = fragmentArgs.roomId, integId = event.integId, screen = event.integType diff --git a/vector/src/main/java/im/vector/riotx/features/widgets/WidgetPostAPIHandler.kt b/vector/src/main/java/im/vector/riotx/features/widgets/WidgetPostAPIHandler.kt index 351a15aad1..7115a2ea62 100644 --- a/vector/src/main/java/im/vector/riotx/features/widgets/WidgetPostAPIHandler.kt +++ b/vector/src/main/java/im/vector/riotx/features/widgets/WidgetPostAPIHandler.kt @@ -39,13 +39,12 @@ import java.util.ArrayList import java.util.HashMap class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roomId: String, - @Assisted private val navigationCallback: NavigationCallback, private val stringProvider: StringProvider, private val session: Session) : WidgetPostAPIMediator.Handler { @AssistedInject.Factory interface Factory { - fun create(roomId: String, navigationCallback: NavigationCallback): WidgetPostAPIHandler + fun create(roomId: String): WidgetPostAPIHandler } interface NavigationCallback { @@ -54,31 +53,31 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo fun openIntegrationManager(integId: String?, integType: String?) } - private val widgetPostAPIMediator = session.widgetService().getWidgetPostAPIMediator() private val room = session.getRoom(roomId)!! + var navigationCallback: NavigationCallback? = null - override fun handleWidgetRequest(eventData: JsonDict): Boolean { + override fun handleWidgetRequest(mediator: WidgetPostAPIMediator, eventData: JsonDict): Boolean { return when (eventData["action"] as String?) { "integration_manager_open" -> handleIntegrationManagerOpenAction(eventData).run { true } - "bot_options" -> getBotOptions(eventData).run { true } - "can_send_event" -> canSendEvent(eventData).run { true } + "bot_options" -> getBotOptions(mediator, eventData).run { true } + "can_send_event" -> canSendEvent(mediator, eventData).run { true } "close_scalar" -> handleCloseScalar().run { true } - "get_membership_count" -> getMembershipCount(eventData).run { true } - "get_widgets" -> getWidgets(eventData).run { true } - "invite" -> inviteUser(eventData).run { true } - "join_rules_state" -> getJoinRules(eventData).run { true } - "membership_state" -> getMembershipState(eventData).run { true } - "set_bot_options" -> setBotOptions(eventData).run { true } - "set_bot_power" -> setBotPower(eventData).run { true } - "set_plumbing_state" -> setPlumbingState(eventData).run { true } - "set_widget" -> setWidget(eventData).run { true } - "m.sticker" -> pickStickerData(eventData).run { true } + "get_membership_count" -> getMembershipCount(mediator, eventData).run { true } + "get_widgets" -> getWidgets(mediator, eventData).run { true } + "invite" -> inviteUser(mediator, eventData).run { true } + "join_rules_state" -> getJoinRules(mediator, eventData).run { true } + "membership_state" -> getMembershipState(mediator, eventData).run { true } + "set_bot_options" -> setBotOptions(mediator, eventData).run { true } + "set_bot_power" -> setBotPower(mediator, eventData).run { true } + "set_plumbing_state" -> setPlumbingState(mediator, eventData).run { true } + "set_widget" -> setWidget(mediator, eventData).run { true } + "m.sticker" -> pickStickerData(mediator, eventData).run { true } else -> false } } private fun handleCloseScalar() { - navigationCallback.close() + navigationCallback?.close() } private fun handleIntegrationManagerOpenAction(eventData: JsonDict) { @@ -101,7 +100,7 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo // Add "type_" as a prefix integType?.let { integType = "type_$integType" } } - navigationCallback.openIntegrationManager(integId, integType) + navigationCallback?.openIntegrationManager(integId, integType) } /** @@ -109,8 +108,8 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo * * @param eventData the modular data */ - private fun getBotOptions(eventData: JsonDict) { - if (checkRoomId(eventData) || checkUserId(eventData)) { + private fun getBotOptions(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict) { + if (checkRoomId(widgetPostAPIMediator, eventData) || checkUserId(widgetPostAPIMediator, eventData)) { return } val userId = eventData["user_id"] as String @@ -134,8 +133,8 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo } } - private fun canSendEvent(eventData: JsonDict) { - if (checkRoomId(eventData)) { + private fun canSendEvent(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict) { + if (checkRoomId(widgetPostAPIMediator, eventData)) { return } Timber.d("Received request canSendEvent in room $roomId") @@ -170,8 +169,8 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo * * @param eventData the modular data */ - private fun getMembershipState(eventData: JsonDict) { - if (checkRoomId(eventData) || checkUserId(eventData)) { + private fun getMembershipState(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict) { + if (checkRoomId(widgetPostAPIMediator, eventData) || checkUserId(widgetPostAPIMediator, eventData)) { return } val userId = eventData["user_id"] as String @@ -189,8 +188,8 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo * * @param eventData the modular data */ - private fun getJoinRules(eventData: JsonDict) { - if (checkRoomId(eventData)) { + private fun getJoinRules(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict) { + if (checkRoomId(widgetPostAPIMediator, eventData)) { return } Timber.d("Received request join rules in room $roomId") @@ -207,8 +206,8 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo * * @param eventData the modular data */ - private fun getWidgets(eventData: JsonDict) { - if (checkRoomId(eventData)) { + private fun getWidgets(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict) { + if (checkRoomId(widgetPostAPIMediator, eventData)) { return } Timber.d("Received request to get widget in room $roomId") @@ -227,12 +226,12 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo * * @param eventData the modular data */ - private fun setWidget(eventData: JsonDict) { + private fun setWidget(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict) { val userWidget = eventData["userWidget"] as Boolean? if (userWidget == true) { Timber.d("Received request to set widget for user") } else { - if (checkRoomId(eventData)) { + if (checkRoomId(widgetPostAPIMediator, eventData)) { return } Timber.d("Received request to set widget in room $roomId") @@ -283,14 +282,14 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo session.updateAccountData( type = UserAccountData.TYPE_WIDGETS, content = addUserWidgetBody, - callback = createWidgetAPICallback(eventData) + callback = createWidgetAPICallback(widgetPostAPIMediator, eventData) ) } else { session.widgetService().createRoomWidget( roomId = roomId, widgetId = widgetId, content = widgetEventContent, - callback = createWidgetAPICallback(eventData) + callback = createWidgetAPICallback(widgetPostAPIMediator, eventData) ) } } @@ -300,8 +299,8 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo * * @param eventData the modular data */ - private fun setPlumbingState(eventData: JsonDict) { - if (checkRoomId(eventData)) { + private fun setPlumbingState(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict) { + if (checkRoomId(widgetPostAPIMediator, eventData)) { return } val description = "Received request to set plumbing state to status " + eventData["status"] + " in room " + roomId + " requested" @@ -315,7 +314,7 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo eventType = EventType.PLUMBING, stateKey = null, body = params, - callback = createWidgetAPICallback(eventData) + callback = createWidgetAPICallback(widgetPostAPIMediator, eventData) ) } @@ -325,8 +324,8 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo * @param eventData the modular data */ @Suppress("UNCHECKED_CAST") - private fun setBotOptions(eventData: JsonDict) { - if (checkRoomId(eventData) || checkUserId(eventData)) { + private fun setBotOptions(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict) { + if (checkRoomId(widgetPostAPIMediator, eventData) || checkUserId(widgetPostAPIMediator, eventData)) { return } val userId = eventData["user_id"] as String @@ -338,7 +337,7 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo eventType = EventType.BOT_OPTIONS, stateKey = stateKey, body = content, - callback = createWidgetAPICallback(eventData) + callback = createWidgetAPICallback(widgetPostAPIMediator, eventData) ) } @@ -347,8 +346,8 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo * * @param eventData the modular data */ - private fun setBotPower(eventData: JsonDict) { - if (checkRoomId(eventData) || checkUserId(eventData)) { + private fun setBotPower(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict) { + if (checkRoomId(widgetPostAPIMediator, eventData) || checkUserId(widgetPostAPIMediator, eventData)) { return } val userId = eventData["user_id"] as String @@ -369,8 +368,8 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo * * @param eventData the modular data */ - private fun inviteUser(eventData: JsonDict) { - if (checkRoomId(eventData) || checkUserId(eventData)) { + private fun inviteUser(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict) { + if (checkRoomId(widgetPostAPIMediator, eventData) || checkUserId(widgetPostAPIMediator, eventData)) { return } val userId = eventData["user_id"] as String @@ -380,7 +379,7 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo if (member != null && member.membership == Membership.JOIN) { widgetPostAPIMediator.sendSuccess(eventData) } else { - room.invite(userId = userId, callback = createWidgetAPICallback(eventData)) + room.invite(userId = userId, callback = createWidgetAPICallback(widgetPostAPIMediator, eventData)) } } @@ -389,8 +388,8 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo * * @param eventData the modular data */ - private fun getMembershipCount(eventData: JsonDict) { - if (checkRoomId(eventData)) { + private fun getMembershipCount(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict) { + if (checkRoomId(widgetPostAPIMediator, eventData)) { return } val numberOfJoinedMembers = room.getNumberOfJoinedMembers() @@ -398,7 +397,7 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo } @Suppress("UNCHECKED_CAST") - private fun pickStickerData(eventData: JsonDict) { + private fun pickStickerData(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict) { Timber.d("Received request send sticker") val data = eventData["data"] if (data == null) { @@ -411,7 +410,7 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo return } widgetPostAPIMediator.sendSuccess(eventData) - navigationCallback.closeWithResult(content) + navigationCallback?.closeWithResult(content) } /** @@ -420,7 +419,7 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo * * @return true in case of error */ - private fun checkRoomId(eventData: JsonDict): Boolean { + private fun checkRoomId(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict): Boolean { val roomIdInEvent = eventData["room_id"] as String? // Check if param is present if (null == roomIdInEvent) { @@ -443,7 +442,7 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo * * @return true in case of error */ - private fun checkUserId(eventData: JsonDict): Boolean { + private fun checkUserId(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict): Boolean { val userIdInEvent = eventData["user_id"] as String? // Check if param is present if (null == userIdInEvent) { @@ -454,7 +453,7 @@ class WidgetPostAPIHandler @AssistedInject constructor(@Assisted private val roo return false } - private fun createWidgetAPICallback(eventData: JsonDict): WidgetAPICallback { + private fun createWidgetAPICallback(widgetPostAPIMediator: WidgetPostAPIMediator, eventData: JsonDict): WidgetAPICallback { return WidgetAPICallback(widgetPostAPIMediator, eventData, stringProvider) } } diff --git a/vector/src/main/java/im/vector/riotx/features/widgets/WidgetViewEvents.kt b/vector/src/main/java/im/vector/riotx/features/widgets/WidgetViewEvents.kt index 7750f2dd68..5b40e0441d 100644 --- a/vector/src/main/java/im/vector/riotx/features/widgets/WidgetViewEvents.kt +++ b/vector/src/main/java/im/vector/riotx/features/widgets/WidgetViewEvents.kt @@ -23,6 +23,6 @@ sealed class WidgetViewEvents : VectorViewEvents { data class Failure(val throwable: Throwable): WidgetViewEvents() data class Close(val content: Content? = null) : WidgetViewEvents() data class DisplayIntegrationManager(val integId: String?, val integType: String?) : WidgetViewEvents() - data class LoadFormattedURL(val formattedURL: String) : WidgetViewEvents() + data class OnURLFormatted(val formattedURL: String) : WidgetViewEvents() data class DisplayTerms(val url: String, val token: String) : WidgetViewEvents() } diff --git a/vector/src/main/java/im/vector/riotx/features/widgets/WidgetViewModel.kt b/vector/src/main/java/im/vector/riotx/features/widgets/WidgetViewModel.kt index d81e1efea4..d516137bc5 100644 --- a/vector/src/main/java/im/vector/riotx/features/widgets/WidgetViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/widgets/WidgetViewModel.kt @@ -76,13 +76,22 @@ class WidgetViewModel @AssistedInject constructor(@Assisted val initialState: Wi private val integrationManagerService = session.integrationManagerService() private val widgetURLFormatter = widgetService.getWidgetURLFormatter() private val postAPIMediator = widgetService.getWidgetPostAPIMediator() + private var widgetPostAPIHandler: WidgetPostAPIHandler? = null + + // Flag to avoid infinite loop + private var canRefreshToken = true init { integrationManagerService.addListener(this) if (initialState.widgetKind.isAdmin()) { - val widgetPostAPIHandler = widgetPostAPIHandlerFactory.create(initialState.roomId, this) + widgetPostAPIHandler = widgetPostAPIHandlerFactory.create(initialState.roomId).apply { + navigationCallback = this@WidgetViewModel + } postAPIMediator.setHandler(widgetPostAPIHandler) } + if (!integrationManagerService.isIntegrationEnabled()) { + _viewEvents.post(WidgetViewEvents.Close(null)) + } setupName() refreshPermissionStatus() observePowerLevel() @@ -139,10 +148,10 @@ class WidgetViewModel @AssistedInject constructor(@Assisted val initialState: Wi is WidgetAction.OnWebViewLoadingError -> handleWebViewLoadingError(action) is WidgetAction.OnWebViewLoadingSuccess -> handleWebViewLoadingSuccess(action) is WidgetAction.OnWebViewStartedToLoad -> handleWebViewStartLoading() - WidgetAction.LoadFormattedUrl -> loadFormattedUrl() + WidgetAction.LoadFormattedUrl -> loadFormattedUrl(forceFetchToken = false) WidgetAction.DeleteWidget -> handleDeleteWidget() WidgetAction.RevokeWidget -> handleRevokeWidget() - WidgetAction.OnTermsReviewed -> refreshPermissionStatus() + WidgetAction.OnTermsReviewed -> loadFormattedUrl(forceFetchToken = false) } } @@ -224,10 +233,10 @@ class WidgetViewModel @AssistedInject constructor(@Assisted val initialState: Wi ) setState { copy(formattedURL = Success(formattedUrl)) } Timber.v("Post load formatted url event: $formattedUrl") - _viewEvents.post(WidgetViewEvents.LoadFormattedURL(formattedUrl)) + _viewEvents.post(WidgetViewEvents.OnURLFormatted(formattedUrl)) } catch (failure: Throwable) { if (failure is WidgetManagementFailure.TermsNotSignedException) { - _viewEvents.post(WidgetViewEvents.DisplayTerms(failure.baseUrl, failure.token)) + _viewEvents.post(WidgetViewEvents.DisplayTerms(initialState.baseUrl, failure.token)) } setState { copy(formattedURL = Fail(failure)) } } @@ -251,7 +260,8 @@ class WidgetViewModel @AssistedInject constructor(@Assisted val initialState: Wi } if (action.isHttpError) { // In case of 403, try to refresh the scalar token - if (it.formattedURL is Success && action.errorCode == HttpsURLConnection.HTTP_FORBIDDEN) { + if (it.formattedURL is Success && action.errorCode == HttpsURLConnection.HTTP_FORBIDDEN && canRefreshToken) { + canRefreshToken = false loadFormattedUrl(true) } } else { @@ -261,17 +271,24 @@ class WidgetViewModel @AssistedInject constructor(@Assisted val initialState: Wi override fun onCleared() { integrationManagerService.removeListener(this) + widgetPostAPIHandler?.navigationCallback = null postAPIMediator.setHandler(null) super.onCleared() } -// IntegrationManagerService.Listener + // IntegrationManagerService.Listener override fun onWidgetPermissionsChanged(widgets: Map) { refreshPermissionStatus() } -// WidgetPostAPIHandler.NavigationCallback + override fun onIsEnabledChanged(enabled: Boolean) { + if (!enabled) { + _viewEvents.post(WidgetViewEvents.Close(null)) + } + } + + // WidgetPostAPIHandler.NavigationCallback override fun close() { _viewEvents.post(WidgetViewEvents.Close(null)) diff --git a/vector/src/main/java/im/vector/riotx/features/widgets/webview/WebviewPermissionUtils.kt b/vector/src/main/java/im/vector/riotx/features/widgets/webview/WebviewPermissionUtils.kt index 32f6a906e2..fa0f4e0a97 100644 --- a/vector/src/main/java/im/vector/riotx/features/widgets/webview/WebviewPermissionUtils.kt +++ b/vector/src/main/java/im/vector/riotx/features/widgets/webview/WebviewPermissionUtils.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package im.vector.fragments.roomwidgets +package im.vector.riotx.features.widgets.webview import android.annotation.SuppressLint import android.content.Context diff --git a/vector/src/main/java/im/vector/riotx/features/widgets/webview/WidgetWebView.kt b/vector/src/main/java/im/vector/riotx/features/widgets/webview/WidgetWebView.kt index 68cbe76531..6b5ade06c4 100644 --- a/vector/src/main/java/im/vector/riotx/features/widgets/webview/WidgetWebView.kt +++ b/vector/src/main/java/im/vector/riotx/features/widgets/webview/WidgetWebView.kt @@ -24,7 +24,6 @@ import android.webkit.PermissionRequest import android.webkit.WebChromeClient import android.webkit.WebSettings import android.webkit.WebView -import im.vector.fragments.roomwidgets.WebviewPermissionUtils import im.vector.riotx.R import im.vector.riotx.features.themes.ThemeUtils import im.vector.riotx.features.webview.VectorWebViewClient @@ -81,22 +80,10 @@ fun WebView.clearAfterWidget() { webChromeClient = null webViewClient = null clearHistory() - // NOTE: clears RAM cache, if you pass true, it will also clear the disk cache. clearCache(true) - // Loading a blank page is optional, but will ensure that the WebView isn't doing anything when you destroy it. loadUrl("about:blank") - - onPause() removeAllViews() - - // NOTE: This pauses JavaScript execution for ALL WebViews, - // do not use if you have other WebViews still alive. - // If you create another WebView after calling this, - // make sure to call mWebView.resumeTimers(). - pauseTimers() - - // NOTE: This can occasionally cause a segfault below API 17 (4.2) destroy() } diff --git a/vector/src/main/res/drawable/bg_login_server.xml b/vector/src/main/res/drawable/bg_login_server.xml index 5aecd26292..a2ca14f02e 100644 --- a/vector/src/main/res/drawable/bg_login_server.xml +++ b/vector/src/main/res/drawable/bg_login_server.xml @@ -7,6 +7,6 @@ android:width="1.2dp" android:color="#E7E7E7" /> - + \ No newline at end of file diff --git a/vector/src/main/res/drawable/bg_login_server_checked.xml b/vector/src/main/res/drawable/bg_login_server_checked.xml index 1aea622462..f120e62884 100644 --- a/vector/src/main/res/drawable/bg_login_server_checked.xml +++ b/vector/src/main/res/drawable/bg_login_server_checked.xml @@ -7,6 +7,6 @@ android:width="1.2dp" android:color="@color/riotx_accent" /> - + \ No newline at end of file diff --git a/vector/src/main/res/drawable/ic_airplane_16dp.xml b/vector/src/main/res/drawable/ic_airplane_16dp.xml new file mode 100644 index 0000000000..fa2387ebf0 --- /dev/null +++ b/vector/src/main/res/drawable/ic_airplane_16dp.xml @@ -0,0 +1,13 @@ + + + diff --git a/vector/src/main/res/drawable/ic_call.xml b/vector/src/main/res/drawable/ic_call.xml new file mode 100644 index 0000000000..430c438577 --- /dev/null +++ b/vector/src/main/res/drawable/ic_call.xml @@ -0,0 +1,14 @@ + + + diff --git a/vector/src/main/res/drawable/ic_call_end.xml b/vector/src/main/res/drawable/ic_call_end.xml new file mode 100644 index 0000000000..07f7e01351 --- /dev/null +++ b/vector/src/main/res/drawable/ic_call_end.xml @@ -0,0 +1,14 @@ + + + diff --git a/vector/src/main/res/drawable/ic_call_flip_camera_active.xml b/vector/src/main/res/drawable/ic_call_flip_camera_active.xml new file mode 100644 index 0000000000..25590cc753 --- /dev/null +++ b/vector/src/main/res/drawable/ic_call_flip_camera_active.xml @@ -0,0 +1,11 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_call_flip_camera_default.xml b/vector/src/main/res/drawable/ic_call_flip_camera_default.xml new file mode 100644 index 0000000000..75ad0133f8 --- /dev/null +++ b/vector/src/main/res/drawable/ic_call_flip_camera_default.xml @@ -0,0 +1,7 @@ + + + diff --git a/vector/src/main/res/drawable/ic_call_mute_active.xml b/vector/src/main/res/drawable/ic_call_mute_active.xml new file mode 100644 index 0000000000..757f9cfa17 --- /dev/null +++ b/vector/src/main/res/drawable/ic_call_mute_active.xml @@ -0,0 +1,11 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_call_mute_default.xml b/vector/src/main/res/drawable/ic_call_mute_default.xml new file mode 100644 index 0000000000..37a0c83fec --- /dev/null +++ b/vector/src/main/res/drawable/ic_call_mute_default.xml @@ -0,0 +1,7 @@ + + + diff --git a/vector/src/main/res/drawable/ic_call_speaker_active.xml b/vector/src/main/res/drawable/ic_call_speaker_active.xml new file mode 100644 index 0000000000..97035b1915 --- /dev/null +++ b/vector/src/main/res/drawable/ic_call_speaker_active.xml @@ -0,0 +1,11 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_call_speaker_default.xml b/vector/src/main/res/drawable/ic_call_speaker_default.xml new file mode 100644 index 0000000000..2fc06a5795 --- /dev/null +++ b/vector/src/main/res/drawable/ic_call_speaker_default.xml @@ -0,0 +1,7 @@ + + + diff --git a/vector/src/main/res/drawable/ic_call_videocam_off_active.xml b/vector/src/main/res/drawable/ic_call_videocam_off_active.xml new file mode 100644 index 0000000000..106317ed56 --- /dev/null +++ b/vector/src/main/res/drawable/ic_call_videocam_off_active.xml @@ -0,0 +1,11 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_call_videocam_off_default.xml b/vector/src/main/res/drawable/ic_call_videocam_off_default.xml new file mode 100644 index 0000000000..0b3d9baf04 --- /dev/null +++ b/vector/src/main/res/drawable/ic_call_videocam_off_default.xml @@ -0,0 +1,7 @@ + + + diff --git a/vector/src/main/res/drawable/ic_hd.xml b/vector/src/main/res/drawable/ic_hd.xml new file mode 100644 index 0000000000..3335724529 --- /dev/null +++ b/vector/src/main/res/drawable/ic_hd.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_hd_disabled.xml b/vector/src/main/res/drawable/ic_hd_disabled.xml new file mode 100644 index 0000000000..6396b7bc7e --- /dev/null +++ b/vector/src/main/res/drawable/ic_hd_disabled.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_microphone_off.xml b/vector/src/main/res/drawable/ic_microphone_off.xml new file mode 100644 index 0000000000..92d5044902 --- /dev/null +++ b/vector/src/main/res/drawable/ic_microphone_off.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/vector/src/main/res/drawable/ic_microphone_on.xml b/vector/src/main/res/drawable/ic_microphone_on.xml new file mode 100644 index 0000000000..aaa9987860 --- /dev/null +++ b/vector/src/main/res/drawable/ic_microphone_on.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/drawable/ic_more_vertical.xml b/vector/src/main/res/drawable/ic_more_vertical.xml new file mode 100644 index 0000000000..9289a8cacb --- /dev/null +++ b/vector/src/main/res/drawable/ic_more_vertical.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/vector/src/main/res/drawable/ic_phone.xml b/vector/src/main/res/drawable/ic_phone.xml new file mode 100644 index 0000000000..430c438577 --- /dev/null +++ b/vector/src/main/res/drawable/ic_phone.xml @@ -0,0 +1,14 @@ + + + diff --git a/vector/src/main/res/drawable/ic_video.xml b/vector/src/main/res/drawable/ic_video.xml new file mode 100644 index 0000000000..f9c57db65e --- /dev/null +++ b/vector/src/main/res/drawable/ic_video.xml @@ -0,0 +1,22 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_video_flip.xml b/vector/src/main/res/drawable/ic_video_flip.xml new file mode 100644 index 0000000000..0cc540b9fb --- /dev/null +++ b/vector/src/main/res/drawable/ic_video_flip.xml @@ -0,0 +1,42 @@ + + + + + + + diff --git a/vector/src/main/res/drawable/ic_video_off.xml b/vector/src/main/res/drawable/ic_video_off.xml new file mode 100644 index 0000000000..34abdb5b51 --- /dev/null +++ b/vector/src/main/res/drawable/ic_video_off.xml @@ -0,0 +1,20 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_videocam.xml b/vector/src/main/res/drawable/ic_videocam.xml new file mode 100644 index 0000000000..b7a50f9a57 --- /dev/null +++ b/vector/src/main/res/drawable/ic_videocam.xml @@ -0,0 +1,5 @@ + + + diff --git a/vector/src/main/res/drawable/oval_destructive.xml b/vector/src/main/res/drawable/oval_destructive.xml new file mode 100644 index 0000000000..045a50456d --- /dev/null +++ b/vector/src/main/res/drawable/oval_destructive.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/drawable/oval_positive.xml b/vector/src/main/res/drawable/oval_positive.xml new file mode 100644 index 0000000000..d2e17d746b --- /dev/null +++ b/vector/src/main/res/drawable/oval_positive.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/activity_call.xml b/vector/src/main/res/layout/activity_call.xml new file mode 100644 index 0000000000..39d0bef790 --- /dev/null +++ b/vector/src/main/res/layout/activity_call.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/bottom_sheet_call_controls.xml b/vector/src/main/res/layout/bottom_sheet_call_controls.xml new file mode 100644 index 0000000000..04cb2af20d --- /dev/null +++ b/vector/src/main/res/layout/bottom_sheet_call_controls.xml @@ -0,0 +1,37 @@ + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_home_detail.xml b/vector/src/main/res/layout/fragment_home_detail.xml index ae2e251d97..f90422dff9 100644 --- a/vector/src/main/res/layout/fragment_home_detail.xml +++ b/vector/src/main/res/layout/fragment_home_detail.xml @@ -63,13 +63,42 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/syncStateView" /> + + + app:layout_constraintTop_toBottomOf="@+id/activeCallView" /> + + + + + + + android:src="@drawable/ic_logo_modular" + android:tint="?riotx_text_primary" /> - @@ -97,6 +96,14 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/roomToolbar" /> + + - + app:layout_constraintTop_toBottomOf="@id/activeCallView"> + + + + + - diff --git a/vector/src/main/res/layout/item_room_category.xml b/vector/src/main/res/layout/item_room_category.xml index 38c315aff5..519fbb7131 100644 --- a/vector/src/main/res/layout/item_room_category.xml +++ b/vector/src/main/res/layout/item_room_category.xml @@ -4,33 +4,32 @@ android:id="@+id/roomCategoryRootView" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="?riotx_header_panel_background" + android:background="?riotx_background" android:clickable="true" android:focusable="true" + android:foreground="?attr/selectableItemBackground" android:gravity="center_vertical" android:orientation="horizontal" - android:paddingStart="@dimen/layout_horizontal_margin" - android:paddingLeft="@dimen/layout_horizontal_margin" - android:paddingTop="@dimen/layout_vertical_margin" + android:paddingStart="8dp" + android:paddingLeft="8dp" + android:paddingTop="12dp" android:paddingEnd="@dimen/layout_horizontal_margin" - android:foreground="?attr/selectableItemBackground" android:paddingRight="@dimen/layout_horizontal_margin" - android:paddingBottom="8dp"> + android:paddingBottom="4dp"> + + + + + + + diff --git a/vector/src/main/res/layout/view_call_controls.xml b/vector/src/main/res/layout/view_call_controls.xml new file mode 100644 index 0000000000..94757c2c72 --- /dev/null +++ b/vector/src/main/res/layout/view_call_controls.xml @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/view_sync_state.xml b/vector/src/main/res/layout/view_sync_state.xml index 0e7ddabc21..49b3bb3857 100644 --- a/vector/src/main/res/layout/view_sync_state.xml +++ b/vector/src/main/res/layout/view_sync_state.xml @@ -1,5 +1,6 @@ - + tools:visibility="visible"> + + + + \ No newline at end of file diff --git a/vector/src/main/res/menu/menu_timeline.xml b/vector/src/main/res/menu/menu_timeline.xml index a4adb203f2..d4eb923d50 100644 --- a/vector/src/main/res/menu/menu_timeline.xml +++ b/vector/src/main/res/menu/menu_timeline.xml @@ -3,11 +3,38 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> + + + + + + #FFF8E3 #22262E - \ No newline at end of file diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 19afc120cb..9b03ef1ee5 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -211,6 +211,20 @@ Try using %s Do not ask me again + RiotX Call Failed + Failed to establish real time connection.\nPlease ask the administrator of your homeserver to configure a TURN server in order for calls to work reliably. + + Select Sound Device + Phone + Speaker + Headset + Wireless Headset + Switch Camera + Front + Back + Turn HD off + Turn HD on + Send files Send sticker Take photo or video @@ -362,6 +376,8 @@ Incoming Voice Call Call In Progress… Video Call In Progress… + Active Call (%s) + Return to call The remote side failed to pick up. Media Connection Failed @@ -461,7 +477,7 @@ ADMIN TOOLS CALL - DIRECT CHATS + Direct Messages SESSIONS Invite @@ -862,6 +878,9 @@ Allow integrations Integration Manager + Integrations are disabled + "Enable 'Allow integrations' in Settings to do this." + User interface Language Choose language @@ -1162,6 +1181,7 @@ Read DRM protected Media + Unable to create widget. Failed to send request. Power level must be positive integer. @@ -2452,5 +2472,10 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming Enter the URL of an identity server Submit Set role + Open chat + Mute the microphone + Unmute the microphone + Stop the camera + Start the camera diff --git a/vector/src/main/res/xml/vector_settings_general.xml b/vector/src/main/res/xml/vector_settings_general.xml index 846380465e..f79fbc71e2 100644 --- a/vector/src/main/res/xml/vector_settings_general.xml +++ b/vector/src/main/res/xml/vector_settings_general.xml @@ -14,6 +14,7 @@ @@ -68,6 +69,26 @@ + + + + + + + + + + - - - - - - - - - -