diff --git a/.idea/dictionaries/bmarty.xml b/.idea/dictionaries/bmarty.xml index 16cc35cebe..4de90e9405 100644 --- a/.idea/dictionaries/bmarty.xml +++ b/.idea/dictionaries/bmarty.xml @@ -26,6 +26,7 @@ pkcs previewable previewables + pstn riotx signin signout diff --git a/CHANGES.md b/CHANGES.md index ddcf5a41a0..1054667336 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,31 @@ -Changes in Element 1.0.17 (2020-02-09) +Changes in Element 1.1.0 (2021-02-19) +=================================================== + +Features ✨: + - VoIP : support for VoIP V1 protocol, transfer call and dial-pad + +Improvements 🙌: + - VoIP : new tiles in timeline + - Improve room profile UX + - Upgrade Jitsi library from 2.9.3 to 3.1.0 + - a11y improvements + +Bugfix 🐛: + - VoIP : fix audio devices output + - Fix crash after initial sync on Dendrite + - Fix crash reported by PlayStore (#2707) + - Ignore url override from credential if it is not valid (#2822) + - Fix crash when deactivating an account + +SDK API changes ⚠️: + - Migrate AuthenticationService API to coroutines (#2449) + +Other changes: + - New Dev Tools panel for developers + - Fix typos in CHANGES.md (#2811) + - Colors rework: first step: merge file `colors_riot.xml` to file `colors_riotx.xml` and rename the file to `colors.xml` + +Changes in Element 1.0.17 (2021-02-09) =================================================== Improvements 🙌: @@ -20,13 +47,13 @@ Build 🧱: Other changes: - Change app name from "Element (Riot.im)" to "Element" -Changes in Element 1.0.16 (2020-02-04) +Changes in Element 1.0.16 (2021-02-04) =================================================== Bugfix 🐛: - Fix crash on API < 30 and light theme (#2774) -Changes in Element 1.0.15 (2020-02-03) +Changes in Element 1.0.15 (2021-02-03) =================================================== Features ✨: @@ -57,7 +84,7 @@ Build 🧱: Other changes: - Update Dagger to 2.31 version so we can use the embedded AssistedInject feature -Changes in Element 1.0.14 (2020-01-15) +Changes in Element 1.0.14 (2021-01-15) =================================================== Features ✨: @@ -1169,7 +1196,7 @@ Mode details here: https://medium.com/@RiotChat/introducing-the-riotx-beta-for-a ======================================================= -Changes in Element 1.X.X (2020-XX-XX) +Changes in Element 1.X.X (2021-XX-XX) =================================================== Features ✨: diff --git a/build.gradle b/build.gradle index 625ed348be..3da87093ec 100644 --- a/build.gradle +++ b/build.gradle @@ -58,9 +58,9 @@ allprojects { maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } // Jitsi repo maven { - url "https://github.com/vector-im/jitsi_libre_maven/raw/master/android-sdk-2.9.3" + url "https://github.com/vector-im/jitsi_libre_maven/raw/master/android-sdk-3.1.0" // Note: to test Jitsi release you can use a local file like this: - // url "file:///Users/bmarty/workspaces/jitsi_libre_maven/android-sdk-2.9.3" + // url "file:///Users/bmarty/workspaces/jitsi_libre_maven/android-sdk-3.1.0" } google() jcenter() diff --git a/docs/jitsi.md b/docs/jitsi.md index 071470b040..389e7d71ec 100644 --- a/docs/jitsi.md +++ b/docs/jitsi.md @@ -18,7 +18,7 @@ The generated maven repository is then host in the project https://github.com/ve Update the script `./tools/jitsi/build_jisti_libs.sh` with the tag of the project `https://github.com/jitsi/jitsi-meet`. -Currently we are building the version with the tag `android-sdk-2.9.3`. +Currently we are building the version with the tag `android-sdk-3.1.0`. ### Run the build script @@ -35,21 +35,21 @@ It will build the Jitsi Meet Android library and put every generated files in th - Update the file `./build.gradle` to use the previously created local Maven repository. Currently we have this line: ```groovy -url "https://github.com/vector-im/jitsi_libre_maven/raw/master/android-sdk-2.9.3" +url "https://github.com/vector-im/jitsi_libre_maven/raw/master/android-sdk-3.1.0" ``` You can uncomment and update the line starting with `// url "file://...` and comment the line starting with `url`, to test the library using the locally generated Maven repository. -- Update the dependency of the WebRTC library in the file `./matrix-sdk-android/build.gradle`. Currently we have this line: - -```groovy -implementation('com.facebook.react:react-native-webrtc:1.84.0-jitsi-5112273@aar') -``` - - Update the dependency of the Jitsi Meet library in the file `./vector/build.gradle`. Currently we have this line: ```groovy -implementation('org.jitsi.react:jitsi-meet-sdk:2.9.3') { transitive = true } +implementation('org.jitsi.react:jitsi-meet-sdk:3.1.0') +``` + +- Update the dependency of the WebRTC library in the file `./vector/build.gradle`. Currently we have this line: + +```groovy +implementation('com.facebook.react:react-native-webrtc:1.87.3-jitsi-6624067@aar') ``` - Perform a gradle sync and build the project @@ -74,9 +74,9 @@ If all the tests are passed, you can export the generated Jitsi library to our M - Update the file `./build.gradle` to use the previously created Maven repository. Currently we have this line: ```groovy -url "https://github.com/vector-im/jitsi_libre_maven/raw/master/android-sdk-2.9.3" +url "https://github.com/vector-im/jitsi_libre_maven/raw/master/android-sdk-3.1.0" ``` - Build the project and perform the sanity tests again. -- Update the file `/CANGES.md` to notify about the library upgrade, and create a regular PR for project Element Android. \ No newline at end of file +- Update the file `/CHANGES.md` to notify about the library upgrade, and create a regular PR for project Element Android. \ No newline at end of file diff --git a/fastlane/metadata/android/ar/changelogs/40100100.txt b/fastlane/metadata/android/ar/changelogs/40100100.txt new file mode 100644 index 0000000000..25fc9c9058 --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/40100100.txt @@ -0,0 +1,2 @@ +يحتوي هذا الإصدار الجديد بشكل أساسي على إصلاحات للأخطاء وتحسينات. إرسال الرسالة أصبح الآن أسرع بكثير. +سجل التعديل الكامل: https://github.com/vector-im/element-android/releases/tag/v1.0.10 diff --git a/fastlane/metadata/android/ar/changelogs/40100110.txt b/fastlane/metadata/android/ar/changelogs/40100110.txt new file mode 100644 index 0000000000..1258ba323a --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/40100110.txt @@ -0,0 +1,2 @@ +يحتوي هذا الإصدار الجديد بشكل أساسي على تحسينات في واجهة المستخدم وتجربة المستخدم. يُمكنك الآن دعوة الأصدقاء وإنشاء رسالة مُباشرة بسرعة كبيرة عن طريق مسح رموز الاستجابة السريعة. +سجل التعديل الكامل: https://github.com/vector-im/element-android/releases/tag/v1.0.11 diff --git a/fastlane/metadata/android/ar/changelogs/40100120.txt b/fastlane/metadata/android/ar/changelogs/40100120.txt new file mode 100644 index 0000000000..18abe163dd --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/40100120.txt @@ -0,0 +1,2 @@ +التغييرات الرئيسة في هذا الإصدار: مُعاينة URL، لوحة مفاتيح Emoji جديدة، إمكانيات جديدة لإعدادات الغرفة والثلج لميلاد المسيح! +سجل التعديل الكامل: https://github.com/vector-im/element-android/releases/tag/v1.0.12 diff --git a/fastlane/metadata/android/ar/changelogs/40100130.txt b/fastlane/metadata/android/ar/changelogs/40100130.txt new file mode 100644 index 0000000000..eb50cdd31a --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/40100130.txt @@ -0,0 +1,2 @@ +التغييرات الرئيسة في هذا الإصدار: مُعاينة URL، لوحة مفاتيح Emoji جديدة، إمكانيات جديدة لإعدادات الغرفة والثلج لميلاد المسيح! +سجل التعديل الكامل: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/ar/changelogs/40100140.txt b/fastlane/metadata/android/ar/changelogs/40100140.txt new file mode 100644 index 0000000000..d3b0dc2dbc --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/40100140.txt @@ -0,0 +1,2 @@ +التغييرات الرئيسة في هذا الإصدار: تحرير أذونات الغُرفة، السِّمة التلقائية الفاتحة/الداكنة، ومجموعة من إصلاحات الأخطاء. +سجل التعديل الكامل: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/ar/changelogs/40100150.txt b/fastlane/metadata/android/ar/changelogs/40100150.txt new file mode 100644 index 0000000000..ba77aa0b1c --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/40100150.txt @@ -0,0 +1,2 @@ +التغييرات الرئيسة في هذا الإصدار: دعم تسجيل الدخول الاجتماعي. +سجل التعديل الكامل: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/ar/changelogs/40100160.txt b/fastlane/metadata/android/ar/changelogs/40100160.txt new file mode 100644 index 0000000000..b52a902cfa --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/40100160.txt @@ -0,0 +1,2 @@ +التغييرات الرئيسة في هذا الإصدار: دعم تسجيل الدخول الاجتماعي. +سجل التعديل الكامل: https://github.com/vector-im/element-android/releases/tag/v1.0.15 and https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/ar/changelogs/40100170.txt b/fastlane/metadata/android/ar/changelogs/40100170.txt new file mode 100644 index 0000000000..8f2226ae0e --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/40100170.txt @@ -0,0 +1,2 @@ +التغييرات الرئيسة في هذا الإصدار: إصلاحات الأخطاء! +سجل التعديل الكامل: https://github.com/vector-im/element-android/releases/tag/v1.0.17 diff --git a/fastlane/metadata/android/ar/full_description.txt b/fastlane/metadata/android/ar/full_description.txt new file mode 100644 index 0000000000..c0baa7d709 --- /dev/null +++ b/fastlane/metadata/android/ar/full_description.txt @@ -0,0 +1,31 @@ +Element هو نوع جديد من تطبيقات المُراسلة والتعاون الذي: + +1. يمنحك التحكم في المُحافضة على خصوصيتك +2. يُتيح لك التواصل مع أي شخص على شبكة Matrix ، وحتى خارجها من خلال التكامل مع التطبيقات مثل Slack +3. يحميك من الإعلانات والتنقيب عن البيانات وعمليات الحدائق المُسورة +4. يؤمنك من خلال تعمية النهاية-إلى-النهاية، مع التوقيع المُتبادل للتحقق من الآخرين + +يختلف Element تمامًا عن تطبيقات المُراسلة والتعاون الأُخرى لأنه لا مركزي ومفتوح المصدر. + +يُتيح لك Element إمكانية الاستضافة الذاتية -أو اختيار مُضيف- بحيث تتمتع بالخصوصية والمُلكية والتحكم في بياناتك ومُحادثاتك. يُتيح لك الوصول إلى شبكة مفتوحة؛ لذلك لا يقتصر الأمر على التحدث إلى مستخدمي Element الآخرين فقط. كما انه آمن للغاية. + +Element قادر على القيام بكل ذلك لأنه يعمل على Matrix -مِعيار التواصل المفتوح اللامركزي. + +Element يمنحك زمام التحكم من خلال السماح لك باختيار من يستضيف المُحادثات الخاصة بك. من تطبيق Element، يُمكنك اختيار الاستضافة بطرق مختلفة: + +1. الحُصول على حساب مجاني على الخادِم العام matrix.org الذي يستضيفه مطورو Matrix، أو اختر من بين آلاف الخوادِم العامة التي يستضيفها متطوعون +2. استضافة حسابك بنفسك عن طريق تشغيل خادِم على أجهزتك الخاصة +3. التسجيل للحصول على حساب على خادِم مُخصص بمُجرد الاشتراك في منصة استضافة Element Matrix Services + + لماذا تختار Element؟ + +تملَّك بياناتك: أنت من تُقرر أين تحتفظ ببياناتك ورسائلك. أنت تمتلكها وتتحكم فيها، وليس بعض الشركات الكُبرى الإحتكارية التي تُنقِّب عن بياناتك أو تُتيح الوصول إلى أطراف ثالثة. + + +تراسُل وتعاون مفتوح: يُمكنك مُحادثة أي شخص آخر على شبكة Matrix، سواء كانوا يستخدمون Element أو تطبيق Matrix آخر، وحتى إذا كانوا يستخدمون نظام مُراسلة مُختلف مثل Slack أو IRC أو XMPP. + +الأمان-الخارق: تشفير حقيقي من النهاية إلى النهاية (فقط أطراف المُحادثة مَن يُمكنهم فك تشفير الرسائل)، والتوقيع المُتبادل للتحقق من أجهزة المُشاركين في المُحادثة. + +التواصل الكامل: المُراسلة، المُكالمات الصوتية والمرئية، مُشاركة الملفات، مُشاركة الشاشة، مجموعة كاملة وكبيرة من عمليات التكامُل، الروبوتات والأدوات. بناء الغُرف، المُجتمعات، ابق على اتصال وأنجز المهام. + +أين ما كُنت: ابق على اتصال أينما كنت مع سجل الرسائل المتزامن بالكامل عبر جميع أجهزتك وفي الويب على https://app.element.io. diff --git a/fastlane/metadata/android/ar/short_description.txt b/fastlane/metadata/android/ar/short_description.txt new file mode 100644 index 0000000000..48df6f2b0c --- /dev/null +++ b/fastlane/metadata/android/ar/short_description.txt @@ -0,0 +1 @@ +مُحادثة آمنة لا مركزية و VoIP. حافظ على بياناتك آمنة من الأطراف الثالثة. diff --git a/fastlane/metadata/android/ar/title.txt b/fastlane/metadata/android/ar/title.txt new file mode 100644 index 0000000000..9b382729c8 --- /dev/null +++ b/fastlane/metadata/android/ar/title.txt @@ -0,0 +1 @@ +Element (سابقاً Riot.im) diff --git a/fastlane/metadata/android/ca/changelogs/40100130.txt b/fastlane/metadata/android/ca/changelogs/40100130.txt index e2e967215f..c63b5fb793 100644 --- a/fastlane/metadata/android/ca/changelogs/40100130.txt +++ b/fastlane/metadata/android/ca/changelogs/40100130.txt @@ -1,2 +1,2 @@ Canvis principals d'aquesta versió: previsualització d'URL, nou teclat d'emoticones, noves funcions de configuració de les sales i neu pel Nadal! -Registre de canvis complet: https://github.com/vector-im/element-android/releases/tag/v1.0.12 +Registre de canvis complet: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/ca/changelogs/40100140.txt b/fastlane/metadata/android/ca/changelogs/40100140.txt new file mode 100644 index 0000000000..1823c1abd7 --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/40100140.txt @@ -0,0 +1,2 @@ +Canvis principals d'aquesta versió: modificació dels permisos de sala, tema clar/fosc automàtic, correcció d'errors. +Registre de canvis complet: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/ca/changelogs/40100150.txt b/fastlane/metadata/android/ca/changelogs/40100150.txt new file mode 100644 index 0000000000..430b311b0e --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/40100150.txt @@ -0,0 +1,2 @@ +Canvis principals d'aquesta versió: inici de sessió amb xarxes socials. +Registre de canvis complet: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/ca/changelogs/40100160.txt b/fastlane/metadata/android/ca/changelogs/40100160.txt new file mode 100644 index 0000000000..8a5b6e5d9d --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/40100160.txt @@ -0,0 +1,2 @@ +Canvis principals d'aquesta versió: inici de sessió amb xarxes socials. +Registre de canvis complet: https://github.com/vector-im/element-android/releases/tag/v1.0.15 i https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/ca/changelogs/40100170.txt b/fastlane/metadata/android/ca/changelogs/40100170.txt new file mode 100644 index 0000000000..8a208b66f7 --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/40100170.txt @@ -0,0 +1,2 @@ +Canvis principals d'aquesta versió: correcció d'errors! +Registre de canvis complet: https://github.com/vector-im/element-android/releases/tag/v1.0.17 diff --git a/fastlane/metadata/android/ca/short_description.txt b/fastlane/metadata/android/ca/short_description.txt index 1e842ec64e..136e9c7ae3 100644 --- a/fastlane/metadata/android/ca/short_description.txt +++ b/fastlane/metadata/android/ca/short_description.txt @@ -1 +1 @@ -Xat i VoIP segurs i descentralitzats. Protegeix les teves dades de tercers. +Xats i VoIP segurs i descentralitzats. Protegeix les teves dades de tercers. diff --git a/fastlane/metadata/android/ca/title.txt b/fastlane/metadata/android/ca/title.txt index adc831006a..9dc1e0d277 100644 --- a/fastlane/metadata/android/ca/title.txt +++ b/fastlane/metadata/android/ca/title.txt @@ -1 +1 @@ -Element (anteriorment Riot.im) +Element (abans Riot.im) diff --git a/fastlane/metadata/android/de/changelogs/40100130.txt b/fastlane/metadata/android/de/changelogs/40100130.txt index 305329ff8c..142003d607 100644 --- a/fastlane/metadata/android/de/changelogs/40100130.txt +++ b/fastlane/metadata/android/de/changelogs/40100130.txt @@ -1,2 +1,2 @@ Hauptänderungen in dieser Version: URL-Vorschau, neue Emoji-Tastatur, neue Raumeinstellungen und Schnee für Weihnachten! -Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases/tag/v1.0.12 +Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/de/changelogs/40100140.txt b/fastlane/metadata/android/de/changelogs/40100140.txt new file mode 100644 index 0000000000..1e4725411b --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/40100140.txt @@ -0,0 +1,2 @@ +Hauptänderungen in dieser Version: Bearbeiten von Raumberechtigungen, automatisches Hell/Dunkel-Design und eine Reihe von Fehlerkorrekturen. +Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/de/changelogs/40100150.txt b/fastlane/metadata/android/de/changelogs/40100150.txt new file mode 100644 index 0000000000..f3335da144 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/40100150.txt @@ -0,0 +1,2 @@ +Hauptänderungen in dieser Version: Unterstützung für soziale Anmeldungen. +Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/de/changelogs/40100160.txt b/fastlane/metadata/android/de/changelogs/40100160.txt new file mode 100644 index 0000000000..1c4ce91ae2 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/40100160.txt @@ -0,0 +1,2 @@ +Hauptänderungen in dieser Version: Unterstützung für soziale Anmeldungen. +Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases/tag/v1.0.15 and https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/de/changelogs/40100170.txt b/fastlane/metadata/android/de/changelogs/40100170.txt new file mode 100644 index 0000000000..2ea20cf3ce --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/40100170.txt @@ -0,0 +1,2 @@ +Hauptänderungen in dieser Version: Fehlerkorrekturen +Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases/tag/v1.0.17 diff --git a/fastlane/metadata/android/en-US/changelogs/40101000.txt b/fastlane/metadata/android/en-US/changelogs/40101000.txt new file mode 100644 index 0000000000..ef64bd99a5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40101000.txt @@ -0,0 +1,2 @@ +Main changes in this version: VoIP (audio and video calls in DM) improvement and bug fixes! +Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.1.0 \ No newline at end of file diff --git a/fastlane/metadata/android/et/changelogs/40100130.txt b/fastlane/metadata/android/et/changelogs/40100130.txt index 583bc3fc42..0dae99a63e 100644 --- a/fastlane/metadata/android/et/changelogs/40100130.txt +++ b/fastlane/metadata/android/et/changelogs/40100130.txt @@ -1,2 +1,2 @@ Olulisemad muutused selles versioonis: URLide eelvaade, uus klahvistik emojide jaoks, jututubade uued seadistused ja natuke lund jõuludeks! -Muudatuste logi täismahus: https://github.com/vector-im/element-android/releases/tag/v1.0.12 +Muudatuste logi täismahus: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/et/changelogs/40100140.txt b/fastlane/metadata/android/et/changelogs/40100140.txt new file mode 100644 index 0000000000..88b6fa56cd --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40100140.txt @@ -0,0 +1,2 @@ +Olulisemad muutused selles versioonis: Jututoa õiguste muutmine, automaatne tumeda ja heleda teema vahetamine ning märgatav kogus veaparandusi. +Muudatuste logi täismahus: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/et/changelogs/40100150.txt b/fastlane/metadata/android/et/changelogs/40100150.txt new file mode 100644 index 0000000000..5df920b96c --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40100150.txt @@ -0,0 +1,2 @@ +Olulisemad muutused selles versioonis: Sisselogimine sotsiaalmeediakontode abil. +Muudatuste logi täismahus: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/et/changelogs/40100160.txt b/fastlane/metadata/android/et/changelogs/40100160.txt new file mode 100644 index 0000000000..902394552a --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40100160.txt @@ -0,0 +1,2 @@ +Olulisemad muutused selles versioonis: Sisselogimine sotsiaalmeediakontode abil. +Muudatuste logi täismahus: https://github.com/vector-im/element-android/releases/tag/v1.0.15 ja https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/et/changelogs/40100170.txt b/fastlane/metadata/android/et/changelogs/40100170.txt new file mode 100644 index 0000000000..3c565c011c --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40100170.txt @@ -0,0 +1,2 @@ +Olulisemad muutused selles versioonis: Veaparandused! +Muudatuste logi täismahus: https://github.com/vector-im/element-android/releases/tag/v1.0.17 diff --git a/fastlane/metadata/android/it/changelogs/40100130.txt b/fastlane/metadata/android/it/changelogs/40100130.txt index fcf8ca0f4e..ef27be9e1e 100644 --- a/fastlane/metadata/android/it/changelogs/40100130.txt +++ b/fastlane/metadata/android/it/changelogs/40100130.txt @@ -1,2 +1,2 @@ Modifiche principali in questa versione: anteprima URL, nuova tastiera emoji, nuove impostazioni stanza e neve per Natale! -Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.0.12 +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/it/changelogs/40100140.txt b/fastlane/metadata/android/it/changelogs/40100140.txt new file mode 100644 index 0000000000..0bf5c1b085 --- /dev/null +++ b/fastlane/metadata/android/it/changelogs/40100140.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: modifica autorizzazioni stanza, tema chiaro/scuro automatico e varie correzioni di errori. +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/it/changelogs/40100150.txt b/fastlane/metadata/android/it/changelogs/40100150.txt new file mode 100644 index 0000000000..5df2a0d650 --- /dev/null +++ b/fastlane/metadata/android/it/changelogs/40100150.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: supporto all'accesso dai social. +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/it/changelogs/40100160.txt b/fastlane/metadata/android/it/changelogs/40100160.txt new file mode 100644 index 0000000000..9177421e44 --- /dev/null +++ b/fastlane/metadata/android/it/changelogs/40100160.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: supporto all'accesso dai social. +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.0.15 and https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/it/changelogs/40100170.txt b/fastlane/metadata/android/it/changelogs/40100170.txt new file mode 100644 index 0000000000..b2a8424497 --- /dev/null +++ b/fastlane/metadata/android/it/changelogs/40100170.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: correzioni di errori! +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.0.17 diff --git a/fastlane/metadata/android/pt_BR/changelogs/40100130.txt b/fastlane/metadata/android/pt_BR/changelogs/40100130.txt index 834b512304..8f5a3d4b21 100644 --- a/fastlane/metadata/android/pt_BR/changelogs/40100130.txt +++ b/fastlane/metadata/android/pt_BR/changelogs/40100130.txt @@ -1,2 +1,2 @@ Principais mudanças nessa versão: Prévia do endereço URL, novo teclado de Emojis, novos recursos de configuração da sala, e neve para o Natal! -Registro de alterações completo: https://github.com/vector-im/element-android/releases/tag/v1.0.12 +Registro de alterações completo: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/pt_BR/changelogs/40100140.txt b/fastlane/metadata/android/pt_BR/changelogs/40100140.txt new file mode 100644 index 0000000000..3533e8dd9c --- /dev/null +++ b/fastlane/metadata/android/pt_BR/changelogs/40100140.txt @@ -0,0 +1,2 @@ +Principais mudanças nessa versão: editar permissões da sala, tema automaticamente claro/escuro e várias correções de erros. +Registro de alterações completo: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/pt_BR/changelogs/40100150.txt b/fastlane/metadata/android/pt_BR/changelogs/40100150.txt new file mode 100644 index 0000000000..fb13732507 --- /dev/null +++ b/fastlane/metadata/android/pt_BR/changelogs/40100150.txt @@ -0,0 +1,2 @@ +Principais mudanças nessa versão: suporte para Login Social. +Registro de alterações completo: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/pt_BR/changelogs/40100160.txt b/fastlane/metadata/android/pt_BR/changelogs/40100160.txt new file mode 100644 index 0000000000..561ceb3f25 --- /dev/null +++ b/fastlane/metadata/android/pt_BR/changelogs/40100160.txt @@ -0,0 +1,2 @@ +Principais mudanças nessa versão: suporte para Login Social. +Registro de alterações completo: https://github.com/vector-im/element-android/releases/tag/v1.0.15 e https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/ru/changelogs/40100130.txt b/fastlane/metadata/android/ru/changelogs/40100130.txt index 5a594af4cf..a5a090d06a 100644 --- a/fastlane/metadata/android/ru/changelogs/40100130.txt +++ b/fastlane/metadata/android/ru/changelogs/40100130.txt @@ -1,2 +1,2 @@ Основные изменения в этой версии: предварительный просмотр URL, новая клавиатура эмодзи, новые возможности настройки комнаты и снег на Рождество! -Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.0.12 +Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/ru/changelogs/40100140.txt b/fastlane/metadata/android/ru/changelogs/40100140.txt new file mode 100644 index 0000000000..8769f971eb --- /dev/null +++ b/fastlane/metadata/android/ru/changelogs/40100140.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Редактирование разрешений для комнаты, автоматическая светлая/темная тема и множество исправлений ошибок. +Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/ru/changelogs/40100150.txt b/fastlane/metadata/android/ru/changelogs/40100150.txt new file mode 100644 index 0000000000..1de07b1b13 --- /dev/null +++ b/fastlane/metadata/android/ru/changelogs/40100150.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Поддержка входа в социальные сети. +Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/ru/changelogs/40100160.txt b/fastlane/metadata/android/ru/changelogs/40100160.txt new file mode 100644 index 0000000000..346a3d75e1 --- /dev/null +++ b/fastlane/metadata/android/ru/changelogs/40100160.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Поддержка входа в социальные сети. +Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.0.15 and https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/sk/changelogs/40100120.txt b/fastlane/metadata/android/sk/changelogs/40100120.txt new file mode 100644 index 0000000000..46d39ad9e7 --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40100120.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: Ukážka URL, nová klávesnica Emoji, nové možnosti nastavenia miestnosti a sneh na Vianoce! +Celý zoznam zmien: https://github.com/vector-im/element-android/releases/tag/v1.0.12 diff --git a/fastlane/metadata/android/sk/changelogs/40100130.txt b/fastlane/metadata/android/sk/changelogs/40100130.txt new file mode 100644 index 0000000000..6dc39b44f1 --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40100130.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: Ukážka URL, nová klávesnica Emoji, nové možnosti nastavenia miestnosti a sneh na Vianoce! +Celý zoznam zmien: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/sk/changelogs/40100140.txt b/fastlane/metadata/android/sk/changelogs/40100140.txt new file mode 100644 index 0000000000..22db5f972f --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40100140.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: Úpravy povolení miestnosti, automatický svetlý / tmavý motív a veľa opráv chýb. +Celý zoznam zmien: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/sk/changelogs/40100150.txt b/fastlane/metadata/android/sk/changelogs/40100150.txt new file mode 100644 index 0000000000..ca26356e8a --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40100150.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: Podpora sociálneho prihlásenia. +Celý zoznam zmien: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/sk/changelogs/40100160.txt b/fastlane/metadata/android/sk/changelogs/40100160.txt new file mode 100644 index 0000000000..28cd4b52c9 --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40100160.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: Podpora sociálneho prihlásenia. +Celý zoznam zmien: https://github.com/vector-im/element-android/releases/tag/v1.0.15 and https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/sk/changelogs/40100170.txt b/fastlane/metadata/android/sk/changelogs/40100170.txt new file mode 100644 index 0000000000..41336e7700 --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/40100170.txt @@ -0,0 +1,2 @@ +Hlavné zmeny v tejto verzii: Opravy chýb! +Celý zoznam zmien: https://github.com/vector-im/element-android/releases/tag/v1.0.17 diff --git a/fastlane/metadata/android/sk/short_description.txt b/fastlane/metadata/android/sk/short_description.txt index 7d352942bf..0744f4a617 100644 --- a/fastlane/metadata/android/sk/short_description.txt +++ b/fastlane/metadata/android/sk/short_description.txt @@ -1 +1 @@ -Zabezpečené konverzácie a VoIP. Ochráňte vaše údaje pred zhromažďovaním. +Zabezpečené konverzácie a VoIP. Ochráňte vaše údaje pred tretími stranami. diff --git a/fastlane/metadata/android/sr/changelogs/40100130.txt b/fastlane/metadata/android/sr/changelogs/40100130.txt index 07ab721c42..ba9ff5ad43 100644 --- a/fastlane/metadata/android/sr/changelogs/40100130.txt +++ b/fastlane/metadata/android/sr/changelogs/40100130.txt @@ -1,2 +1,2 @@ -Главне измене у овој верзији: УРЛ преглед, нова емоџи тастатура, нове могућности у поставкама собе и снег за Божић ! -Дневник свих измена: https://github.com/vector-im/element-android/releases/tag/v1.0.12 +Главне измене у овој верзији: УРЛ преглед, нова емоџи тастатура, нове могућности у поставкама собе и снег за Божић! +Дневник свих измена: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/sr/changelogs/40100140.txt b/fastlane/metadata/android/sr/changelogs/40100140.txt new file mode 100644 index 0000000000..8fe5bed02d --- /dev/null +++ b/fastlane/metadata/android/sr/changelogs/40100140.txt @@ -0,0 +1,2 @@ +Главна измена у овој верзији: уређивање дозвола у соби, аутоматска светла/тамна тема и гомила исправљених грешака. +Цео дневник измена: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/sr/changelogs/40100150.txt b/fastlane/metadata/android/sr/changelogs/40100150.txt new file mode 100644 index 0000000000..6300f1f026 --- /dev/null +++ b/fastlane/metadata/android/sr/changelogs/40100150.txt @@ -0,0 +1,2 @@ +Главна измена у овој верзији: подршка за пријављивање са друштвених мрежа. +Цео дневник измена: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/sr/changelogs/40100160.txt b/fastlane/metadata/android/sr/changelogs/40100160.txt new file mode 100644 index 0000000000..b4dbbd763a --- /dev/null +++ b/fastlane/metadata/android/sr/changelogs/40100160.txt @@ -0,0 +1,2 @@ +Главна измена у овој верзији: подршка за пријављивање са друштвених мрежа. +Цео дневник измена: https://github.com/vector-im/element-android/releases/tag/v1.0.15 и https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/sr/changelogs/40100170.txt b/fastlane/metadata/android/sr/changelogs/40100170.txt new file mode 100644 index 0000000000..51d332a5ea --- /dev/null +++ b/fastlane/metadata/android/sr/changelogs/40100170.txt @@ -0,0 +1,2 @@ +Главна измена у овој верзији: сређене грешке! +Цео дневник измена: https://github.com/vector-im/element-android/releases/tag/v1.0.15 и https://github.com/vector-im/element-android/releases/tag/v1.0.17 diff --git a/fastlane/metadata/android/sv/changelogs/40100130.txt b/fastlane/metadata/android/sv/changelogs/40100130.txt index 5aa3351aaf..a2adbf9746 100644 --- a/fastlane/metadata/android/sv/changelogs/40100130.txt +++ b/fastlane/metadata/android/sv/changelogs/40100130.txt @@ -1,2 +1,2 @@ Huvudsakliga ändringar i den här versionen: URL-förhandsgranskning, nya rumsinställningsförmågor, och en vit jul! -Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.0.12 +Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/sv/changelogs/40100140.txt b/fastlane/metadata/android/sv/changelogs/40100140.txt new file mode 100644 index 0000000000..15596de911 --- /dev/null +++ b/fastlane/metadata/android/sv/changelogs/40100140.txt @@ -0,0 +1,2 @@ +Huvudsakliga ändringar i den här versionen: Redigering av rumsbehörigheter, automatiskt ljust/mörkt tema, och en hög buggfixar. +Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/sv/changelogs/40100150.txt b/fastlane/metadata/android/sv/changelogs/40100150.txt new file mode 100644 index 0000000000..f280e5ed89 --- /dev/null +++ b/fastlane/metadata/android/sv/changelogs/40100150.txt @@ -0,0 +1,2 @@ +Huvudsakliga ändringar i den här versionen: Stöd för social inloggning. +Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/sv/changelogs/40100160.txt b/fastlane/metadata/android/sv/changelogs/40100160.txt new file mode 100644 index 0000000000..adb520ecab --- /dev/null +++ b/fastlane/metadata/android/sv/changelogs/40100160.txt @@ -0,0 +1,2 @@ +Huvudsakliga ändringar i den här versionen: Stöd för social inloggning. +Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.0.15 och https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/uk/changelogs/40100130.txt b/fastlane/metadata/android/uk/changelogs/40100130.txt index 7342934333..c1ccdd7500 100644 --- a/fastlane/metadata/android/uk/changelogs/40100130.txt +++ b/fastlane/metadata/android/uk/changelogs/40100130.txt @@ -1,2 +1,2 @@ -Основні зміни в цій версії: попередній перегляд URL-адреси, нова клавіатура Emoji, нові можливості налаштування кімнати та сніг на Різдво! -Повний журнал змін: https://github.com/vector-im/element-android/releases/tag/v1.0.12 +Основні зміни в цій версії: попередній перегляд URL-адрес, нова клавіатура Emoji, нові можливості налаштування кімнати та сніг на Різдво! +Повний перелік змін: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/uk/changelogs/40100140.txt b/fastlane/metadata/android/uk/changelogs/40100140.txt new file mode 100644 index 0000000000..f61bebc71c --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40100140.txt @@ -0,0 +1,2 @@ +Основні зміни цієї версії: Керування дозволами кімнати, автоперемикання між світлою/темною темами та виправлення багатьох вад. +Повний перелік змін: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/uk/changelogs/40100150.txt b/fastlane/metadata/android/uk/changelogs/40100150.txt new file mode 100644 index 0000000000..5b76163ab0 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40100150.txt @@ -0,0 +1,2 @@ +Основні зміни цієї версії: підтримка входу за допомогою суспільних мереж. +Повний перелік змін: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/uk/changelogs/40100160.txt b/fastlane/metadata/android/uk/changelogs/40100160.txt new file mode 100644 index 0000000000..f3fa048e24 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40100160.txt @@ -0,0 +1,2 @@ +Основні зміни цієї версії: підтримка входу за допомогою суспільних мереж. +Повний перелік змін: https://github.com/vector-im/element-android/releases/tag/v1.0.15 та https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/uk/changelogs/40100170.txt b/fastlane/metadata/android/uk/changelogs/40100170.txt new file mode 100644 index 0000000000..c4b1bcc8e0 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40100170.txt @@ -0,0 +1,2 @@ +Основні зміни у цій версії: Виправлення помилок! +Повний перелік змін: https://github.com/vector-im/element-android/releases/tag/v1.0.17 diff --git a/fastlane/metadata/android/zh_Hant/changelogs/40100130.txt b/fastlane/metadata/android/zh_Hant/changelogs/40100130.txt index 846126af63..f42e9d3101 100644 --- a/fastlane/metadata/android/zh_Hant/changelogs/40100130.txt +++ b/fastlane/metadata/android/zh_Hant/changelogs/40100130.txt @@ -1,2 +1,2 @@ 此版本中的主要變更:URL 預覽、新的表情符號鍵盤、新的聊天室設定功能以及聖誕節降雪! -完整變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.0.12 +完整變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/zh_Hant/changelogs/40100140.txt b/fastlane/metadata/android/zh_Hant/changelogs/40100140.txt new file mode 100644 index 0000000000..9ed2152127 --- /dev/null +++ b/fastlane/metadata/android/zh_Hant/changelogs/40100140.txt @@ -0,0 +1,2 @@ +此版本的主要變動:編輯聊天室權限、自動淺色/深色佈景主題與許多臭蟲修復。 +完整變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/zh_Hant/changelogs/40100150.txt b/fastlane/metadata/android/zh_Hant/changelogs/40100150.txt new file mode 100644 index 0000000000..09a67d544b --- /dev/null +++ b/fastlane/metadata/android/zh_Hant/changelogs/40100150.txt @@ -0,0 +1,2 @@ +此版本的主要變動:社群網路登入支援。 +完整變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/zh_Hant/changelogs/40100160.txt b/fastlane/metadata/android/zh_Hant/changelogs/40100160.txt new file mode 100644 index 0000000000..77606636d3 --- /dev/null +++ b/fastlane/metadata/android/zh_Hant/changelogs/40100160.txt @@ -0,0 +1,2 @@ +此版本的主要變動:社群網路登入支援。 +完整變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.0.15 以及 https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 087983c1d6..30d5f428d7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,3 +1,4 @@ +#Fri Jan 29 18:05:42 CET 2021 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionSha256Sum=1433372d903ffba27496f8d5af24265310d2da0d78bf6b4e5138831d4fe066e9 diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 836b49b3f2..dca19e7755 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -168,12 +168,6 @@ dependencies { // Phone number https://github.com/google/libphonenumber implementation 'com.googlecode.libphonenumber:libphonenumber:8.10.23' - // Web RTC - // org.webrtc:google-webrtc is for development purposes only. See http://webrtc.github.io/webrtc-org/native-code/android/ - // implementation 'org.webrtc:google-webrtc:1.0.+' - // Use the same WebRTC library than the one used by Jitsi library - implementation('com.facebook.react:react-native-webrtc:1.84.0-jitsi-5112273@aar') - testImplementation 'junit:junit:4.13' testImplementation 'org.robolectric:robolectric:4.3' //testImplementation 'org.robolectric:shadows-support-v4:3.0' diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/account/DeactivateAccountTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/account/DeactivateAccountTest.kt index b0df6fcb44..01c4f8ccb3 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/account/DeactivateAccountTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/account/DeactivateAccountTest.kt @@ -26,15 +26,12 @@ import org.matrix.android.sdk.InstrumentedTest import org.matrix.android.sdk.api.auth.UIABaseAuth import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor import org.matrix.android.sdk.api.auth.UserPasswordAuth -import org.matrix.android.sdk.api.auth.data.LoginFlowResult import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse -import org.matrix.android.sdk.api.auth.registration.RegistrationResult import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.common.CommonTestHelper import org.matrix.android.sdk.common.SessionTestParams import org.matrix.android.sdk.common.TestConstants -import org.matrix.android.sdk.common.TestMatrixCallback import kotlin.coroutines.Continuation import kotlin.coroutines.resume @@ -46,12 +43,13 @@ class DeactivateAccountTest : InstrumentedTest { @Test fun deactivateAccountTest() { - val session = commonTestHelper.createAccount(TestConstants.USER_ALICE, SessionTestParams(withInitialSync = false)) + val session = commonTestHelper.createAccount(TestConstants.USER_ALICE, SessionTestParams(withInitialSync = true)) // Deactivate the account commonTestHelper.runBlockingTest { session.deactivateAccount( - object : UserInteractiveAuthInterceptor { + eraseAllData = false, + userInteractiveAuthInterceptor = object : UserInteractiveAuthInterceptor { override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) { promise.resume( UserPasswordAuth( @@ -61,7 +59,8 @@ class DeactivateAccountTest : InstrumentedTest { ) ) } - }, false) + } + ) } // Try to login on the previous account, it will fail (M_USER_DEACTIVATED) @@ -75,23 +74,23 @@ class DeactivateAccountTest : InstrumentedTest { // Try to create an account with the deactivate account user id, it will fail (M_USER_IN_USE) val hs = commonTestHelper.createHomeServerConfig() - commonTestHelper.doSync { - commonTestHelper.matrix.authenticationService.getLoginFlow(hs, it) + commonTestHelper.runBlockingTest { + commonTestHelper.matrix.authenticationService.getLoginFlow(hs) } var accountCreationError: Throwable? = null - commonTestHelper.waitWithLatch { - commonTestHelper.matrix.authenticationService - .getRegistrationWizard() - .createAccount(session.myUserId.substringAfter("@").substringBefore(":"), - TestConstants.PASSWORD, - null, - object : TestMatrixCallback(it, false) { - override fun onFailure(failure: Throwable) { - accountCreationError = failure - super.onFailure(failure) - } - }) + commonTestHelper.runBlockingTest { + try { + commonTestHelper.matrix.authenticationService + .getRegistrationWizard() + .createAccount( + session.myUserId.substringAfter("@").substringBefore(":"), + TestConstants.PASSWORD, + null + ) + } catch (failure: Throwable) { + accountCreationError = failure + } } // Test the error diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt index a4dbd70b11..c677d91f0a 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt @@ -23,7 +23,6 @@ import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig -import org.matrix.android.sdk.api.auth.data.LoginFlowResult import org.matrix.android.sdk.api.auth.registration.RegistrationResult import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.EventType @@ -210,22 +209,21 @@ class CommonTestHelper(context: Context) { sessionTestParams: SessionTestParams): Session { val hs = createHomeServerConfig() - doSync { - matrix.authenticationService - .getLoginFlow(hs, it) + runBlockingTest { + matrix.authenticationService.getLoginFlow(hs) } - doSync(timeout = 60_000) { + runBlockingTest(timeout = 60_000) { matrix.authenticationService .getRegistrationWizard() - .createAccount(userName, password, null, it) + .createAccount(userName, password, null) } // Perform dummy step - val registrationResult = doSync(timeout = 60_000) { + val registrationResult = runBlockingTest(timeout = 60_000) { matrix.authenticationService .getRegistrationWizard() - .dummy(it) + .dummy() } assertTrue(registrationResult is RegistrationResult.Success) @@ -249,15 +247,14 @@ class CommonTestHelper(context: Context) { sessionTestParams: SessionTestParams): Session { val hs = createHomeServerConfig() - doSync { - matrix.authenticationService - .getLoginFlow(hs, it) + runBlockingTest { + matrix.authenticationService.getLoginFlow(hs) } - val session = doSync { + val session = runBlockingTest { matrix.authenticationService .getLoginWizard() - .login(userName, password, "myDevice", it) + .login(userName, password, "myDevice") } if (sessionTestParams.withInitialSync) { @@ -277,21 +274,19 @@ class CommonTestHelper(context: Context) { password: String): Throwable { val hs = createHomeServerConfig() - doSync { - matrix.authenticationService - .getLoginFlow(hs, it) + runBlockingTest { + matrix.authenticationService.getLoginFlow(hs) } var requestFailure: Throwable? = null - waitWithLatch { latch -> - matrix.authenticationService - .getLoginWizard() - .login(userName, password, "myDevice", object : TestMatrixCallback(latch, onlySuccessful = false) { - override fun onFailure(failure: Throwable) { - requestFailure = failure - super.onFailure(failure) - } - }) + runBlockingTest { + try { + matrix.authenticationService + .getLoginWizard() + .login(userName, password, "myDevice") + } catch (failure: Throwable) { + requestFailure = failure + } } assertNotNull(requestFailure) diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt index ae300c936d..cadb83ca00 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt @@ -61,7 +61,7 @@ class SearchMessagesTest : InstrumentedTest { 2) run { - var lock = CountDownLatch(1) + val lock = CountDownLatch(1) val eventListener = commonTestHelper.createEventListener(lock) { snapshot -> snapshot.count { it.root.content.toModel()?.body?.startsWith(MESSAGE).orFalse() } == 2 @@ -70,7 +70,6 @@ class SearchMessagesTest : InstrumentedTest { aliceTimeline.addListener(eventListener) commonTestHelper.await(lock) - lock = CountDownLatch(1) val data = commonTestHelper.runBlockingTest { aliceSession .searchService() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt index 725fd08d3b..93a1b962ed 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt @@ -35,7 +35,11 @@ data class MatrixConfiguration( * Optional proxy to connect to the matrix servers * You can create one using for instance Proxy(proxyType, InetSocketAddress.createUnresolved(hostname, port) */ - val proxy: Proxy? = null + val proxy: Proxy? = null, + /** + * True to advertise support for call transfers to other parties on Matrix calls. + */ + val supportsCallTransfer: Boolean = false ) { /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt index bf21941e0c..a7f5163774 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt @@ -16,7 +16,6 @@ package org.matrix.android.sdk.api.auth -import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.auth.data.Credentials import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.data.LoginFlowResult @@ -24,7 +23,6 @@ import org.matrix.android.sdk.api.auth.login.LoginWizard import org.matrix.android.sdk.api.auth.registration.RegistrationWizard import org.matrix.android.sdk.api.auth.wellknown.WellknownResult import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.util.Cancelable /** * This interface defines methods to authenticate or to create an account to a matrix server. @@ -32,14 +30,14 @@ import org.matrix.android.sdk.api.util.Cancelable interface AuthenticationService { /** * Request the supported login flows for this homeserver. - * This is the first method to call to be able to get a wizard to login or the create an account + * This is the first method to call to be able to get a wizard to login or to create an account */ - fun getLoginFlow(homeServerConnectionConfig: HomeServerConnectionConfig, callback: MatrixCallback): Cancelable + suspend fun getLoginFlow(homeServerConnectionConfig: HomeServerConnectionConfig): LoginFlowResult /** * Request the supported login flows for the corresponding sessionId. */ - fun getLoginFlowOfSession(sessionId: String, callback: MatrixCallback): Cancelable + suspend fun getLoginFlowOfSession(sessionId: String): LoginFlowResult /** * Get a SSO url @@ -69,12 +67,12 @@ interface AuthenticationService { /** * Cancel pending login or pending registration */ - fun cancelPendingLoginOrRegistration() + suspend fun cancelPendingLoginOrRegistration() /** * Reset all pending settings, including current HomeServerConnectionConfig */ - fun reset() + suspend fun reset() /** * Check if there is an authenticated [Session]. @@ -91,24 +89,21 @@ interface AuthenticationService { /** * Create a session after a SSO successful login */ - fun createSessionFromSso(homeServerConnectionConfig: HomeServerConnectionConfig, - credentials: Credentials, - callback: MatrixCallback): Cancelable + suspend fun createSessionFromSso(homeServerConnectionConfig: HomeServerConnectionConfig, + credentials: Credentials): Session /** * Perform a wellknown request, using the domain from the matrixId */ - fun getWellKnownData(matrixId: String, - homeServerConnectionConfig: HomeServerConnectionConfig?, - callback: MatrixCallback): Cancelable + suspend fun getWellKnownData(matrixId: String, + homeServerConnectionConfig: HomeServerConnectionConfig?): WellknownResult /** * Authenticate with a matrixId and a password * Usually call this after a successful call to getWellKnownData() */ - fun directAuthentication(homeServerConnectionConfig: HomeServerConnectionConfig, - matrixId: String, - password: String, - initialDeviceName: String, - callback: MatrixCallback): Cancelable + suspend fun directAuthentication(homeServerConnectionConfig: HomeServerConnectionConfig, + matrixId: String, + password: String, + initialDeviceName: String): Session } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt index 48705ee7b7..9c96cba40c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt @@ -16,7 +16,6 @@ package org.matrix.android.sdk.api.auth.login -import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.util.Cancelable @@ -29,26 +28,23 @@ interface LoginWizard { * @param callback the matrix callback on which you'll receive the result of authentication. * @return a [Cancelable] */ - fun login(login: String, - password: String, - deviceName: String, - callback: MatrixCallback): Cancelable + suspend fun login(login: String, + password: String, + deviceName: String): Session /** * Exchange a login token to an access token */ - fun loginWithToken(loginToken: String, - callback: MatrixCallback): Cancelable + suspend fun loginWithToken(loginToken: String): Session /** * Reset user password */ - fun resetPassword(email: String, - newPassword: String, - callback: MatrixCallback): Cancelable + suspend fun resetPassword(email: String, + newPassword: String) /** - * Confirm the new password, once the user has checked his email + * Confirm the new password, once the user has checked their email */ - fun resetPasswordMailConfirmed(callback: MatrixCallback): Cancelable + suspend fun resetPasswordMailConfirmed() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/registration/RegistrationWizard.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/registration/RegistrationWizard.kt index ed7b249f1e..d00c9a0c82 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/registration/RegistrationWizard.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/registration/RegistrationWizard.kt @@ -16,28 +16,25 @@ package org.matrix.android.sdk.api.auth.registration -import org.matrix.android.sdk.api.MatrixCallback -import org.matrix.android.sdk.api.util.Cancelable - interface RegistrationWizard { - fun getRegistrationFlow(callback: MatrixCallback): Cancelable + suspend fun getRegistrationFlow(): RegistrationResult - fun createAccount(userName: String, password: String, initialDeviceDisplayName: String?, callback: MatrixCallback): Cancelable + suspend fun createAccount(userName: String, password: String, initialDeviceDisplayName: String?): RegistrationResult - fun performReCaptcha(response: String, callback: MatrixCallback): Cancelable + suspend fun performReCaptcha(response: String): RegistrationResult - fun acceptTerms(callback: MatrixCallback): Cancelable + suspend fun acceptTerms(): RegistrationResult - fun dummy(callback: MatrixCallback): Cancelable + suspend fun dummy(): RegistrationResult - fun addThreePid(threePid: RegisterThreePid, callback: MatrixCallback): Cancelable + suspend fun addThreePid(threePid: RegisterThreePid): RegistrationResult - fun sendAgainThreePid(callback: MatrixCallback): Cancelable + suspend fun sendAgainThreePid(): RegistrationResult - fun handleValidateThreePid(code: String, callback: MatrixCallback): Cancelable + suspend fun handleValidateThreePid(code: String): RegistrationResult - fun checkIfEmailHasBeenValidated(delayMillis: Long, callback: MatrixCallback): Cancelable + suspend fun checkIfEmailHasBeenValidated(delayMillis: Long): RegistrationResult val currentThreePid: String? diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt index c06cdd9e23..e0ee9f36ba 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt @@ -53,22 +53,24 @@ fun Throwable.isInvalidUIAAuth(): Boolean { * Try to convert to a RegistrationFlowResponse. Return null in the cases it's not possible */ fun Throwable.toRegistrationFlowResponse(): RegistrationFlowResponse? { - return if (this is Failure.OtherServerError && this.httpCode == 401) { + return if (this is Failure.OtherServerError && httpCode == 401) { tryOrNull { MoshiProvider.providesMoshi() .adapter(RegistrationFlowResponse::class.java) - .fromJson(this.errorBody) + .fromJson(errorBody) } - } else if (this is Failure.ServerError && this.httpCode == 401 && this.error.code == MatrixError.M_FORBIDDEN) { + } else if (this is Failure.ServerError && httpCode == 401 && error.code == MatrixError.M_FORBIDDEN) { // This happens when the submission for this stage was bad (like bad password) - if (this.error.session != null && this.error.flows != null) { + if (error.session != null && error.flows != null) { RegistrationFlowResponse( - flows = this.error.flows, - session = this.error.session, - completedStages = this.error.completedStages, - params = this.error.params + flows = error.flows, + session = error.session, + completedStages = error.completedStages, + params = error.params ) - } else null + } else { + null + } } else { null } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt index ff7c9f0521..039025e0df 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt @@ -48,6 +48,7 @@ import org.matrix.android.sdk.api.session.signout.SignOutService import org.matrix.android.sdk.api.session.sync.FilterService import org.matrix.android.sdk.api.session.sync.SyncState import org.matrix.android.sdk.api.session.terms.TermsService +import org.matrix.android.sdk.api.session.thirdparty.ThirdPartyService import org.matrix.android.sdk.api.session.typing.TypingUsersTracker import org.matrix.android.sdk.api.session.user.UserService import org.matrix.android.sdk.api.session.widgets.WidgetService @@ -212,6 +213,11 @@ interface Session : */ fun searchService(): SearchService + /** + * Returns the third party service associated with the session + */ + fun thirdPartyService(): ThirdPartyService + /** * Add a listener to the session. * @param listener the listener to add. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/account/AccountService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/account/AccountService.kt index eb327dfd56..1f28dbd8af 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/account/AccountService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/account/AccountService.kt @@ -27,7 +27,8 @@ interface AccountService { * @param password Current password. * @param newPassword New password */ - suspend fun changePassword(password: String, newPassword: String) + suspend fun changePassword(password: String, + newPassword: String) /** * Deactivate the account. @@ -41,9 +42,10 @@ interface AccountService { * be shared with any new or unregistered users, but registered users who already have access to these messages will still * have access to their copy. * - * @param password the account password * @param eraseAllData set to true to forget all messages that have been sent. Warning: this will cause future users to see * an incomplete view of conversations + * @param userInteractiveAuthInterceptor see [UserInteractiveAuthInterceptor] */ - suspend fun deactivateAccount(userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, eraseAllData: Boolean) + suspend fun deactivateAccount(eraseAllData: Boolean, + userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallsListener.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallListener.kt similarity index 67% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallsListener.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallListener.kt index 37ab198487..303add747f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallsListener.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallListener.kt @@ -20,8 +20,11 @@ import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent +import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent +import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent +import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent -interface CallsListener { +interface CallListener { /** * Called when there is an incoming call within the room. */ @@ -39,5 +42,23 @@ interface CallsListener { */ fun onCallHangupReceived(callHangupContent: CallHangupContent) + /** + * Called when a called has been rejected + */ + fun onCallRejectReceived(callRejectContent: CallRejectContent) + + /** + * Called when an answer has been selected + */ + fun onCallSelectAnswerReceived(callSelectAnswerContent: CallSelectAnswerContent) + + /** + * Called when a negotiation is sent + */ + fun onCallNegotiateReceived(callNegotiateContent: CallNegotiateContent) + + /** + * Called when the call has been managed by an other session + */ fun onCallManagedByOtherSession(callId: String) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallSignalingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallSignalingService.kt index e28c1fa595..dc67aa536a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallSignalingService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallSignalingService.kt @@ -16,21 +16,20 @@ package org.matrix.android.sdk.api.session.call -import org.matrix.android.sdk.api.MatrixCallback -import org.matrix.android.sdk.api.util.Cancelable - interface CallSignalingService { - fun getTurnServer(callback: MatrixCallback): Cancelable + suspend fun getTurnServer(): TurnServerResponse + + fun getPSTNProtocolChecker(): PSTNProtocolChecker /** * Create an outgoing call */ fun createOutgoingCall(roomId: String, otherUserId: String, isVideoCall: Boolean): MxCall - fun addCallListener(listener: CallsListener) + fun addCallListener(listener: CallListener) - fun removeCallListener(listener: CallsListener) + fun removeCallListener(listener: CallListener) fun getCallWithId(callId: String): MxCall? diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallState.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallState.kt index 757a09fb3f..2dbd1c9b01 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallState.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/CallState.kt @@ -16,13 +16,16 @@ package org.matrix.android.sdk.api.session.call -import org.webrtc.PeerConnection - sealed class CallState { /** Idle, setting up objects */ object Idle : CallState() + /** + * CreateOffer. Intermediate state between Idle and Dialing. + */ + object CreateOffer: CallState() + /** Dialing. Outgoing call is signaling the remote peer */ object Dialing : CallState() @@ -36,8 +39,8 @@ sealed class 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() + * */ + data class Connected(val iceConnectionState: MxPeerConnectionState) : CallState() /** Terminated. Incoming/Outgoing call, the call is terminated */ object Terminated : CallState() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/MxCall.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/MxCall.kt index a1ab687894..7533619eb0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/MxCall.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/MxCall.kt @@ -16,14 +16,17 @@ package org.matrix.android.sdk.api.session.call -import org.webrtc.IceCandidate -import org.webrtc.SessionDescription +import org.matrix.android.sdk.api.session.room.model.call.CallCandidate +import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities +import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent +import org.matrix.android.sdk.api.session.room.model.call.SdpType +import org.matrix.android.sdk.api.util.Optional interface MxCallDetail { val callId: String val isOutgoing: Boolean val roomId: String - val otherUserId: String + val opponentUserId: String val isVideoCall: Boolean } @@ -32,40 +35,64 @@ interface MxCallDetail { */ interface MxCall : MxCallDetail { + companion object { + const val VOIP_PROTO_VERSION = 1 + } + + val ourPartyId: String + var opponentPartyId: Optional? + var opponentVersion: Int + + var capabilities: CallCapabilities? + var state: CallState /** * Pick Up the incoming call * It has no effect on outgoing call */ - fun accept(sdp: SessionDescription) + fun accept(sdpString: String) + + /** + * SDP negotiation for media pause, hold/resume, ICE restarts and voice/video call up/downgrading + */ + fun negotiate(sdpString: String, type: SdpType) + + /** + * This has to be sent by the caller's client once it has chosen an answer. + */ + fun selectAnswer() /** * Reject an incoming call - * It's an alias to hangUp */ - fun reject() = hangUp() + fun reject() /** * End the call */ - fun hangUp() + fun hangUp(reason: CallHangupContent.Reason? = null) /** * Start a call * Send offer SDP to the other participant. */ - fun offerSdp(sdp: SessionDescription) + fun offerSdp(sdpString: String) /** - * Send Ice candidate to the other participant. + * Send Call candidate to the other participant. */ - fun sendLocalIceCandidates(candidates: List) + fun sendLocalCallCandidates(candidates: List) /** * Send removed ICE candidates to the other participant. */ - fun sendLocalIceCandidateRemovals(candidates: List) + fun sendLocalIceCandidateRemovals(candidates: List) + + /** + * Send a m.call.replaces event to initiate call transfer. + */ + suspend fun transfer(targetUserId: String, targetRoomId: String?) fun addListener(listener: StateListener) fun removeListener(listener: StateListener) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/MxPeerConnectionState.java b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/MxPeerConnectionState.java new file mode 100644 index 0000000000..7ea0765809 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/MxPeerConnectionState.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.call; + +/** + * This is a copy of https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState + * to avoid having the dependency over WebRtc library on sdk. + */ +public enum MxPeerConnectionState { + NEW, + CONNECTING, + CONNECTED, + DISCONNECTED, + FAILED, + CLOSED +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/PSTNProtocolChecker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/PSTNProtocolChecker.kt new file mode 100644 index 0000000000..6627f62e24 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/PSTNProtocolChecker.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.call + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol +import org.matrix.android.sdk.internal.session.SessionScope +import org.matrix.android.sdk.internal.session.thirdparty.GetThirdPartyProtocolsTask +import org.matrix.android.sdk.internal.task.TaskExecutor +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject + +private const val PSTN_VECTOR_KEY = "im.vector.protocol.pstn" +private const val PSTN_MATRIX_KEY = "m.protocol.pstn" + +/** + * This class is responsible for checking if the HS support the PSTN protocol. + * As long as the request succeed, it'll check only once by session. + */ +@SessionScope +class PSTNProtocolChecker @Inject internal constructor(private val taskExecutor: TaskExecutor, + private val getThirdPartyProtocolsTask: GetThirdPartyProtocolsTask) { + + interface Listener { + fun onPSTNSupportUpdated() + } + + private var alreadyChecked = AtomicBoolean(false) + + private val pstnSupportListeners = mutableListOf() + + fun addListener(listener: Listener) { + pstnSupportListeners.add(listener) + } + + fun removeListener(listener: Listener) { + pstnSupportListeners.remove(listener) + } + + var supportedPSTNProtocol: String? = null + private set + + fun checkForPSTNSupportIfNeeded() { + if (alreadyChecked.get()) return + taskExecutor.executorScope.checkForPSTNSupport() + } + + private fun CoroutineScope.checkForPSTNSupport() = launch { + try { + supportedPSTNProtocol = getSupportedPSTN(3) + alreadyChecked.set(true) + if (supportedPSTNProtocol != null) { + pstnSupportListeners.forEach { + tryOrNull { it.onPSTNSupportUpdated() } + } + } + } catch (failure: Throwable) { + Timber.v("Fail to get supported PSTN, will check again next time.") + } + } + + private suspend fun getSupportedPSTN(maxTries: Int): String? { + val thirdPartyProtocols: Map = try { + getThirdPartyProtocolsTask.execute(Unit) + } catch (failure: Throwable) { + if (maxTries == 1) { + throw failure + } else { + // Wait for 10s before trying again + delay(10_000L) + return getSupportedPSTN(maxTries - 1) + } + } + return when { + thirdPartyProtocols.containsKey(PSTN_VECTOR_KEY) -> PSTN_VECTOR_KEY + thirdPartyProtocols.containsKey(PSTN_MATRIX_KEY) -> PSTN_MATRIX_KEY + else -> null + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt index fa5ea359e8..eead9b4ab7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt @@ -56,8 +56,6 @@ interface CryptoService { fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback) - fun deleteDeviceWithUserPassword(deviceId: String, authSession: String?, password: String, callback: MatrixCallback) - fun getCryptoVersion(context: Context, longFormat: Boolean): String fun isCryptoEnabled(): Boolean diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt index 68874a1fb1..d79117ad87 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt @@ -68,7 +68,12 @@ object EventType { const val CALL_INVITE = "m.call.invite" const val CALL_CANDIDATES = "m.call.candidates" const val CALL_ANSWER = "m.call.answer" + const val CALL_SELECT_ANSWER = "m.call.select_answer" + const val CALL_NEGOTIATE = "m.call.negotiate" + const val CALL_REJECT = "m.call.reject" const val CALL_HANGUP = "m.call.hangup" + // This type is not processed by the client, just sent to the server + const val CALL_REPLACES = "m.call.replaces" // Key share events const val ROOM_KEY_REQUEST = "m.room_key_request" @@ -98,5 +103,9 @@ object EventType { || type == CALL_CANDIDATES || type == CALL_ANSWER || type == CALL_HANGUP + || type == CALL_SELECT_ANSWER + || type == CALL_NEGOTIATE + || type == CALL_REJECT + || type == CALL_REPLACES } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomDirectoryService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomDirectoryService.kt index 61970ce848..55f3b76760 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomDirectoryService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomDirectoryService.kt @@ -20,7 +20,6 @@ import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsResponse -import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol import org.matrix.android.sdk.api.util.Cancelable /** @@ -35,12 +34,6 @@ interface RoomDirectoryService { publicRoomsParams: PublicRoomsParams, callback: MatrixCallback): Cancelable - /** - * Fetches the overall metadata about protocols supported by the homeserver. - * Includes both the available protocols and all fields required for queries against each protocol. - */ - fun getThirdPartyProtocol(callback: MatrixCallback>): Cancelable - /** * Get the visibility of a room in the directory */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomThirdPartyInviteContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomThirdPartyInviteContent.kt index 776acbd8ea..56503e3e35 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomThirdPartyInviteContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomThirdPartyInviteContent.kt @@ -30,24 +30,24 @@ data class RoomThirdPartyInviteContent( * This should not contain the user's third party ID, as otherwise when the invite * is accepted it would leak the association between the matrix ID and the third party ID. */ - @Json(name = "display_name") val displayName: String, + @Json(name = "display_name") val displayName: String?, /** * Required. A URL which can be fetched, with querystring public_key=public_key, to validate * whether the key has been revoked. The URL must return a JSON object containing a boolean property named 'valid'. */ - @Json(name = "key_validity_url") val keyValidityUrl: String, + @Json(name = "key_validity_url") val keyValidityUrl: String?, /** * Required. A base64-encoded ed25519 key with which token must be signed (though a signature from any entry in * public_keys is also sufficient). This exists for backwards compatibility. */ - @Json(name = "public_key") val publicKey: String, + @Json(name = "public_key") val publicKey: String?, /** * Keys with which the token may be signed. */ - @Json(name = "public_keys") val publicKeys: List = emptyList() + @Json(name = "public_keys") val publicKeys: List? = emptyList() ) @JsonClass(generateAdapter = true) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallAnswerContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallAnswerContent.kt index c4d1f6486f..45a54bb379 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallAnswerContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallAnswerContent.kt @@ -27,16 +27,24 @@ data class CallAnswerContent( /** * Required. The ID of the call this event relates to. */ - @Json(name = "call_id") val callId: String, + @Json(name = "call_id") override val callId: String, + /** + * Required. ID to let user identify remote echo of their own events + */ + @Json(name = "party_id") override val partyId: String? = null, /** * Required. The session description object */ @Json(name = "answer") val answer: Answer, /** - * Required. The version of the VoIP specification this messages adheres to. This specification is version 0. + * Required. The version of the VoIP specification this messages adheres to. */ - @Json(name = "version") val version: Int = 0 -) { + @Json(name = "version") override val version: String?, + /** + * Capability advertisement. + */ + @Json(name = "capabilities") val capabilities: CallCapabilities? = null +): CallSignallingContent { @JsonClass(generateAdapter = true) data class Answer( diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallCandidate.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallCandidate.kt new file mode 100644 index 0000000000..ace8c5a757 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallCandidate.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.room.model.call + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CallCandidate( + /** + * Required. The SDP media type this candidate is intended for. + */ + @Json(name = "sdpMid") val sdpMid: String? = null, + /** + * Required. The index of the SDP 'm' line this candidate is intended for. + */ + @Json(name = "sdpMLineIndex") val sdpMLineIndex: Int = 0, + /** + * Required. The SDP 'a' line of the candidate. + */ + @Json(name = "candidate") val candidate: String? = null +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallCandidatesContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallCandidatesContent.kt index cad2356c2d..7bfe7a97ac 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallCandidatesContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallCandidatesContent.kt @@ -28,30 +28,17 @@ data class CallCandidatesContent( /** * Required. The ID of the call this event relates to. */ - @Json(name = "call_id") val callId: String, + @Json(name = "call_id") override val callId: String, + /** + * Required. ID to let user identify remote echo of their own events + */ + @Json(name = "party_id") override val partyId: String? = null, /** * Required. Array of objects describing the candidates. */ - @Json(name = "candidates") val candidates: List = emptyList(), + @Json(name = "candidates") val candidates: List = emptyList(), /** - * Required. The version of the VoIP specification this messages adheres to. This specification is version 0. + * Required. The version of the VoIP specification this messages adheres to. */ - @Json(name = "version") val version: Int = 0 -) { - - @JsonClass(generateAdapter = true) - data class Candidate( - /** - * Required. The SDP media type this candidate is intended for. - */ - @Json(name = "sdpMid") val sdpMid: String, - /** - * Required. The index of the SDP 'm' line this candidate is intended for. - */ - @Json(name = "sdpMLineIndex") val sdpMLineIndex: Int, - /** - * Required. The SDP 'a' line of the candidate. - */ - @Json(name = "candidate") val candidate: String - ) -} + @Json(name = "version") override val version: String? +): CallSignallingContent diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallCapabilities.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallCapabilities.kt new file mode 100644 index 0000000000..d911ca3b88 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallCapabilities.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.room.model.call + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.extensions.orFalse + +@JsonClass(generateAdapter = true) +data class CallCapabilities( + /** + * If set to true, states that the sender of the event supports the m.call.replaces event and therefore supports + * being transferred to another destination + */ + @Json(name = "m.call.transferee") val transferee: Boolean? = null +) + +fun CallCapabilities?.supportCallTransfer() = this?.transferee.orFalse() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallHangupContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallHangupContent.kt index d30441df4b..0acc409053 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallHangupContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallHangupContent.kt @@ -28,24 +28,41 @@ data class CallHangupContent( /** * Required. The ID of the call this event relates to. */ - @Json(name = "call_id") val callId: String, + @Json(name = "call_id") override val callId: String, /** - * Required. The version of the VoIP specification this message adheres to. This specification is version 0. + * Required. ID to let user identify remote echo of their own events */ - @Json(name = "version") val version: Int = 0, + @Json(name = "party_id") override val partyId: String? = null, + /** + * Required. The version of the VoIP specification this message adheres to. + */ + @Json(name = "version") override val version: String?, /** * Optional error reason for the hangup. This should not be provided when the user naturally ends or rejects the call. * When there was an error in the call negotiation, this should be `ice_failed` for when ICE negotiation fails - * or `invite_timeout` for when the other party did not answer in time. One of: ["ice_failed", "invite_timeout"] + * or `invite_timeout` for when the other party did not answer in time. + * One of: ["ice_failed", "invite_timeout"] */ @Json(name = "reason") val reason: Reason? = null -) { +) : CallSignallingContent { @JsonClass(generateAdapter = false) enum class Reason { @Json(name = "ice_failed") ICE_FAILED, + @Json(name = "ice_timeout") + ICE_TIMEOUT, + + @Json(name = "user_hangup") + USER_HANGUP, + + @Json(name = "user_media_failed") + USER_MEDIA_FAILED, + @Json(name = "invite_timeout") - INVITE_TIMEOUT + INVITE_TIMEOUT, + + @Json(name = "unknown_error") + UNKWOWN_ERROR } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallInviteContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallInviteContent.kt index b961a6f654..42489bc0ce 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallInviteContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallInviteContent.kt @@ -27,22 +27,35 @@ data class CallInviteContent( /** * Required. A unique identifier for the call. */ - @Json(name = "call_id") val callId: String?, + @Json(name = "call_id") override val callId: String?, + /** + * Required. ID to let user identify remote echo of their own events + */ + @Json(name = "party_id") override val partyId: String? = null, /** * Required. The session description object */ @Json(name = "offer") val offer: Offer?, /** - * Required. The version of the VoIP specification this message adheres to. This specification is version 0. + * Required. The version of the VoIP specification this message adheres to. */ - @Json(name = "version") val version: Int? = 0, + @Json(name = "version") override val version: String?, /** * Required. The time in milliseconds that the invite is valid for. * 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? -) { + @Json(name = "lifetime") val lifetime: Int?, + /** + * The field should be added for all invites where the target is a specific user + */ + @Json(name = "invitee") val invitee: String? = null, + /** + * Capability advertisement. + */ + @Json(name = "capabilities") val capabilities: CallCapabilities? = null + +): CallSignallingContent { @JsonClass(generateAdapter = true) data class Offer( /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallNegotiateContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallNegotiateContent.kt new file mode 100644 index 0000000000..040993bb51 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallNegotiateContent.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.room.model.call + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * This introduces SDP negotiation semantics for media pause, hold/resume, ICE restarts and voice/video call up/downgrading. + */ +@JsonClass(generateAdapter = true) +data class CallNegotiateContent( + /** + * Required. The ID of the call this event relates to. + */ + @Json(name = "call_id") override val callId: String, + /** + * Required. ID to let user identify remote echo of their own events + */ + @Json(name = "party_id") override val partyId: String? = null, + /** + * Required. The time in milliseconds that the negotiation is valid for. Once exceeded the sender + * of the negotiate event should consider the negotiation failed (timed out) and the recipient should ignore it. + **/ + @Json(name = "lifetime") val lifetime: Int?, + /** + * Required. The session description object + */ + @Json(name = "description") val description: Description? = null, + + /** + * Required. The version of the VoIP specification this message adheres to. + */ + @Json(name = "version") override val version: String? + + ): CallSignallingContent { + @JsonClass(generateAdapter = true) + data class Description( + /** + * Required. The type of session description. + */ + @Json(name = "type") val type: SdpType?, + /** + * Required. The SDP text of the session description. + */ + @Json(name = "sdp") val sdp: String? + ) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallRejectContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallRejectContent.kt new file mode 100644 index 0000000000..1da229b179 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallRejectContent.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.room.model.call + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Sent by either party to signal their termination of the call. This can be sent either once + * the call has been established or before to abort the call. + */ +@JsonClass(generateAdapter = true) +data class CallRejectContent( + /** + * Required. The ID of the call this event relates to. + */ + @Json(name = "call_id") override val callId: String, + /** + * Required. ID to let user identify remote echo of their own events + */ + @Json(name = "party_id") override val partyId: String? = null, + /** + * Required. The version of the VoIP specification this message adheres to. + */ + @Json(name = "version") override val version: String? +) : CallSignallingContent diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallReplacesContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallReplacesContent.kt new file mode 100644 index 0000000000..97a3b8c7a7 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallReplacesContent.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.room.model.call + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * This event is sent to signal the intent of a participant in a call to replace the call with another, + * such that the other participant ends up in a call with a new user. + */ +@JsonClass(generateAdapter = true) +data class CallReplacesContent( + /** + * Required. The ID of the call this event relates to. + */ + @Json(name = "call_id") override val callId: String, + /** + * Required. ID to let user identify remote echo of their own events + */ + @Json(name = "party_id") override val partyId: String? = null, + /** + * An identifier for the call replacement itself, generated by the transferor. + */ + @Json(name = "replacement_id") val replacementId: String? = null, + /** + * Optional. If specified, the transferee client waits for an invite to this room and joins it + * (possibly waiting for user confirmation) and then continues the transfer in this room. + * If absent, the transferee contacts the Matrix User ID given in the target_user field in a room of its choosing. + */ + @Json(name = "target_room") val targerRoomId: String? = null, + /** + * An object giving information about the transfer target + */ + @Json(name = "target_user") val targetUser: TargetUser? = null, + /** + * If specified, gives the call ID for the transferee's client to use when placing the replacement call. + * Mutually exclusive with await_call + */ + @Json(name = "create_call") val createCall: String? = null, + /** + * If specified, gives the call ID that the transferee's client should wait for. + * Mutually exclusive with create_call. + */ + @Json(name = "await_call") val awaitCall: String? = null, + /** + * Required. The version of the VoIP specification this messages adheres to. + */ + @Json(name = "version") override val version: String? +): CallSignallingContent { + + @JsonClass(generateAdapter = true) + data class TargetUser( + /** + * Required. The matrix user ID of the transfer target + */ + @Json(name = "id") val id: String, + /** + * Optional. The display name of the transfer target. + */ + @Json(name = "display_name") val displayName: String?, + /** + * Optional. The avatar URL of the transfer target. + */ + @Json(name = "avatar_url") val avatarUrl: String? + + ) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallSelectAnswerContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallSelectAnswerContent.kt new file mode 100644 index 0000000000..6ea70ac990 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallSelectAnswerContent.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.room.model.call + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * This event is sent by the callee when they wish to answer the call. + */ +@JsonClass(generateAdapter = true) +data class CallSelectAnswerContent( + /** + * Required. The ID of the call this event relates to. + */ + @Json(name = "call_id") override val callId: String, + /** + * Required. ID to let user identify remote echo of their own events + */ + @Json(name = "party_id") override val partyId: String? = null, + /** + * Required. Indicates the answer user has chosen. + */ + @Json(name = "selected_party_id") val selectedPartyId: String? = null, + + /** + * Required. The version of the VoIP specification this message adheres to. + */ + @Json(name = "version") override val version: String? +): CallSignallingContent diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallSignallingContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallSignallingContent.kt new file mode 100644 index 0000000000..f8d8c2a5e8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/CallSignallingContent.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.room.model.call + +interface CallSignallingContent { + /** + * Required. A unique identifier for the call. + */ + val callId: String? + + /** + * Required. ID to let user identify remote echo of their own events + */ + val partyId: String? + + /** + * Required. The version of the VoIP specification this message adheres to. This specification is version 0. + */ + val version: String? +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/SdpType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/SdpType.kt index ff393135ea..9b55ab80c7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/SdpType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/call/SdpType.kt @@ -25,5 +25,5 @@ enum class SdpType { OFFER, @Json(name = "answer") - ANSWER + ANSWER; } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/state/StateService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/state/StateService.kt index 444366e912..e614ea91d6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/state/StateService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/state/StateService.kt @@ -65,13 +65,30 @@ interface StateService { */ suspend fun deleteAvatar() + /** + * Send a state event to the room + */ suspend fun sendStateEvent(eventType: String, stateKey: String?, body: JsonDict) + /** + * Get a state event of the room + */ fun getStateEvent(eventType: String, stateKey: QueryStringValue = QueryStringValue.NoCondition): Event? + /** + * Get a live state event of the room + */ fun getStateEventLive(eventType: String, stateKey: QueryStringValue = QueryStringValue.NoCondition): LiveData> + /** + * Get state events of the room + * @param eventTypes Set of eventType. If empty, all state events will be returned + */ fun getStateEvents(eventTypes: Set, stateKey: QueryStringValue = QueryStringValue.NoCondition): List + /** + * Get live state events of the room + * @param eventTypes Set of eventType to observe. If empty, all state events will be observed + */ fun getStateEventsLive(eventTypes: Set, stateKey: QueryStringValue = QueryStringValue.NoCondition): LiveData> } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt index dcaf5f3276..ef6300eae2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt @@ -20,11 +20,15 @@ import org.matrix.android.sdk.api.session.events.model.EventType object RoomSummaryConstants { + /** + * + */ val PREVIEWABLE_TYPES = listOf( // TODO filter message type (KEY_VERIFICATION_READY, etc.) EventType.MESSAGE, EventType.CALL_INVITE, EventType.CALL_HANGUP, + EventType.CALL_REJECT, EventType.CALL_ANSWER, EventType.ENCRYPTED, EventType.STICKER, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt index b10fb540e1..53f0e5a8d3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt @@ -52,6 +52,8 @@ data class TimelineEvent( } } + val roomId = root.roomId ?: "" + val metadata = HashMap() /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/thirdparty/ThirdPartyService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/thirdparty/ThirdPartyService.kt new file mode 100644 index 0000000000..2ae4562b0b --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/thirdparty/ThirdPartyService.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.thirdparty + +import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol +import org.matrix.android.sdk.api.session.thirdparty.model.ThirdPartyUser + +/** + * See https://matrix.org/docs/spec/client_server/r0.4.0.html#get-matrix-client-r0-thirdparty-protocols + */ +interface ThirdPartyService { + + /** + * Fetches the overall metadata about protocols supported by the homeserver. + * Includes both the available protocols and all fields required for queries against each protocol. + */ + suspend fun getThirdPartyProtocols(): Map + + /** + * Retrieve a Matrix User ID linked to a user on the third party service, given a set of user parameters. + * @param protocol Required. The name of the protocol. + * @param fields One or more custom fields that are passed to the AS to help identify the user. + */ + suspend fun getThirdPartyUser(protocol: String, fields: Map = emptyMap()): List +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/thirdparty/model/ThirdPartyUser.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/thirdparty/model/ThirdPartyUser.kt new file mode 100644 index 0000000000..d77dfcfe35 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/thirdparty/model/ThirdPartyUser.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.thirdparty.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.util.JsonDict + +@JsonClass(generateAdapter = true) +data class ThirdPartyUser( + /* + Required. A Matrix User ID represting a third party user. + */ + @Json(name = "userid") val userId: String, + /* + Required. The protocol ID that the third party location is a part of. + */ + @Json(name = "protocol") val protocol: String, + /* + Required. Information used to identify this third party location. + */ + @Json(name = "fields") val fields: JsonDict +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/WidgetService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/WidgetService.kt index 0310a3d001..bf3ff8959d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/WidgetService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/WidgetService.kt @@ -56,6 +56,11 @@ interface WidgetService { excludedTypes: Set? = null ): List + /** + * Return the computed URL of a widget + */ + fun getWidgetComputedUrl(widget: Widget, isLightTheme: Boolean): String? + /** * Returns the live room widgets so you can listen to them. * Some widgets can be deactivated, so be sure to check for isActive. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/Widget.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/Widget.kt index c8465d4d2e..86aaba7f6f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/Widget.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/Widget.kt @@ -25,7 +25,6 @@ data class Widget( val widgetId: String, val senderInfo: SenderInfo?, val isAddedByMe: Boolean, - val computedUrl: String?, val type: WidgetType ) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt index 2ec8900f7c..bb62dbbfe9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt @@ -20,7 +20,9 @@ import android.content.Context import dagger.Binds import dagger.Module import dagger.Provides +import io.realm.RealmConfiguration import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.auth.HomeServerHistoryService import org.matrix.android.sdk.api.legacy.LegacySessionImporter import org.matrix.android.sdk.internal.auth.db.AuthRealmMigration import org.matrix.android.sdk.internal.auth.db.AuthRealmModule @@ -32,8 +34,6 @@ import org.matrix.android.sdk.internal.database.RealmKeysUtils import org.matrix.android.sdk.internal.di.AuthDatabase import org.matrix.android.sdk.internal.legacy.DefaultLegacySessionImporter import org.matrix.android.sdk.internal.wellknown.WellknownModule -import io.realm.RealmConfiguration -import org.matrix.android.sdk.api.auth.HomeServerHistoryService import java.io.File @Module(includes = [WellknownModule::class]) @@ -82,6 +82,9 @@ internal abstract class AuthModule { @Binds abstract fun bindDirectLoginTask(task: DefaultDirectLoginTask): DirectLoginTask + @Binds + abstract fun bindIsValidClientServerApiTask(task: DefaultIsValidClientServerApiTask): IsValidClientServerApiTask + @Binds abstract fun bindHomeServerHistoryService(service: DefaultHomeServerHistoryService): HomeServerHistoryService } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt index c99e9bd81c..4f3451cf30 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt @@ -18,10 +18,7 @@ package org.matrix.android.sdk.internal.auth import android.net.Uri import dagger.Lazy -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import okhttp3.OkHttpClient -import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.auth.data.Credentials import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig @@ -32,8 +29,6 @@ import org.matrix.android.sdk.api.auth.registration.RegistrationWizard import org.matrix.android.sdk.api.auth.wellknown.WellknownResult import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.util.Cancelable -import org.matrix.android.sdk.api.util.NoOpCancellable import org.matrix.android.sdk.api.util.appendParamToUrl import org.matrix.android.sdk.internal.SessionManager import org.matrix.android.sdk.internal.auth.data.LoginFlowResponse @@ -50,11 +45,6 @@ import org.matrix.android.sdk.internal.network.RetrofitFactory import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.network.httpclient.addSocketFactory import org.matrix.android.sdk.internal.network.ssl.UnrecognizedCertificateException -import org.matrix.android.sdk.internal.task.TaskExecutor -import org.matrix.android.sdk.internal.task.configureWith -import org.matrix.android.sdk.internal.task.launchToCallback -import org.matrix.android.sdk.internal.util.MatrixCoroutineDispatchers -import org.matrix.android.sdk.internal.util.toCancelable import org.matrix.android.sdk.internal.wellknown.GetWellknownTask import javax.inject.Inject import javax.net.ssl.HttpsURLConnection @@ -63,14 +53,12 @@ internal class DefaultAuthenticationService @Inject constructor( @Unauthenticated private val okHttpClient: Lazy, private val retrofitFactory: RetrofitFactory, - private val coroutineDispatchers: MatrixCoroutineDispatchers, private val sessionParamsStore: SessionParamsStore, private val sessionManager: SessionManager, private val sessionCreator: SessionCreator, private val pendingSessionStore: PendingSessionStore, private val getWellknownTask: GetWellknownTask, - private val directLoginTask: DirectLoginTask, - private val taskExecutor: TaskExecutor + private val directLoginTask: DirectLoginTask ) : AuthenticationService { private var pendingSessionData: PendingSessionData? = pendingSessionStore.getPendingSessionData() @@ -89,15 +77,11 @@ internal class DefaultAuthenticationService @Inject constructor( } } - override fun getLoginFlowOfSession(sessionId: String, callback: MatrixCallback): Cancelable { + override suspend fun getLoginFlowOfSession(sessionId: String): LoginFlowResult { val homeServerConnectionConfig = sessionParamsStore.get(sessionId)?.homeServerConnectionConfig + ?: throw IllegalStateException("Session not found") - return if (homeServerConnectionConfig == null) { - callback.onFailure(IllegalStateException("Session not found")) - NoOpCancellable - } else { - getLoginFlow(homeServerConnectionConfig, callback) - } + return getLoginFlow(homeServerConnectionConfig) } override fun getSsoUrl(redirectUrl: String, deviceId: String?, providerId: String?): String? { @@ -146,70 +130,70 @@ internal class DefaultAuthenticationService @Inject constructor( ?.trim { it == '/' } } - override fun getLoginFlow(homeServerConnectionConfig: HomeServerConnectionConfig, callback: MatrixCallback): Cancelable { + /** + * This is the entry point of the authentication service. + * homeServerConnectionConfig contains a homeserver URL probably entered by the user, which can be a + * valid homeserver API url, the url of Element Web, or anything else. + */ + override suspend fun getLoginFlow(homeServerConnectionConfig: HomeServerConnectionConfig): LoginFlowResult { pendingSessionData = null - return taskExecutor.executorScope.launch(coroutineDispatchers.main) { - pendingSessionStore.delete() + pendingSessionStore.delete() - val result = runCatching { - getLoginFlowInternal(homeServerConnectionConfig) - } - result.fold( - { - if (it is LoginFlowResult.Success) { - // The homeserver exists and up to date, keep the config - // Homeserver url may have been changed, if it was a Riot url - val alteredHomeServerConnectionConfig = homeServerConnectionConfig.copy( - homeServerUri = Uri.parse(it.homeServerUrl) - ) - - pendingSessionData = PendingSessionData(alteredHomeServerConnectionConfig) - .also { data -> pendingSessionStore.savePendingSessionData(data) } - } - callback.onSuccess(it) - }, - { - if (it is UnrecognizedCertificateException) { - callback.onFailure(Failure.UnrecognizedCertificateFailure(homeServerConnectionConfig.homeServerUri.toString(), it.fingerprint)) - } else { - callback.onFailure(it) - } - } - ) + val result = runCatching { + getLoginFlowInternal(homeServerConnectionConfig) } - .toCancelable() + return result.fold( + { + if (it is LoginFlowResult.Success) { + // The homeserver exists and up to date, keep the config + // Homeserver url may have been changed, if it was a Riot url + val alteredHomeServerConnectionConfig = homeServerConnectionConfig.copy( + homeServerUri = Uri.parse(it.homeServerUrl) + ) + + pendingSessionData = PendingSessionData(alteredHomeServerConnectionConfig) + .also { data -> pendingSessionStore.savePendingSessionData(data) } + } + it + }, + { + if (it is UnrecognizedCertificateException) { + throw Failure.UnrecognizedCertificateFailure(homeServerConnectionConfig.homeServerUri.toString(), it.fingerprint) + } else { + throw it + } + } + ) } private suspend fun getLoginFlowInternal(homeServerConnectionConfig: HomeServerConnectionConfig): LoginFlowResult { - return withContext(coroutineDispatchers.io) { - val authAPI = buildAuthAPI(homeServerConnectionConfig) + val authAPI = buildAuthAPI(homeServerConnectionConfig) - // First check the homeserver version - runCatching { - executeRequest(null) { - apiCall = authAPI.versions() - } + // First check the homeserver version + return runCatching { + executeRequest(null) { + apiCall = authAPI.versions() } - .map { versions -> - // Ok, it seems that the homeserver url is valid - getLoginFlowResult(authAPI, versions, homeServerConnectionConfig.homeServerUri.toString()) - } - .fold( - { - it - }, - { - if (it is Failure.OtherServerError - && it.httpCode == HttpsURLConnection.HTTP_NOT_FOUND /* 404 */) { - // It's maybe a Riot url? - getRiotDomainLoginFlowInternal(homeServerConnectionConfig) - } else { - throw it - } - } - ) } + .map { versions -> + // Ok, it seems that the homeserver url is valid + getLoginFlowResult(authAPI, versions, homeServerConnectionConfig.homeServerUri.toString()) + } + .fold( + { + it + }, + { + if (it is Failure.OtherServerError + && it.httpCode == HttpsURLConnection.HTTP_NOT_FOUND /* 404 */) { + // It's maybe a Riot url? + getRiotDomainLoginFlowInternal(homeServerConnectionConfig) + } else { + throw it + } + } + ) } private suspend fun getRiotDomainLoginFlowInternal(homeServerConnectionConfig: HomeServerConnectionConfig): LoginFlowResult { @@ -338,12 +322,9 @@ internal class DefaultAuthenticationService @Inject constructor( ?: let { pendingSessionData?.homeServerConnectionConfig?.let { DefaultRegistrationWizard( - buildClient(it), - retrofitFactory, - coroutineDispatchers, + buildAuthAPI(it), sessionCreator, - pendingSessionStore, - taskExecutor.executorScope + pendingSessionStore ).also { currentRegistrationWizard = it } @@ -359,12 +340,9 @@ internal class DefaultAuthenticationService @Inject constructor( ?: let { pendingSessionData?.homeServerConnectionConfig?.let { DefaultLoginWizard( - buildClient(it), - retrofitFactory, - coroutineDispatchers, + buildAuthAPI(it), sessionCreator, - pendingSessionStore, - taskExecutor.executorScope + pendingSessionStore ).also { currentLoginWizard = it } @@ -372,7 +350,7 @@ internal class DefaultAuthenticationService @Inject constructor( } } - override fun cancelPendingLoginOrRegistration() { + override suspend fun cancelPendingLoginOrRegistration() { currentLoginWizard = null currentRegistrationWizard = null @@ -381,61 +359,39 @@ internal class DefaultAuthenticationService @Inject constructor( pendingSessionData = pendingSessionData?.homeServerConnectionConfig ?.let { PendingSessionData(it) } .also { - taskExecutor.executorScope.launch(coroutineDispatchers.main) { - if (it == null) { - // Should not happen - pendingSessionStore.delete() - } else { - pendingSessionStore.savePendingSessionData(it) - } + if (it == null) { + // Should not happen + pendingSessionStore.delete() + } else { + pendingSessionStore.savePendingSessionData(it) } } } - override fun reset() { + override suspend fun reset() { currentLoginWizard = null currentRegistrationWizard = null pendingSessionData = null - taskExecutor.executorScope.launch(coroutineDispatchers.main) { - pendingSessionStore.delete() - } + pendingSessionStore.delete() } - override fun createSessionFromSso(homeServerConnectionConfig: HomeServerConnectionConfig, - credentials: Credentials, - callback: MatrixCallback): Cancelable { - return taskExecutor.executorScope.launchToCallback(coroutineDispatchers.main, callback) { - createSessionFromSso(credentials, homeServerConnectionConfig) - } + override suspend fun createSessionFromSso(homeServerConnectionConfig: HomeServerConnectionConfig, + credentials: Credentials): Session { + return sessionCreator.createSession(credentials, homeServerConnectionConfig) } - override fun getWellKnownData(matrixId: String, - homeServerConnectionConfig: HomeServerConnectionConfig?, - callback: MatrixCallback): Cancelable { - return getWellknownTask - .configureWith(GetWellknownTask.Params(matrixId, homeServerConnectionConfig)) { - this.callback = callback - } - .executeBy(taskExecutor) + override suspend fun getWellKnownData(matrixId: String, + homeServerConnectionConfig: HomeServerConnectionConfig?): WellknownResult { + return getWellknownTask.execute(GetWellknownTask.Params(matrixId, homeServerConnectionConfig)) } - override fun directAuthentication(homeServerConnectionConfig: HomeServerConnectionConfig, - matrixId: String, - password: String, - initialDeviceName: String, - callback: MatrixCallback): Cancelable { - return directLoginTask - .configureWith(DirectLoginTask.Params(homeServerConnectionConfig, matrixId, password, initialDeviceName)) { - this.callback = callback - } - .executeBy(taskExecutor) - } - - private suspend fun createSessionFromSso(credentials: Credentials, - homeServerConnectionConfig: HomeServerConnectionConfig): Session = withContext(coroutineDispatchers.computation) { - sessionCreator.createSession(credentials, homeServerConnectionConfig) + override suspend fun directAuthentication(homeServerConnectionConfig: HomeServerConnectionConfig, + matrixId: String, + password: String, + initialDeviceName: String): Session { + return directLoginTask.execute(DirectLoginTask.Params(homeServerConnectionConfig, matrixId, password, initialDeviceName)) } private fun buildAuthAPI(homeServerConnectionConfig: HomeServerConnectionConfig): AuthAPI { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/IsValidClientServerApiTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/IsValidClientServerApiTask.kt new file mode 100644 index 0000000000..b8416d69bf --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/IsValidClientServerApiTask.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.auth + +import dagger.Lazy +import okhttp3.OkHttpClient +import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.internal.auth.data.LoginFlowResponse +import org.matrix.android.sdk.internal.di.Unauthenticated +import org.matrix.android.sdk.internal.network.RetrofitFactory +import org.matrix.android.sdk.internal.network.executeRequest +import org.matrix.android.sdk.internal.network.httpclient.addSocketFactory +import org.matrix.android.sdk.internal.task.Task +import javax.inject.Inject +import javax.net.ssl.HttpsURLConnection + +internal interface IsValidClientServerApiTask : Task { + data class Params( + val homeServerConnectionConfig: HomeServerConnectionConfig + ) +} + +internal class DefaultIsValidClientServerApiTask @Inject constructor( + @Unauthenticated + private val okHttpClient: Lazy, + private val retrofitFactory: RetrofitFactory +) : IsValidClientServerApiTask { + + override suspend fun execute(params: IsValidClientServerApiTask.Params): Boolean { + val client = buildClient(params.homeServerConnectionConfig) + val homeServerUrl = params.homeServerConnectionConfig.homeServerUri.toString() + + val authAPI = retrofitFactory.create(client, homeServerUrl) + .create(AuthAPI::class.java) + + return try { + executeRequest(null) { + apiCall = authAPI.getLoginFlows() + } + // We get a response, so the API is valid + true + } catch (failure: Throwable) { + if (failure is Failure.OtherServerError + && failure.httpCode == HttpsURLConnection.HTTP_NOT_FOUND /* 404 */) { + // Probably not valid + false + } else { + // Other error + throw failure + } + } + } + + private fun buildClient(homeServerConnectionConfig: HomeServerConnectionConfig): OkHttpClient { + return okHttpClient.get() + .newBuilder() + .addSocketFactory(homeServerConnectionConfig) + .build() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/SessionCreator.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/SessionCreator.kt index 6743e7336e..7c4a0c38ec 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/SessionCreator.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/SessionCreator.kt @@ -20,6 +20,7 @@ import android.net.Uri import org.matrix.android.sdk.api.auth.data.Credentials import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.data.SessionParams +import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.internal.SessionManager import timber.log.Timber @@ -32,7 +33,8 @@ internal interface SessionCreator { internal class DefaultSessionCreator @Inject constructor( private val sessionParamsStore: SessionParamsStore, private val sessionManager: SessionManager, - private val pendingSessionStore: PendingSessionStore + private val pendingSessionStore: PendingSessionStore, + private val isValidClientServerApiTask: IsValidClientServerApiTask ) : SessionCreator { /** @@ -43,16 +45,28 @@ internal class DefaultSessionCreator @Inject constructor( // We can cleanup the pending session params pendingSessionStore.delete() + val overriddenUrl = credentials.discoveryInformation?.homeServer?.baseURL + // remove trailing "/" + ?.trim { it == '/' } + ?.takeIf { it.isNotBlank() } + ?.also { Timber.d("Overriding homeserver url to $it (will check if valid)") } + ?.let { Uri.parse(it) } + ?.takeIf { + // Validate the URL, if the configuration is wrong server side, do not override + tryOrNull { + isValidClientServerApiTask.execute( + IsValidClientServerApiTask.Params( + homeServerConnectionConfig.copy(homeServerUri = it) + ) + ) + .also { Timber.d("Overriding homeserver url: $it") } + } ?: true // In case of other error (no network, etc.), consider it is valid... + } + val sessionParams = SessionParams( credentials = credentials, homeServerConnectionConfig = homeServerConnectionConfig.copy( - homeServerUri = credentials.discoveryInformation?.homeServer?.baseURL - // remove trailing "/" - ?.trim { it == '/' } - ?.takeIf { it.isNotBlank() } - ?.also { Timber.d("Overriding homeserver url to $it") } - ?.let { Uri.parse(it) } - ?: homeServerConnectionConfig.homeServerUri, + homeServerUri = overriddenUrl ?: homeServerConnectionConfig.homeServerUri, identityServerUri = credentials.discoveryInformation?.identityServer?.baseURL // remove trailing "/" ?.trim { it == '/' } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt index 108d0d4a42..4167875849 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt @@ -17,13 +17,10 @@ package org.matrix.android.sdk.internal.auth.login import android.util.Patterns -import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.auth.data.Credentials import org.matrix.android.sdk.api.auth.login.LoginWizard import org.matrix.android.sdk.api.auth.registration.RegisterThreePid import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.util.Cancelable -import org.matrix.android.sdk.api.util.NoOpCancellable import org.matrix.android.sdk.internal.auth.AuthAPI import org.matrix.android.sdk.internal.auth.PendingSessionStore import org.matrix.android.sdk.internal.auth.SessionCreator @@ -34,56 +31,19 @@ import org.matrix.android.sdk.internal.auth.db.PendingSessionData import org.matrix.android.sdk.internal.auth.registration.AddThreePidRegistrationParams import org.matrix.android.sdk.internal.auth.registration.AddThreePidRegistrationResponse import org.matrix.android.sdk.internal.auth.registration.RegisterAddThreePidTask -import org.matrix.android.sdk.internal.network.RetrofitFactory import org.matrix.android.sdk.internal.network.executeRequest -import org.matrix.android.sdk.internal.task.launchToCallback -import org.matrix.android.sdk.internal.util.MatrixCoroutineDispatchers -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient internal class DefaultLoginWizard( - okHttpClient: OkHttpClient, - retrofitFactory: RetrofitFactory, - private val coroutineDispatchers: MatrixCoroutineDispatchers, + private val authAPI: AuthAPI, private val sessionCreator: SessionCreator, - private val pendingSessionStore: PendingSessionStore, - private val coroutineScope: CoroutineScope + private val pendingSessionStore: PendingSessionStore ) : LoginWizard { private var pendingSessionData: PendingSessionData = pendingSessionStore.getPendingSessionData() ?: error("Pending session data should exist here") - private val authAPI = retrofitFactory.create(okHttpClient, pendingSessionData.homeServerConnectionConfig.homeServerUri.toString()) - .create(AuthAPI::class.java) - - override fun login(login: String, - password: String, - deviceName: String, - callback: MatrixCallback): Cancelable { - return coroutineScope.launchToCallback(coroutineDispatchers.main, callback) { - loginInternal(login, password, deviceName) - } - } - - /** - * Ref: https://matrix.org/docs/spec/client_server/latest#handling-the-authentication-endpoint - */ - override fun loginWithToken(loginToken: String, callback: MatrixCallback): Cancelable { - return coroutineScope.launchToCallback(coroutineDispatchers.main, callback) { - val loginParams = TokenLoginParams( - token = loginToken - ) - val credentials = executeRequest(null) { - apiCall = authAPI.login(loginParams) - } - - sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) - } - } - - private suspend fun loginInternal(login: String, - password: String, - deviceName: String) = withContext(coroutineDispatchers.computation) { + override suspend fun login(login: String, + password: String, + deviceName: String): Session { val loginParams = if (Patterns.EMAIL_ADDRESS.matcher(login).matches()) { PasswordLoginParams.thirdPartyIdentifier(ThreePidMedium.EMAIL, login, password, deviceName) } else { @@ -93,16 +53,24 @@ internal class DefaultLoginWizard( apiCall = authAPI.login(loginParams) } - sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) + return sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) } - override fun resetPassword(email: String, newPassword: String, callback: MatrixCallback): Cancelable { - return coroutineScope.launchToCallback(coroutineDispatchers.main, callback) { - resetPasswordInternal(email, newPassword) + /** + * Ref: https://matrix.org/docs/spec/client_server/latest#handling-the-authentication-endpoint + */ + override suspend fun loginWithToken(loginToken: String): Session { + val loginParams = TokenLoginParams( + token = loginToken + ) + val credentials = executeRequest(null) { + apiCall = authAPI.login(loginParams) } + + return sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) } - private suspend fun resetPasswordInternal(email: String, newPassword: String) { + override suspend fun resetPassword(email: String, newPassword: String) { val param = RegisterAddThreePidTask.Params( RegisterThreePid.Email(email), pendingSessionData.clientSecret, @@ -120,21 +88,14 @@ internal class DefaultLoginWizard( .also { pendingSessionStore.savePendingSessionData(it) } } - override fun resetPasswordMailConfirmed(callback: MatrixCallback): Cancelable { - val safeResetPasswordData = pendingSessionData.resetPasswordData ?: run { - callback.onFailure(IllegalStateException("developer error, no reset password in progress")) - return NoOpCancellable - } - return coroutineScope.launchToCallback(coroutineDispatchers.main, callback) { - resetPasswordMailConfirmedInternal(safeResetPasswordData) - } - } + override suspend fun resetPasswordMailConfirmed() { + val safeResetPasswordData = pendingSessionData.resetPasswordData + ?: throw IllegalStateException("developer error, no reset password in progress") - private suspend fun resetPasswordMailConfirmedInternal(resetPasswordData: ResetPasswordData) { val param = ResetPasswordMailConfirmed.create( pendingSessionData.clientSecret, - resetPasswordData.addThreePidRegistrationResponse.sid, - resetPasswordData.newPassword + safeResetPasswordData.addThreePidRegistrationResponse.sid, + safeResetPasswordData.newPassword ) executeRequest(null) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/DefaultRegistrationWizard.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/DefaultRegistrationWizard.kt index 163009d918..91e414e689 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/DefaultRegistrationWizard.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/DefaultRegistrationWizard.kt @@ -16,10 +16,7 @@ package org.matrix.android.sdk.internal.auth.registration -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay -import okhttp3.OkHttpClient -import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.auth.registration.RegisterThreePid import org.matrix.android.sdk.api.auth.registration.RegistrationResult @@ -27,31 +24,22 @@ import org.matrix.android.sdk.api.auth.registration.RegistrationWizard import org.matrix.android.sdk.api.auth.registration.toFlowResult import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.Failure.RegistrationFlowError -import org.matrix.android.sdk.api.util.Cancelable -import org.matrix.android.sdk.api.util.NoOpCancellable import org.matrix.android.sdk.internal.auth.AuthAPI import org.matrix.android.sdk.internal.auth.PendingSessionStore import org.matrix.android.sdk.internal.auth.SessionCreator import org.matrix.android.sdk.internal.auth.db.PendingSessionData -import org.matrix.android.sdk.internal.network.RetrofitFactory -import org.matrix.android.sdk.internal.task.launchToCallback -import org.matrix.android.sdk.internal.util.MatrixCoroutineDispatchers /** * This class execute the registration request and is responsible to keep the session of interactive authentication */ internal class DefaultRegistrationWizard( - private val okHttpClient: OkHttpClient, - private val retrofitFactory: RetrofitFactory, - private val coroutineDispatchers: MatrixCoroutineDispatchers, + authAPI: AuthAPI, private val sessionCreator: SessionCreator, - private val pendingSessionStore: PendingSessionStore, - private val coroutineScope: CoroutineScope + private val pendingSessionStore: PendingSessionStore ) : RegistrationWizard { private var pendingSessionData: PendingSessionData = pendingSessionStore.getPendingSessionData() ?: error("Pending session data should exist here") - private val authAPI = buildAuthAPI() private val registerTask = DefaultRegisterTask(authAPI) private val registerAddThreePidTask = DefaultRegisterAddThreePidTask(authAPI) private val validateCodeTask = DefaultValidateCodeTask(authAPI) @@ -71,70 +59,54 @@ internal class DefaultRegistrationWizard( override val isRegistrationStarted: Boolean get() = pendingSessionData.isRegistrationStarted - override fun getRegistrationFlow(callback: MatrixCallback): Cancelable { + override suspend fun getRegistrationFlow(): RegistrationResult { val params = RegistrationParams() - return coroutineScope.launchToCallback(coroutineDispatchers.main, callback) { - performRegistrationRequest(params) - } + return performRegistrationRequest(params) } - override fun createAccount(userName: String, - password: String, - initialDeviceDisplayName: String?, - callback: MatrixCallback): Cancelable { + override suspend fun createAccount(userName: String, + password: String, + initialDeviceDisplayName: String?): RegistrationResult { val params = RegistrationParams( username = userName, password = password, initialDeviceDisplayName = initialDeviceDisplayName ) - return coroutineScope.launchToCallback(coroutineDispatchers.main, callback) { - performRegistrationRequest(params) - .also { - pendingSessionData = pendingSessionData.copy(isRegistrationStarted = true) - .also { pendingSessionStore.savePendingSessionData(it) } - } - } + return performRegistrationRequest(params) + .also { + pendingSessionData = pendingSessionData.copy(isRegistrationStarted = true) + .also { pendingSessionStore.savePendingSessionData(it) } + } } - override fun performReCaptcha(response: String, callback: MatrixCallback): Cancelable { - val safeSession = pendingSessionData.currentSession ?: run { - callback.onFailure(IllegalStateException("developer error, call createAccount() method first")) - return NoOpCancellable - } + override suspend fun performReCaptcha(response: String): RegistrationResult { + val safeSession = pendingSessionData.currentSession + ?: throw IllegalStateException("developer error, call createAccount() method first") + val params = RegistrationParams(auth = AuthParams.createForCaptcha(safeSession, response)) - return coroutineScope.launchToCallback(coroutineDispatchers.main, callback) { - performRegistrationRequest(params) - } + return performRegistrationRequest(params) } - override fun acceptTerms(callback: MatrixCallback): Cancelable { - val safeSession = pendingSessionData.currentSession ?: run { - callback.onFailure(IllegalStateException("developer error, call createAccount() method first")) - return NoOpCancellable - } + override suspend fun acceptTerms(): RegistrationResult { + val safeSession = pendingSessionData.currentSession + ?: throw IllegalStateException("developer error, call createAccount() method first") + val params = RegistrationParams(auth = AuthParams(type = LoginFlowTypes.TERMS, session = safeSession)) - return coroutineScope.launchToCallback(coroutineDispatchers.main, callback) { - performRegistrationRequest(params) - } + return performRegistrationRequest(params) } - override fun addThreePid(threePid: RegisterThreePid, callback: MatrixCallback): Cancelable { - return coroutineScope.launchToCallback(coroutineDispatchers.main, callback) { - pendingSessionData = pendingSessionData.copy(currentThreePidData = null) - .also { pendingSessionStore.savePendingSessionData(it) } + override suspend fun addThreePid(threePid: RegisterThreePid): RegistrationResult { + pendingSessionData = pendingSessionData.copy(currentThreePidData = null) + .also { pendingSessionStore.savePendingSessionData(it) } - sendThreePid(threePid) - } + return sendThreePid(threePid) } - override fun sendAgainThreePid(callback: MatrixCallback): Cancelable { - val safeCurrentThreePid = pendingSessionData.currentThreePidData?.threePid ?: run { - callback.onFailure(IllegalStateException("developer error, call createAccount() method first")) - return NoOpCancellable - } - return coroutineScope.launchToCallback(coroutineDispatchers.main, callback) { - sendThreePid(safeCurrentThreePid) - } + override suspend fun sendAgainThreePid(): RegistrationResult { + val safeCurrentThreePid = pendingSessionData.currentThreePidData?.threePid + ?: throw IllegalStateException("developer error, call createAccount() method first") + + return sendThreePid(safeCurrentThreePid) } private suspend fun sendThreePid(threePid: RegisterThreePid): RegistrationResult { @@ -173,20 +145,15 @@ internal class DefaultRegistrationWizard( return performRegistrationRequest(params) } - override fun checkIfEmailHasBeenValidated(delayMillis: Long, callback: MatrixCallback): Cancelable { - val safeParam = pendingSessionData.currentThreePidData?.registrationParams ?: run { - callback.onFailure(IllegalStateException("developer error, no pending three pid")) - return NoOpCancellable - } - return coroutineScope.launchToCallback(coroutineDispatchers.main, callback) { - performRegistrationRequest(safeParam, delayMillis) - } + override suspend fun checkIfEmailHasBeenValidated(delayMillis: Long): RegistrationResult { + val safeParam = pendingSessionData.currentThreePidData?.registrationParams + ?: throw IllegalStateException("developer error, no pending three pid") + + return performRegistrationRequest(safeParam, delayMillis) } - override fun handleValidateThreePid(code: String, callback: MatrixCallback): Cancelable { - return coroutineScope.launchToCallback(coroutineDispatchers.main, callback) { - validateThreePid(code) - } + override suspend fun handleValidateThreePid(code: String): RegistrationResult { + return validateThreePid(code) } private suspend fun validateThreePid(code: String): RegistrationResult { @@ -210,15 +177,12 @@ internal class DefaultRegistrationWizard( } } - override fun dummy(callback: MatrixCallback): Cancelable { - val safeSession = pendingSessionData.currentSession ?: run { - callback.onFailure(IllegalStateException("developer error, call createAccount() method first")) - return NoOpCancellable - } - return coroutineScope.launchToCallback(coroutineDispatchers.main, callback) { - val params = RegistrationParams(auth = AuthParams(type = LoginFlowTypes.DUMMY, session = safeSession)) - performRegistrationRequest(params) - } + override suspend fun dummy(): RegistrationResult { + val safeSession = pendingSessionData.currentSession + ?: throw IllegalStateException("developer error, call createAccount() method first") + + val params = RegistrationParams(auth = AuthParams(type = LoginFlowTypes.DUMMY, session = safeSession)) + return performRegistrationRequest(params) } private suspend fun performRegistrationRequest(registrationParams: RegistrationParams, @@ -239,9 +203,4 @@ internal class DefaultRegistrationWizard( val session = sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) return RegistrationResult.Success(session) } - - private fun buildAuthAPI(): AuthAPI { - val retrofit = retrofitFactory.create(okHttpClient, pendingSessionData.homeServerConnectionConfig.homeServerUri.toString()) - return retrofit.create(AuthAPI::class.java) - } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/UIAExt.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/UIAExt.kt index 1a0383cb22..da0866a5fd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/UIAExt.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/UIAExt.kt @@ -16,14 +16,25 @@ package org.matrix.android.sdk.internal.auth.registration +import org.matrix.android.sdk.api.auth.UIABaseAuth import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.toRegistrationFlowResponse -import org.matrix.android.sdk.api.auth.UIABaseAuth import timber.log.Timber import kotlin.coroutines.suspendCoroutine -internal suspend fun handleUIA(failure: Throwable, interceptor: UserInteractiveAuthInterceptor, retryBlock: suspend (UIABaseAuth) -> Unit): Boolean { +/** + * Handle a UIA challenge + * + * @param failure the failure to handle + * @param interceptor see doc in [UserInteractiveAuthInterceptor] + * @param retryBlock called at the end of the process, in this block generally retry executing the task, with + * provided authUpdate + * @return true if UIA is handled without error + */ +internal suspend fun handleUIA(failure: Throwable, + interceptor: UserInteractiveAuthInterceptor, + retryBlock: suspend (UIABaseAuth) -> Unit): Boolean { Timber.d("## UIA: check error ${failure.message}") val flowResponse = failure.toRegistrationFlowResponse() ?: return false.also { @@ -38,16 +49,16 @@ internal suspend fun handleUIA(failure: Throwable, interceptor: UserInteractiveA suspendCoroutine { continuation -> interceptor.performStage(flowResponse, (failure as? Failure.ServerError)?.error?.code, continuation) } - } catch (failure: Throwable) { - Timber.w(failure, "## UIA: failed to participate") + } catch (failure2: Throwable) { + Timber.w(failure2, "## UIA: failed to participate") return false } - Timber.d("## UIA: updated auth $authUpdate") + Timber.d("## UIA: updated auth") return try { retryBlock(authUpdate) true - } catch (failure: Throwable) { - handleUIA(failure, interceptor, retryBlock) + } catch (failure3: Throwable) { + handleUIA(failure3, interceptor, retryBlock) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoModule.kt index a786ebd4b2..e114f86a99 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoModule.kt @@ -61,7 +61,6 @@ import org.matrix.android.sdk.internal.crypto.store.db.RealmCryptoStoreModule import org.matrix.android.sdk.internal.crypto.tasks.ClaimOneTimeKeysForUsersDeviceTask import org.matrix.android.sdk.internal.crypto.tasks.DefaultClaimOneTimeKeysForUsersDevice import org.matrix.android.sdk.internal.crypto.tasks.DefaultDeleteDeviceTask -import org.matrix.android.sdk.internal.crypto.tasks.DefaultDeleteDeviceWithUserPasswordTask import org.matrix.android.sdk.internal.crypto.tasks.DefaultDownloadKeysForUsers import org.matrix.android.sdk.internal.crypto.tasks.DefaultEncryptEventTask import org.matrix.android.sdk.internal.crypto.tasks.DefaultGetDeviceInfoTask @@ -75,7 +74,6 @@ import org.matrix.android.sdk.internal.crypto.tasks.DefaultUploadKeysTask import org.matrix.android.sdk.internal.crypto.tasks.DefaultUploadSignaturesTask import org.matrix.android.sdk.internal.crypto.tasks.DefaultUploadSigningKeysTask import org.matrix.android.sdk.internal.crypto.tasks.DeleteDeviceTask -import org.matrix.android.sdk.internal.crypto.tasks.DeleteDeviceWithUserPasswordTask import org.matrix.android.sdk.internal.crypto.tasks.DownloadKeysForUsersTask import org.matrix.android.sdk.internal.crypto.tasks.EncryptEventTask import org.matrix.android.sdk.internal.crypto.tasks.GetDeviceInfoTask @@ -240,9 +238,6 @@ internal abstract class CryptoModule { @Binds abstract fun bindClaimOneTimeKeysForUsersDeviceTask(task: DefaultClaimOneTimeKeysForUsersDevice): ClaimOneTimeKeysForUsersDeviceTask - @Binds - abstract fun bindDeleteDeviceWithUserPasswordTask(task: DefaultDeleteDeviceWithUserPasswordTask): DeleteDeviceWithUserPasswordTask - @Binds abstract fun bindCrossSigningService(service: DefaultCrossSigningService): CrossSigningService diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt index 678bc9819f..67229a5eae 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt @@ -75,7 +75,6 @@ import org.matrix.android.sdk.internal.crypto.model.toRest import org.matrix.android.sdk.internal.crypto.repository.WarnOnUnknownDeviceRepository import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore import org.matrix.android.sdk.internal.crypto.tasks.DeleteDeviceTask -import org.matrix.android.sdk.internal.crypto.tasks.DeleteDeviceWithUserPasswordTask import org.matrix.android.sdk.internal.crypto.tasks.GetDeviceInfoTask import org.matrix.android.sdk.internal.crypto.tasks.GetDevicesTask import org.matrix.android.sdk.internal.crypto.tasks.SetDeviceNameTask @@ -153,9 +152,8 @@ internal class DefaultCryptoService @Inject constructor( // Repository private val megolmEncryptionFactory: MXMegolmEncryptionFactory, private val olmEncryptionFactory: MXOlmEncryptionFactory, - private val deleteDeviceTask: DeleteDeviceTask, - private val deleteDeviceWithUserPasswordTask: DeleteDeviceWithUserPasswordTask, // Tasks + private val deleteDeviceTask: DeleteDeviceTask, private val getDevicesTask: GetDevicesTask, private val getDeviceInfoTask: GetDeviceInfoTask, private val setDeviceNameTask: SetDeviceNameTask, @@ -217,15 +215,6 @@ internal class DefaultCryptoService @Inject constructor( .executeBy(taskExecutor) } - override fun deleteDeviceWithUserPassword(deviceId: String, authSession: String?, password: String, callback: MatrixCallback) { - deleteDeviceWithUserPasswordTask - .configureWith(DeleteDeviceWithUserPasswordTask.Params(deviceId, authSession, password)) { - this.executionThread = TaskThread.CRYPTO - this.callback = callback - } - .executeBy(taskExecutor) - } - override fun getCryptoVersion(context: Context, longFormat: Boolean): String { return if (longFormat) olmManager.getDetailedVersion(context) else olmManager.version } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt index ff25ac0f66..61596bb5b6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt @@ -47,12 +47,16 @@ internal class DefaultDeleteDeviceTask @Inject constructor( } } catch (throwable: Throwable) { if (params.userInteractiveAuthInterceptor == null - || !handleUIA(throwable, params.userInteractiveAuthInterceptor) { auth -> - execute(params.copy(userAuthParam = auth)) - } + || !handleUIA( + failure = throwable, + interceptor = params.userInteractiveAuthInterceptor, + retryBlock = { authUpdate -> + execute(params.copy(userAuthParam = authUpdate)) + } + ) ) { Timber.d("## UIA: propagate failure") - throw throwable + throw throwable } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceWithUserPasswordTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceWithUserPasswordTask.kt deleted file mode 100644 index dc0077425e..0000000000 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceWithUserPasswordTask.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2020 The Matrix.org Foundation C.I.C. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.matrix.android.sdk.internal.crypto.tasks - -import org.matrix.android.sdk.api.auth.data.LoginFlowTypes -import org.matrix.android.sdk.internal.crypto.api.CryptoApi -import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDeviceParams -import org.matrix.android.sdk.api.auth.UserPasswordAuth -import org.matrix.android.sdk.internal.di.UserId -import org.matrix.android.sdk.internal.network.GlobalErrorReceiver -import org.matrix.android.sdk.internal.network.executeRequest -import org.matrix.android.sdk.internal.task.Task -import javax.inject.Inject - -internal interface DeleteDeviceWithUserPasswordTask : Task { - data class Params( - val deviceId: String, - val authSession: String?, - val password: String - ) -} - -internal class DefaultDeleteDeviceWithUserPasswordTask @Inject constructor( - private val cryptoApi: CryptoApi, - @UserId private val userId: String, - private val globalErrorReceiver: GlobalErrorReceiver -) : DeleteDeviceWithUserPasswordTask { - - override suspend fun execute(params: DeleteDeviceWithUserPasswordTask.Params) { - return executeRequest(globalErrorReceiver) { - apiCall = cryptoApi.deleteDevice(params.deviceId, - DeleteDeviceParams( - auth = UserPasswordAuth( - type = LoginFlowTypes.PASSWORD, - session = params.authSession, - user = userId, - password = params.password - ).asMap() - ) - ) - } - } -} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/InitializeCrossSigningTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/InitializeCrossSigningTask.kt index ef31130f55..f8a8354e48 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/InitializeCrossSigningTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/InitializeCrossSigningTask.kt @@ -126,11 +126,16 @@ internal class DefaultInitializeCrossSigningTask @Inject constructor( uploadSigningKeysTask.execute(uploadSigningKeysParams) } catch (failure: Throwable) { if (params.interactiveAuthInterceptor == null - || !handleUIA(failure, params.interactiveAuthInterceptor) { authUpdate -> - uploadSigningKeysTask.execute(uploadSigningKeysParams.copy(userAuthParam = authUpdate)) - }) { + || !handleUIA( + failure = failure, + interceptor = params.interactiveAuthInterceptor, + retryBlock = { authUpdate -> + uploadSigningKeysTask.execute(uploadSigningKeysParams.copy(userAuthParam = authUpdate)) + } + ) + ) { Timber.d("## UIA: propagate failure") - throw failure + throw failure } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/EventInsertLiveObserver.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/EventInsertLiveObserver.kt index 71f978c03c..88aa432fb3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/EventInsertLiveObserver.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/EventInsertLiveObserver.kt @@ -71,7 +71,6 @@ internal class EventInsertLiveObserver @Inject constructor(@SessionDatabase real return@forEach } val domainEvent = event.asDomain() -// decryptIfNeeded(domainEvent) processors.filter { it.shouldProcess(eventId, domainEvent.getClearType(), eventInsert.insertType) }.forEach { @@ -83,6 +82,7 @@ internal class EventInsertLiveObserver @Inject constructor(@SessionDatabase real .findAll() .deleteAllFromRealm() } + processors.forEach { it.onPostProcess() } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt index e09c051c81..890f3a6ac3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt @@ -49,6 +49,7 @@ import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageServi import org.matrix.android.sdk.api.session.signout.SignOutService import org.matrix.android.sdk.api.session.sync.FilterService import org.matrix.android.sdk.api.session.terms.TermsService +import org.matrix.android.sdk.api.session.thirdparty.ThirdPartyService import org.matrix.android.sdk.api.session.typing.TypingUsersTracker import org.matrix.android.sdk.api.session.user.UserService import org.matrix.android.sdk.api.session.widgets.WidgetService @@ -114,6 +115,7 @@ internal class DefaultSession @Inject constructor( private val accountService: Lazy, private val defaultIdentityService: DefaultIdentityService, private val integrationManagerService: IntegrationManagerService, + private val thirdPartyService: Lazy, private val callSignalingService: Lazy, @UnauthenticatedWithCertificate private val unauthenticatedWithCertificateOkHttpClient: Lazy, @@ -258,6 +260,8 @@ internal class DefaultSession @Inject constructor( override fun searchService(): SearchService = searchService.get() + override fun thirdPartyService(): ThirdPartyService = thirdPartyService.get() + override fun getOkHttpClient(): OkHttpClient { return unauthenticatedWithCertificateOkHttpClient.get() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/EventInsertLiveProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/EventInsertLiveProcessor.kt index 53b1a73544..7a687b774b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/EventInsertLiveProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/EventInsertLiveProcessor.kt @@ -25,4 +25,12 @@ internal interface EventInsertLiveProcessor { fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean suspend fun process(realm: Realm, event: Event) + + /** + * Called after transaction. + * Maybe you prefer to process the events outside of the realm transaction. + */ + suspend fun onPostProcess() { + // Noop by default + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionComponent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionComponent.kt index f5eade1704..9279c5c97a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionComponent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionComponent.kt @@ -56,6 +56,7 @@ import org.matrix.android.sdk.internal.session.sync.SyncTask import org.matrix.android.sdk.internal.session.sync.SyncTokenStore import org.matrix.android.sdk.internal.session.sync.job.SyncWorker import org.matrix.android.sdk.internal.session.terms.TermsModule +import org.matrix.android.sdk.internal.session.thirdparty.ThirdPartyModule import org.matrix.android.sdk.internal.session.user.UserModule import org.matrix.android.sdk.internal.session.user.accountdata.AccountDataModule import org.matrix.android.sdk.internal.session.widgets.WidgetModule @@ -87,7 +88,8 @@ import org.matrix.android.sdk.internal.util.MatrixCoroutineDispatchers ProfileModule::class, AccountModule::class, CallModule::class, - SearchModule::class + SearchModule::class, + ThirdPartyModule::class ] ) @SessionScope diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/DeactivateAccountTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/DeactivateAccountTask.kt index d67b21567e..ca6b0554a9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/DeactivateAccountTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/DeactivateAccountTask.kt @@ -16,10 +16,9 @@ package org.matrix.android.sdk.internal.session.account +import org.matrix.android.sdk.api.auth.UIABaseAuth import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor import org.matrix.android.sdk.internal.auth.registration.handleUIA -import org.matrix.android.sdk.api.auth.UIABaseAuth -import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.session.cleanup.CleanupSession @@ -30,8 +29,8 @@ import javax.inject.Inject internal interface DeactivateAccountTask : Task { data class Params( - val userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, val eraseAllData: Boolean, + val userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, val userAuthParam: UIABaseAuth? = null ) } @@ -39,7 +38,6 @@ internal interface DeactivateAccountTask : Task(globalErrorReceiver) { apiCall = accountAPI.deactivate(deactivateAccountParams) } + true } catch (throwable: Throwable) { - if (!handleUIA(throwable, params.userInteractiveAuthInterceptor) { auth -> - execute(params.copy(userAuthParam = auth)) - } + if (!handleUIA( + failure = throwable, + interceptor = params.userInteractiveAuthInterceptor, + retryBlock = { authUpdate -> + execute(params.copy(userAuthParam = authUpdate)) + } + ) ) { Timber.d("## UIA: propagate failure") - throw throwable + throw throwable + } else { + false } } - // Logout from identity server if any, ignoring errors - runCatching { identityDisconnectTask.execute(Unit) } - .onFailure { Timber.w(it, "Unable to disconnect identity server") } - cleanupSession.handle() + if (canCleanup) { + // Logout from identity server if any, ignoring errors + runCatching { identityDisconnectTask.execute(Unit) } + .onFailure { Timber.w(it, "Unable to disconnect identity server") } + + cleanupSession.handle() + } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/DefaultAccountService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/DefaultAccountService.kt index 25b67159a9..dc77d7bffb 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/DefaultAccountService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/account/DefaultAccountService.kt @@ -27,7 +27,7 @@ internal class DefaultAccountService @Inject constructor(private val changePassw changePasswordTask.execute(ChangePasswordTask.Params(password, newPassword)) } - override suspend fun deactivateAccount(userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, eraseAllData: Boolean) { - deactivateAccountTask.execute(DeactivateAccountTask.Params(userInteractiveAuthInterceptor, eraseAllData)) + override suspend fun deactivateAccount(eraseAllData: Boolean, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor) { + deactivateAccountTask.execute(DeactivateAccountTask.Params(eraseAllData, userInteractiveAuthInterceptor)) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt index f789a64500..4887351709 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt @@ -16,28 +16,30 @@ package org.matrix.android.sdk.internal.session.call +import io.realm.Realm import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.internal.database.model.EventInsertType -import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.session.EventInsertLiveProcessor -import io.realm.Realm import timber.log.Timber import javax.inject.Inject -internal class CallEventProcessor @Inject constructor( - @UserId private val userId: String, - private val callService: DefaultCallSignalingService -) : EventInsertLiveProcessor { +internal class CallEventProcessor @Inject constructor(private val callSignalingHandler: CallSignalingHandler) + : EventInsertLiveProcessor { private val allowedTypes = listOf( EventType.CALL_ANSWER, + EventType.CALL_SELECT_ANSWER, + EventType.CALL_REJECT, + EventType.CALL_NEGOTIATE, EventType.CALL_CANDIDATES, EventType.CALL_INVITE, EventType.CALL_HANGUP, EventType.ENCRYPTED ) + private val eventsToPostProcess = mutableListOf() + override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean { if (insertType != EventInsertType.INCREMENTAL_SYNC) { return false @@ -46,10 +48,17 @@ internal class CallEventProcessor @Inject constructor( } override suspend fun process(realm: Realm, event: Event) { - update(realm, event) + eventsToPostProcess.add(event) } - private fun update(realm: Realm, event: Event) { + override suspend fun onPostProcess() { + eventsToPostProcess.forEach { + dispatchToCallSignalingHandlerIfNeeded(it) + } + eventsToPostProcess.clear() + } + + private fun dispatchToCallSignalingHandlerIfNeeded(event: Event) { val now = System.currentTimeMillis() // TODO might check if an invite is not closed (hangup/answsered) in the same event batch? event.roomId ?: return Unit.also { @@ -60,10 +69,6 @@ internal class CallEventProcessor @Inject constructor( // To old to ring? return } - event.ageLocalTs - if (EventType.isCallEvent(event.getClearType())) { - callService.onCallEvent(event) - } - Timber.v("$realm : $userId") + callSignalingHandler.onCallEvent(event) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallListenersDispatcher.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallListenersDispatcher.kt new file mode 100644 index 0000000000..1de2d8a106 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallListenersDispatcher.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.call + +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.call.CallListener +import org.matrix.android.sdk.api.session.call.MxCall +import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent +import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent +import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent +import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent +import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent +import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent +import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent + +/** + * Dispatch each method safely to all listeners. + */ +internal class CallListenersDispatcher(private val listeners: Set) : CallListener { + + override fun onCallInviteReceived(mxCall: MxCall, callInviteContent: CallInviteContent) = dispatch { + it.onCallInviteReceived(mxCall, callInviteContent) + } + + override fun onCallIceCandidateReceived(mxCall: MxCall, iceCandidatesContent: CallCandidatesContent) = dispatch { + it.onCallIceCandidateReceived(mxCall, iceCandidatesContent) + } + + override fun onCallAnswerReceived(callAnswerContent: CallAnswerContent) = dispatch { + it.onCallAnswerReceived(callAnswerContent) + } + + override fun onCallHangupReceived(callHangupContent: CallHangupContent) = dispatch { + it.onCallHangupReceived(callHangupContent) + } + + override fun onCallRejectReceived(callRejectContent: CallRejectContent) = dispatch { + it.onCallRejectReceived(callRejectContent) + } + + override fun onCallManagedByOtherSession(callId: String) = dispatch { + it.onCallManagedByOtherSession(callId) + } + + override fun onCallSelectAnswerReceived(callSelectAnswerContent: CallSelectAnswerContent) = dispatch { + it.onCallSelectAnswerReceived(callSelectAnswerContent) + } + + override fun onCallNegotiateReceived(callNegotiateContent: CallNegotiateContent) = dispatch { + it.onCallNegotiateReceived(callNegotiateContent) + } + + private fun dispatch(lambda: (CallListener) -> Unit) { + listeners.toList().forEach { + tryOrNull { + lambda(it) + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt new file mode 100644 index 0000000000..7e54301f63 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.call + +import org.matrix.android.sdk.api.session.call.CallListener +import org.matrix.android.sdk.api.session.call.CallState +import org.matrix.android.sdk.api.session.call.MxCall +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent +import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent +import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities +import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent +import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent +import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent +import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent +import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent +import org.matrix.android.sdk.api.session.room.model.call.CallSignallingContent +import org.matrix.android.sdk.api.util.Optional +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.session.SessionScope +import timber.log.Timber +import java.math.BigDecimal +import javax.inject.Inject + +@SessionScope +internal class CallSignalingHandler @Inject constructor(private val activeCallHandler: ActiveCallHandler, + private val mxCallFactory: MxCallFactory, + @UserId private val userId: String) { + + private val callListeners = mutableSetOf() + private val callListenersDispatcher = CallListenersDispatcher(callListeners) + + fun addCallListener(listener: CallListener) { + callListeners.add(listener) + } + + fun removeCallListener(listener: CallListener) { + callListeners.remove(listener) + } + + fun onCallEvent(event: Event) { + when (event.getClearType()) { + EventType.CALL_ANSWER -> { + handleCallAnswerEvent(event) + } + EventType.CALL_INVITE -> { + handleCallInviteEvent(event) + } + EventType.CALL_HANGUP -> { + handleCallHangupEvent(event) + } + EventType.CALL_REJECT -> { + handleCallRejectEvent(event) + } + EventType.CALL_CANDIDATES -> { + handleCallCandidatesEvent(event) + } + EventType.CALL_SELECT_ANSWER -> { + handleCallSelectAnswerEvent(event) + } + EventType.CALL_NEGOTIATE -> { + handleCallNegotiateEvent(event) + } + } + } + + private fun handleCallNegotiateEvent(event: Event) { + val content = event.getClearContent().toModel() ?: return + val call = content.getCall() ?: return + if (call.ourPartyId == content.partyId) { + // Ignore remote echo + return + } + callListenersDispatcher.onCallNegotiateReceived(content) + } + + private fun handleCallSelectAnswerEvent(event: Event) { + val content = event.getClearContent().toModel() ?: return + val call = content.getCall() ?: return + if (call.ourPartyId == content.partyId) { + // Ignore remote echo + return + } + if (call.isOutgoing) { + Timber.v("Got selectAnswer for an outbound call: ignoring") + return + } + val selectedPartyId = content.selectedPartyId + if (selectedPartyId == null) { + Timber.w("Got nonsensical select_answer with null selected_party_id: ignoring") + return + } + callListenersDispatcher.onCallSelectAnswerReceived(content) + } + + private fun handleCallCandidatesEvent(event: Event) { + val content = event.getClearContent().toModel() ?: return + val call = content.getCall() ?: return + if (call.ourPartyId == content.partyId) { + // Ignore remote echo + return + } + if (call.opponentPartyId != null && !call.partyIdsMatches(content)) { + Timber.v("Ignoring candidates from party ID ${content.partyId} we have chosen party ID ${call.opponentPartyId}") + return + } + callListenersDispatcher.onCallIceCandidateReceived(call, content) + } + + private fun handleCallRejectEvent(event: Event) { + val content = event.getClearContent().toModel() ?: return + val call = content.getCall() ?: return + if (call.ourPartyId == content.partyId) { + // Ignore remote echo + return + } + activeCallHandler.removeCall(content.callId) + if (event.senderId == userId) { + // discard current call, it's rejected by another of my session + callListenersDispatcher.onCallManagedByOtherSession(content.callId) + return + } + // No need to check party_id for reject because if we'd received either + // an answer or reject, we wouldn't be in state InviteSent + if (call.state != CallState.Dialing) { + return + } + callListenersDispatcher.onCallRejectReceived(content) + } + + private fun handleCallHangupEvent(event: Event) { + val content = event.getClearContent().toModel() ?: return + val call = content.getCall() ?: return + // party ID must match (our chosen partner hanging up the call) or be undefined (we haven't chosen + // a partner yet but we're treating the hangup as a reject as per VoIP v0) + if (call.opponentPartyId != null && !call.partyIdsMatches(content)) { + Timber.v("Ignoring hangup from party ID ${content.partyId} we have chosen party ID ${call.opponentPartyId}") + return + } + if (call.state != CallState.Terminated) { + activeCallHandler.removeCall(content.callId) + callListenersDispatcher.onCallHangupReceived(content) + } + } + + private fun handleCallInviteEvent(event: Event) { + if (event.senderId == userId) { + // ignore invites you send + return + } + if (event.roomId == null || event.senderId == null) { + return + } + val content = event.getClearContent().toModel() ?: return + val incomingCall = mxCallFactory.createIncomingCall( + roomId = event.roomId, + opponentUserId = event.senderId, + content = content + ) ?: return + activeCallHandler.addCall(incomingCall) + callListenersDispatcher.onCallInviteReceived(incomingCall, content) + } + + private fun handleCallAnswerEvent(event: Event) { + val content = event.getClearContent().toModel() ?: return + val call = content.getCall() ?: return + if (call.ourPartyId == content.partyId) { + // Ignore remote echo + return + } + if (event.senderId == userId) { + // discard current call, it's answered by another of my session + activeCallHandler.removeCall(call.callId) + callListenersDispatcher.onCallManagedByOtherSession(content.callId) + } else { + if (call.opponentPartyId != null) { + Timber.v("Ignoring answer from party ID ${content.partyId} we already have an answer from ${call.opponentPartyId}") + return + } + call.apply { + opponentPartyId = Optional.from(content.partyId) + opponentVersion = content.version?.let { BigDecimal(it).intValueExact() } ?: MxCall.VOIP_PROTO_VERSION + capabilities = content.capabilities ?: CallCapabilities() + } + callListenersDispatcher.onCallAnswerReceived(content) + } + } + + private fun MxCall.partyIdsMatches(contentSignallingContent: CallSignallingContent): Boolean { + return opponentPartyId?.getOrNull() == contentSignallingContent.partyId + } + + private fun CallSignallingContent.getCall(): MxCall? { + val currentCall = callId?.let { + activeCallHandler.getCallWithId(it) + } + if (currentCall == null) { + Timber.v("Call with id $callId is null") + } + return currentCall + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/DefaultCallSignalingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/DefaultCallSignalingService.kt index 019da27d27..7d046cb642 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/DefaultCallSignalingService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/DefaultCallSignalingService.kt @@ -16,106 +16,44 @@ package org.matrix.android.sdk.internal.session.call -import android.os.SystemClock -import org.matrix.android.sdk.api.MatrixCallback -import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.call.CallListener import org.matrix.android.sdk.api.session.call.CallSignalingService -import org.matrix.android.sdk.api.session.call.CallState -import org.matrix.android.sdk.api.session.call.CallsListener import org.matrix.android.sdk.api.session.call.MxCall +import org.matrix.android.sdk.api.session.call.PSTNProtocolChecker import org.matrix.android.sdk.api.session.call.TurnServerResponse -import org.matrix.android.sdk.api.session.events.model.Event -import org.matrix.android.sdk.api.session.events.model.EventType -import org.matrix.android.sdk.api.session.events.model.toModel -import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent -import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent -import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent -import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent -import org.matrix.android.sdk.api.util.Cancelable -import org.matrix.android.sdk.api.util.NoOpCancellable -import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.session.SessionScope -import org.matrix.android.sdk.internal.session.call.model.MxCallImpl -import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor -import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory -import org.matrix.android.sdk.internal.task.TaskExecutor -import org.matrix.android.sdk.internal.task.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 callSignalingHandler: CallSignalingHandler, + private val mxCallFactory: MxCallFactory, private val activeCallHandler: ActiveCallHandler, - private val localEchoEventFactory: LocalEchoEventFactory, - private val eventSenderProcessor: EventSenderProcessor, - private val taskExecutor: TaskExecutor, - private val turnServerTask: GetTurnServerTask + private val turnServerDataSource: TurnServerDataSource, + private val pstnProtocolChecker: PSTNProtocolChecker ) : CallSignalingService { - private val callListeners = mutableSetOf() - - private val cachedTurnServerResponse = object { - // Keep one minute safe to avoid considering the data is valid and then actually it is not when effectively using it. - private val MIN_TTL = 60 - - private val now = { SystemClock.elapsedRealtime() / 1000 } - - private var expiresAt: Long = 0 - - var data: TurnServerResponse? = null - get() = if (expiresAt > now()) field else null - set(value) { - expiresAt = now() + (value?.ttl ?: 0) - MIN_TTL - field = value - } + override suspend fun getTurnServer(): TurnServerResponse { + return turnServerDataSource.getTurnServer() } - override fun getTurnServer(callback: MatrixCallback): Cancelable { - if (cachedTurnServerResponse.data != null) { - cachedTurnServerResponse.data?.let { callback.onSuccess(it) } - return NoOpCancellable - } - return turnServerTask - .configureWith(GetTurnServerTask.Params) { - this.callback = object : MatrixCallback { - override fun onSuccess(data: TurnServerResponse) { - cachedTurnServerResponse.data = data - callback.onSuccess(data) - } - - override fun onFailure(failure: Throwable) { - callback.onFailure(failure) - } - } - } - .executeBy(taskExecutor) + override fun getPSTNProtocolChecker(): PSTNProtocolChecker { + return pstnProtocolChecker } override fun createOutgoingCall(roomId: String, otherUserId: String, isVideoCall: Boolean): MxCall { - val call = MxCallImpl( - callId = UUID.randomUUID().toString(), - isOutgoing = true, - roomId = roomId, - userId = userId, - otherUserId = otherUserId, - isVideoCall = isVideoCall, - localEchoEventFactory = localEchoEventFactory, - eventSenderProcessor = eventSenderProcessor - ) - activeCallHandler.addCall(call).also { - return call + return mxCallFactory.createOutgoingCall(roomId, otherUserId, isVideoCall).also { + activeCallHandler.addCall(it) } } - override fun addCallListener(listener: CallsListener) { - callListeners.add(listener) + override fun addCallListener(listener: CallListener) { + callSignalingHandler.addCallListener(listener) } - override fun removeCallListener(listener: CallsListener) { - callListeners.remove(listener) + override fun removeCallListener(listener: CallListener) { + callSignalingHandler.removeCallListener(listener) } override fun getCallWithId(callId: String): MxCall? { @@ -127,129 +65,6 @@ internal class DefaultCallSignalingService @Inject constructor( return activeCallHandler.getActiveCallsLiveData().value?.isNotEmpty() == true } - 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, - eventSenderProcessor = eventSenderProcessor - ) - activeCallHandler.addCall(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 - } - - activeCallHandler.removeCall(content.callId) - onCallHangup(content) - } - } - EventType.CALL_CANDIDATES -> { - if (event.senderId == userId) { - // Always ignore local echos of invite - return - } - event.getClearContent().toModel()?.let { content -> - activeCallHandler.getCallWithId(content.callId)?.let { - onCallIceCandidate(it, content) - } - } - } - } - } - - private fun onCallHangup(hangup: CallHangupContent) { - callListeners.toList().forEach { - tryOrNull { - it.onCallHangupReceived(hangup) - } - } - } - - private fun onCallAnswer(answer: CallAnswerContent) { - callListeners.toList().forEach { - tryOrNull { - it.onCallAnswerReceived(answer) - } - } - } - - private fun onCallManageByOtherSession(callId: String) { - callListeners.toList().forEach { - tryOrNull { - it.onCallManagedByOtherSession(callId) - } - } - } - - private fun onCallInvite(incomingCall: MxCall, invite: CallInviteContent) { - // Ignore the invitation from current user - if (incomingCall.otherUserId == userId) return - - callListeners.toList().forEach { - tryOrNull { - it.onCallInviteReceived(incomingCall, invite) - } - } - } - - private fun onCallIceCandidate(incomingCall: MxCall, candidates: CallCandidatesContent) { - callListeners.toList().forEach { - tryOrNull { - it.onCallIceCandidateReceived(incomingCall, candidates) - } - } - } - companion object { const val CALL_TIMEOUT_MS = 120_000 } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/MxCallFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/MxCallFactory.kt new file mode 100644 index 0000000000..b14cdca63c --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/MxCallFactory.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.call + +import org.matrix.android.sdk.api.MatrixConfiguration +import org.matrix.android.sdk.api.session.call.MxCall +import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities +import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent +import org.matrix.android.sdk.api.util.Optional +import org.matrix.android.sdk.internal.di.DeviceId +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.session.call.model.MxCallImpl +import org.matrix.android.sdk.internal.session.profile.GetProfileInfoTask +import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory +import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor +import java.math.BigDecimal +import java.util.UUID +import javax.inject.Inject + +internal class MxCallFactory @Inject constructor( + @DeviceId private val deviceId: String?, + private val localEchoEventFactory: LocalEchoEventFactory, + private val eventSenderProcessor: EventSenderProcessor, + private val matrixConfiguration: MatrixConfiguration, + private val getProfileInfoTask: GetProfileInfoTask, + @UserId private val userId: String +) { + + fun createIncomingCall(roomId: String, opponentUserId: String, content: CallInviteContent): MxCall? { + content.callId ?: return null + return MxCallImpl( + callId = content.callId, + isOutgoing = false, + roomId = roomId, + userId = userId, + ourPartyId = deviceId ?: "", + opponentUserId = opponentUserId, + isVideoCall = content.isVideo(), + localEchoEventFactory = localEchoEventFactory, + eventSenderProcessor = eventSenderProcessor, + matrixConfiguration = matrixConfiguration, + getProfileInfoTask = getProfileInfoTask + ).apply { + opponentPartyId = Optional.from(content.partyId) + opponentVersion = content.version?.let { BigDecimal(it).intValueExact() } ?: MxCall.VOIP_PROTO_VERSION + capabilities = content.capabilities ?: CallCapabilities() + } + } + + fun createOutgoingCall(roomId: String, opponentUserId: String, isVideoCall: Boolean): MxCall { + return MxCallImpl( + callId = UUID.randomUUID().toString(), + isOutgoing = true, + roomId = roomId, + userId = userId, + ourPartyId = deviceId ?: "", + opponentUserId = opponentUserId, + isVideoCall = isVideoCall, + localEchoEventFactory = localEchoEventFactory, + eventSenderProcessor = eventSenderProcessor, + matrixConfiguration = matrixConfiguration, + getProfileInfoTask = getProfileInfoTask + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/TurnServerDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/TurnServerDataSource.kt new file mode 100644 index 0000000000..8e2ac5e17e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/TurnServerDataSource.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.call + +import android.os.SystemClock +import org.matrix.android.sdk.api.session.call.TurnServerResponse +import javax.inject.Inject + +internal class TurnServerDataSource @Inject constructor(private val turnServerTask: GetTurnServerTask) { + + private val cachedTurnServerResponse = object { + // Keep one minute safe to avoid considering the data is valid and then actually it is not when effectively using it. + private val MIN_TTL = 60 + + private val now = { SystemClock.elapsedRealtime() / 1000 } + + private var expiresAt: Long = 0 + + var data: TurnServerResponse? = null + get() = if (expiresAt > now()) field else null + set(value) { + expiresAt = now() + (value?.ttl ?: 0) - MIN_TTL + field = value + } + } + + suspend fun getTurnServer(): TurnServerResponse { + return cachedTurnServerResponse.data ?: turnServerTask.execute(GetTurnServerTask.Params).also { + cachedTurnServerResponse.data = it + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/model/MxCallImpl.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/model/MxCallImpl.kt index 6c0d437a60..88fba0ea85 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/model/MxCallImpl.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/model/MxCallImpl.kt @@ -16,6 +16,7 @@ package org.matrix.android.sdk.internal.session.call.model +import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.MxCall import org.matrix.android.sdk.api.session.events.model.Content @@ -24,28 +25,44 @@ import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.LocalEcho import org.matrix.android.sdk.api.session.events.model.UnsignedData import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.profile.ProfileService import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent +import org.matrix.android.sdk.api.session.room.model.call.CallCandidate import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent +import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent +import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent +import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent +import org.matrix.android.sdk.api.session.room.model.call.CallReplacesContent +import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent +import org.matrix.android.sdk.api.session.room.model.call.SdpType +import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.internal.session.call.DefaultCallSignalingService -import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor +import org.matrix.android.sdk.internal.session.profile.GetProfileInfoTask import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory -import org.webrtc.IceCandidate -import org.webrtc.SessionDescription +import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor import timber.log.Timber +import java.util.UUID 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 opponentUserId: String, override val isVideoCall: Boolean, + override val ourPartyId: String, private val localEchoEventFactory: LocalEchoEventFactory, - private val eventSenderProcessor: EventSenderProcessor + private val eventSenderProcessor: EventSenderProcessor, + private val matrixConfiguration: MatrixConfiguration, + private val getProfileInfoTask: GetProfileInfoTask ) : MxCall { + override var opponentPartyId: Optional? = null + override var opponentVersion: Int = MxCall.VOIP_PROTO_VERSION + override var capabilities: CallCapabilities? = null + override var state: CallState = CallState.Idle set(value) { field = value @@ -81,60 +98,135 @@ internal class MxCallImpl( } } - override fun offerSdp(sdp: SessionDescription) { + override fun offerSdp(sdpString: String) { if (!isOutgoing) return Timber.v("## VOIP offerSdp $callId") state = CallState.Dialing CallInviteContent( callId = callId, + partyId = ourPartyId, lifetime = DefaultCallSignalingService.CALL_TIMEOUT_MS, - offer = CallInviteContent.Offer(sdp = sdp.description) + offer = CallInviteContent.Offer(sdp = sdpString), + version = MxCall.VOIP_PROTO_VERSION.toString(), + capabilities = buildCapabilities() ) .let { createEventAndLocalEcho(type = EventType.CALL_INVITE, roomId = roomId, content = it.toContent()) } .also { eventSenderProcessor.postEvent(it) } } - override fun sendLocalIceCandidates(candidates: List) { + override fun sendLocalCallCandidates(candidates: List) { + Timber.v("Send local call canditates $callId: $candidates") CallCandidatesContent( callId = callId, - candidates = candidates.map { - CallCandidatesContent.Candidate( - sdpMid = it.sdpMid, - sdpMLineIndex = it.sdpMLineIndex, - candidate = it.sdp - ) - } + partyId = ourPartyId, + candidates = candidates, + version = MxCall.VOIP_PROTO_VERSION.toString() ) .let { createEventAndLocalEcho(type = EventType.CALL_CANDIDATES, roomId = roomId, content = it.toContent()) } .also { eventSenderProcessor.postEvent(it) } } - override fun sendLocalIceCandidateRemovals(candidates: List) { + override fun sendLocalIceCandidateRemovals(candidates: List) { // For now we don't support this flow } - override fun hangUp() { + override fun reject() { + if (opponentVersion < 1) { + Timber.v("Opponent version is less than 1 ($opponentVersion): sending hangup instead of reject") + hangUp() + return + } + Timber.v("## VOIP reject $callId") + CallRejectContent( + callId = callId, + partyId = ourPartyId, + version = MxCall.VOIP_PROTO_VERSION.toString() + ) + .let { createEventAndLocalEcho(type = EventType.CALL_REJECT, roomId = roomId, content = it.toContent()) } + .also { eventSenderProcessor.postEvent(it) } + state = CallState.Terminated + } + + override fun hangUp(reason: CallHangupContent.Reason?) { Timber.v("## VOIP hangup $callId") CallHangupContent( - callId = callId + callId = callId, + partyId = ourPartyId, + reason = reason ?: CallHangupContent.Reason.USER_HANGUP, + version = MxCall.VOIP_PROTO_VERSION.toString() ) .let { createEventAndLocalEcho(type = EventType.CALL_HANGUP, roomId = roomId, content = it.toContent()) } .also { eventSenderProcessor.postEvent(it) } state = CallState.Terminated } - override fun accept(sdp: SessionDescription) { + override fun accept(sdpString: String) { Timber.v("## VOIP accept $callId") if (isOutgoing) return state = CallState.Answering CallAnswerContent( callId = callId, - answer = CallAnswerContent.Answer(sdp = sdp.description) + partyId = ourPartyId, + answer = CallAnswerContent.Answer(sdp = sdpString), + version = MxCall.VOIP_PROTO_VERSION.toString(), + capabilities = buildCapabilities() ) .let { createEventAndLocalEcho(type = EventType.CALL_ANSWER, roomId = roomId, content = it.toContent()) } .also { eventSenderProcessor.postEvent(it) } } + override fun negotiate(sdpString: String, type: SdpType) { + Timber.v("## VOIP negotiate $callId") + CallNegotiateContent( + callId = callId, + partyId = ourPartyId, + lifetime = DefaultCallSignalingService.CALL_TIMEOUT_MS, + description = CallNegotiateContent.Description(sdp = sdpString, type = type), + version = MxCall.VOIP_PROTO_VERSION.toString() + ) + .let { createEventAndLocalEcho(type = EventType.CALL_NEGOTIATE, roomId = roomId, content = it.toContent()) } + .also { eventSenderProcessor.postEvent(it) } + } + + override fun selectAnswer() { + Timber.v("## VOIP select answer $callId") + if (isOutgoing) return + state = CallState.Answering + CallSelectAnswerContent( + callId = callId, + partyId = ourPartyId, + selectedPartyId = opponentPartyId?.getOrNull(), + version = MxCall.VOIP_PROTO_VERSION.toString() + ) + .let { createEventAndLocalEcho(type = EventType.CALL_SELECT_ANSWER, roomId = roomId, content = it.toContent()) } + .also { eventSenderProcessor.postEvent(it) } + } + + override suspend fun transfer(targetUserId: String, targetRoomId: String?) { + val profileInfoParams = GetProfileInfoTask.Params(targetUserId) + val profileInfo = try { + getProfileInfoTask.execute(profileInfoParams) + } catch (failure: Throwable) { + Timber.v("Fail fetching profile info of $targetUserId while transferring call") + null + } + CallReplacesContent( + callId = callId, + partyId = ourPartyId, + replacementId = UUID.randomUUID().toString(), + version = MxCall.VOIP_PROTO_VERSION.toString(), + targetUser = CallReplacesContent.TargetUser( + id = targetUserId, + displayName = profileInfo?.get(ProfileService.DISPLAY_NAME_KEY) as? String, + avatarUrl = profileInfo?.get(ProfileService.AVATAR_URL_KEY) as? String + ), + targerRoomId = targetRoomId, + createCall = UUID.randomUUID().toString() + ) + .let { createEventAndLocalEcho(type = EventType.CALL_REPLACES, roomId = roomId, content = it.toContent()) } + .also { eventSenderProcessor.postEvent(it) } + } + private fun createEventAndLocalEcho(localId: String = LocalEcho.createLocalEchoId(), type: String, roomId: String, content: Content): Event { return Event( roomId = roomId, @@ -147,4 +239,12 @@ internal class MxCallImpl( ) .also { localEchoEventFactory.createLocalEcho(it) } } + + private fun buildCapabilities(): CallCapabilities? { + return if (matrixConfiguration.supportsCallTransfer) { + CallCapabilities(true) + } else { + null + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/integrationmanager/IntegrationManager.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/integrationmanager/IntegrationManager.kt index ebd57ce657..19a87103f4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/integrationmanager/IntegrationManager.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/integrationmanager/IntegrationManager.kt @@ -37,7 +37,6 @@ import org.matrix.android.sdk.internal.session.user.accountdata.AccountDataDataS import org.matrix.android.sdk.internal.session.user.accountdata.UpdateUserAccountDataTask import org.matrix.android.sdk.internal.session.widgets.helper.WidgetFactory import org.matrix.android.sdk.internal.session.widgets.helper.extractWidgetSequence -import org.matrix.android.sdk.internal.task.TaskExecutor import timber.log.Timber import javax.inject.Inject @@ -55,7 +54,6 @@ import javax.inject.Inject */ @SessionScope internal class IntegrationManager @Inject constructor(matrixConfiguration: MatrixConfiguration, - private val taskExecutor: TaskExecutor, @SessionDatabase private val monarchy: Monarchy, private val updateUserAccountDataTask: UpdateUserAccountDataTask, private val accountDataDataSource: AccountDataDataSource, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/FinalizeAddingThreePidTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/FinalizeAddingThreePidTask.kt index 916a602936..c2a38af093 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/FinalizeAddingThreePidTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/profile/FinalizeAddingThreePidTask.kt @@ -26,7 +26,6 @@ import org.matrix.android.sdk.api.auth.UIABaseAuth import org.matrix.android.sdk.internal.database.model.PendingThreePidEntity import org.matrix.android.sdk.internal.database.model.PendingThreePidEntityFields import org.matrix.android.sdk.internal.di.SessionDatabase -import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.task.Task @@ -47,11 +46,12 @@ internal class DefaultFinalizeAddingThreePidTask @Inject constructor( private val profileAPI: ProfileAPI, @SessionDatabase private val monarchy: Monarchy, private val pendingThreePidMapper: PendingThreePidMapper, - @UserId private val userId: String, private val globalErrorReceiver: GlobalErrorReceiver) : FinalizeAddingThreePidTask() { override suspend fun execute(params: Params) { - if (params.userWantsToCancel.not()) { + val canCleanup = if (params.userWantsToCancel) { + true + } else { // Get the required pending data val pendingThreePids = monarchy.fetchAllMappedSync( { it.where(PendingThreePidEntity::class.java) }, @@ -69,21 +69,30 @@ internal class DefaultFinalizeAddingThreePidTask @Inject constructor( ) apiCall = profileAPI.finalizeAddThreePid(body) } + true } catch (throwable: Throwable) { if (params.userInteractiveAuthInterceptor == null - || !handleUIA(throwable, params.userInteractiveAuthInterceptor) { auth -> - execute(params.copy(userAuthParam = auth)) - } + || !handleUIA( + failure = throwable, + interceptor = params.userInteractiveAuthInterceptor, + retryBlock = { authUpdate -> + execute(params.copy(userAuthParam = authUpdate)) + } + ) ) { Timber.d("## UIA: propagate failure") - throw throwable.toRegistrationFlowResponse() + throw throwable.toRegistrationFlowResponse() ?.let { Failure.RegistrationFlowError(it) } ?: throwable + } else { + false } } } - cleanupDatabase(params) + if (canCleanup) { + cleanupDatabase(params) + } } private suspend fun cleanupDatabase(params: Params) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomDirectoryService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomDirectoryService.kt index 0d41c6f35e..f9047fdf3c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomDirectoryService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomDirectoryService.kt @@ -21,11 +21,9 @@ import org.matrix.android.sdk.api.session.room.RoomDirectoryService import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsResponse -import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask -import org.matrix.android.sdk.internal.session.room.directory.GetThirdPartyProtocolsTask import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.task.TaskExecutor import org.matrix.android.sdk.internal.task.configureWith @@ -33,7 +31,6 @@ import javax.inject.Inject internal class DefaultRoomDirectoryService @Inject constructor( private val getPublicRoomTask: GetPublicRoomTask, - private val getThirdPartyProtocolsTask: GetThirdPartyProtocolsTask, private val getRoomDirectoryVisibilityTask: GetRoomDirectoryVisibilityTask, private val setRoomDirectoryVisibilityTask: SetRoomDirectoryVisibilityTask, private val taskExecutor: TaskExecutor) : RoomDirectoryService { @@ -48,14 +45,6 @@ internal class DefaultRoomDirectoryService @Inject constructor( .executeBy(taskExecutor) } - override fun getThirdPartyProtocol(callback: MatrixCallback>): Cancelable { - return getThirdPartyProtocolsTask - .configureWith { - this.callback = callback - } - .executeBy(taskExecutor) - } - override suspend fun getRoomDirectoryVisibility(roomId: String): RoomDirectoryVisibility { return getRoomDirectoryVisibilityTask.execute(GetRoomDirectoryVisibilityTask.Params(roomId)) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt index aa92c1cb3b..20cb49ee8a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt @@ -20,7 +20,6 @@ import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsResponse -import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.internal.network.NetworkConstants import org.matrix.android.sdk.internal.session.room.alias.GetAliasesResponse @@ -50,14 +49,6 @@ import retrofit2.http.Query internal interface RoomAPI { - /** - * Get the third party server protocols. - * - * Ref: https://matrix.org/docs/spec/client_server/r0.4.0.html#get-matrix-client-r0-thirdparty-protocols - */ - @GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "thirdparty/protocols") - fun thirdPartyProtocols(): Call> - /** * Lists the public rooms on the server, with optional filter. * This API returns paginated responses. The rooms are ordered by the number of joined members, with the largest rooms first. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt index 92f4ea2aea..66b7272360 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt @@ -39,11 +39,9 @@ import org.matrix.android.sdk.internal.session.room.create.CreateRoomTask import org.matrix.android.sdk.internal.session.room.create.DefaultCreateRoomTask import org.matrix.android.sdk.internal.session.room.directory.DefaultGetPublicRoomTask import org.matrix.android.sdk.internal.session.room.directory.DefaultGetRoomDirectoryVisibilityTask -import org.matrix.android.sdk.internal.session.room.directory.DefaultGetThirdPartyProtocolsTask import org.matrix.android.sdk.internal.session.room.directory.DefaultSetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask -import org.matrix.android.sdk.internal.session.room.directory.GetThirdPartyProtocolsTask import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.membership.DefaultLoadRoomMembersTask import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask @@ -153,9 +151,6 @@ internal abstract class RoomModule { @Binds abstract fun bindSetRoomDirectoryVisibilityTask(task: DefaultSetRoomDirectoryVisibilityTask): SetRoomDirectoryVisibilityTask - @Binds - abstract fun bindGetThirdPartyProtocolsTask(task: DefaultGetThirdPartyProtocolsTask): GetThirdPartyProtocolsTask - @Binds abstract fun bindInviteTask(task: DefaultInviteTask): InviteTask diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessor.kt index 5014d94558..62338a1d07 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessor.kt @@ -196,6 +196,7 @@ internal class EventSenderProcessor @Inject constructor( else -> { Timber.v("## SendThread retryLoop Un-Retryable error, try next task") // this task is in error, check next one? + task.onTaskFailed() break@retryLoop } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/QueueMemento.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/QueueMemento.kt index dfbac347d9..a6836c8086 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/QueueMemento.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/QueueMemento.kt @@ -49,8 +49,10 @@ internal class QueueMemento @Inject constructor(context: Context, } fun unTrack(task: QueuedTask) { - managedTaskInfos.remove(task) - persist() + synchronized(managedTaskInfos) { + managedTaskInfos.remove(task) + persist() + } } private fun persist() { @@ -64,19 +66,17 @@ internal class QueueMemento @Inject constructor(context: Context, } private fun toTaskInfo(task: QueuedTask, order: Int): TaskInfo? { - synchronized(managedTaskInfos) { - return when (task) { - is SendEventQueuedTask -> SendEventTaskInfo( - localEchoId = task.event.eventId ?: "", - encrypt = task.encrypt, - order = order - ) - is RedactQueuedTask -> RedactEventTaskInfo( - redactionLocalEcho = task.redactionLocalEchoId, - order = order - ) - else -> null - } + return when (task) { + is SendEventQueuedTask -> SendEventTaskInfo( + localEchoId = task.event.eventId ?: "", + encrypt = task.encrypt, + order = order + ) + is RedactQueuedTask -> RedactEventTaskInfo( + redactionLocalEcho = task.redactionLocalEchoId, + order = order + ) + else -> null } } @@ -90,7 +90,7 @@ internal class QueueMemento @Inject constructor(context: Context, ?.forEach { info -> try { when (info) { - is SendEventTaskInfo -> { + is SendEventTaskInfo -> { localEchoRepository.getUpToDateEcho(info.localEchoId)?.let { if (it.sendState.isSending() && it.eventId != null && it.roomId != null) { localEchoRepository.updateSendState(it.eventId, it.roomId, SendState.UNSENT) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/StateEventDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/StateEventDataSource.kt index d0f6f8050e..a25a362bfa 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/StateEventDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/StateEventDataSource.kt @@ -80,7 +80,11 @@ internal class StateEventDataSource @Inject constructor(@SessionDatabase private ): RealmQuery { return realm.where() .equalTo(CurrentStateEventEntityFields.ROOM_ID, roomId) - .`in`(CurrentStateEventEntityFields.TYPE, eventTypes.toTypedArray()) + .apply { + if (eventTypes.isNotEmpty()) { + `in`(CurrentStateEventEntityFields.TYPE, eventTypes.toTypedArray()) + } + } .process(CurrentStateEventEntityFields.STATE_KEY, stateKey) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/DefaultThirdPartyService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/DefaultThirdPartyService.kt new file mode 100644 index 0000000000..13829c400a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/DefaultThirdPartyService.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.thirdparty + +import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol +import org.matrix.android.sdk.api.session.thirdparty.ThirdPartyService +import org.matrix.android.sdk.api.session.thirdparty.model.ThirdPartyUser +import javax.inject.Inject + +internal class DefaultThirdPartyService @Inject constructor(private val getThirdPartyProtocolTask: GetThirdPartyProtocolsTask, + private val getThirdPartyUserTask: GetThirdPartyUserTask) + : ThirdPartyService { + + override suspend fun getThirdPartyProtocols(): Map { + return getThirdPartyProtocolTask.execute(Unit) + } + + override suspend fun getThirdPartyUser(protocol: String, fields: Map): List { + val taskParams = GetThirdPartyUserTask.Params( + protocol = protocol, + fields = fields + ) + return getThirdPartyUserTask.execute(taskParams) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/directory/GetThirdPartyProtocolsTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/GetThirdPartyProtocolsTask.kt similarity index 86% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/directory/GetThirdPartyProtocolsTask.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/GetThirdPartyProtocolsTask.kt index 3477aa671e..fd1ed741e9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/directory/GetThirdPartyProtocolsTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/GetThirdPartyProtocolsTask.kt @@ -14,25 +14,24 @@ * limitations under the License. */ -package org.matrix.android.sdk.internal.session.room.directory +package org.matrix.android.sdk.internal.session.thirdparty import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest -import org.matrix.android.sdk.internal.session.room.RoomAPI import org.matrix.android.sdk.internal.task.Task import javax.inject.Inject internal interface GetThirdPartyProtocolsTask : Task> internal class DefaultGetThirdPartyProtocolsTask @Inject constructor( - private val roomAPI: RoomAPI, + private val thirdPartyAPI: ThirdPartyAPI, private val globalErrorReceiver: GlobalErrorReceiver ) : GetThirdPartyProtocolsTask { override suspend fun execute(params: Unit): Map { return executeRequest(globalErrorReceiver) { - apiCall = roomAPI.thirdPartyProtocols() + apiCall = thirdPartyAPI.thirdPartyProtocols() } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/GetThirdPartyUserTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/GetThirdPartyUserTask.kt new file mode 100644 index 0000000000..01a8b57678 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/GetThirdPartyUserTask.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.thirdparty + +import org.matrix.android.sdk.api.session.thirdparty.model.ThirdPartyUser +import org.matrix.android.sdk.internal.network.GlobalErrorReceiver +import org.matrix.android.sdk.internal.network.executeRequest +import org.matrix.android.sdk.internal.task.Task +import javax.inject.Inject + +internal interface GetThirdPartyUserTask : Task> { + + data class Params( + val protocol: String, + val fields: Map = emptyMap() + ) +} + +internal class DefaultGetThirdPartyUserTask @Inject constructor( + private val thirdPartyAPI: ThirdPartyAPI, + private val globalErrorReceiver: GlobalErrorReceiver +) : GetThirdPartyUserTask { + + override suspend fun execute(params: GetThirdPartyUserTask.Params): List { + return executeRequest(globalErrorReceiver) { + apiCall = thirdPartyAPI.getThirdPartyUser(params.protocol, params.fields) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/ThirdPartyAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/ThirdPartyAPI.kt new file mode 100644 index 0000000000..0c60a27341 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/ThirdPartyAPI.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.thirdparty + +import org.matrix.android.sdk.api.session.room.model.thirdparty.ThirdPartyProtocol +import org.matrix.android.sdk.api.session.thirdparty.model.ThirdPartyUser +import org.matrix.android.sdk.internal.network.NetworkConstants +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.QueryMap + +internal interface ThirdPartyAPI { + + /** + * Get the third party server protocols. + * + * Ref: https://matrix.org/docs/spec/client_server/r0.6.1.html#get-matrix-client-r0-thirdparty-protocols + */ + @GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "thirdparty/protocols") + fun thirdPartyProtocols(): Call> + + /** + * Retrieve a Matrix User ID linked to a user on the third party service, given a set of user parameters. + * + * Ref: https://matrix.org/docs/spec/client_server/r0.6.1#get-matrix-client-r0-thirdparty-user-protocol + */ + @GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "thirdparty/protocols/user/{protocol}") + fun getThirdPartyUser(@Path("protocol") protocol: String, @QueryMap params: Map?): Call> +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/ThirdPartyModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/ThirdPartyModule.kt new file mode 100644 index 0000000000..d3acd7a9f3 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/thirdparty/ThirdPartyModule.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.thirdparty + +import dagger.Binds +import dagger.Module +import dagger.Provides +import org.matrix.android.sdk.api.session.thirdparty.ThirdPartyService +import org.matrix.android.sdk.internal.session.SessionScope +import retrofit2.Retrofit + +@Module +internal abstract class ThirdPartyModule { + + @Module + companion object { + @Provides + @JvmStatic + @SessionScope + fun providesThirdPartyAPI(retrofit: Retrofit): ThirdPartyAPI { + return retrofit.create(ThirdPartyAPI::class.java) + } + } + + @Binds + abstract fun bindThirdPartyService(service: DefaultThirdPartyService): ThirdPartyService + + @Binds + abstract fun bindGetThirdPartyProtocolsTask(task: DefaultGetThirdPartyProtocolsTask): GetThirdPartyProtocolsTask + + @Binds + abstract fun bindGetThirdPartyUserTask(task: DefaultGetThirdPartyUserTask): GetThirdPartyUserTask +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/DefaultWidgetService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/DefaultWidgetService.kt index 3e4e430e3b..9f5a9360ee 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/DefaultWidgetService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/DefaultWidgetService.kt @@ -50,6 +50,10 @@ internal class DefaultWidgetService @Inject constructor(private val widgetManage return widgetManager.getRoomWidgets(roomId, widgetId, widgetTypes, excludedTypes) } + override fun getWidgetComputedUrl(widget: Widget, isLightTheme: Boolean): String? { + return widgetManager.getWidgetComputedUrl(widget, isLightTheme) + } + override fun getRoomWidgetsLive( roomId: String, widgetId: QueryStringValue, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/WidgetManager.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/WidgetManager.kt index 329903f15b..f841a2a245 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/WidgetManager.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/WidgetManager.kt @@ -104,6 +104,10 @@ internal class WidgetManager @Inject constructor(private val integrationManager: return widgetEvents.mapEventsToWidgets(widgetTypes, excludedTypes) } + fun getWidgetComputedUrl(widget: Widget, isLightTheme: Boolean): String? { + return widgetFactory.computeURL(widget, isLightTheme) + } + private fun List.mapEventsToWidgets(widgetTypes: Set? = null, excludedTypes: Set? = null): List { val widgetEvents = this diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/helper/WidgetFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/helper/WidgetFactory.kt index 702e424218..a469a9fe97 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/helper/WidgetFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/helper/WidgetFactory.kt @@ -16,6 +16,7 @@ package org.matrix.android.sdk.internal.session.widgets.helper +import org.matrix.android.sdk.api.session.content.ContentUrlResolver import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.sender.SenderInfo @@ -31,6 +32,7 @@ import javax.inject.Inject internal class WidgetFactory @Inject constructor(private val userDataSource: UserDataSource, private val realmSessionProvider: RealmSessionProvider, + private val urlResolver: ContentUrlResolver, @UserId private val userId: String) { fun create(widgetEvent: Event): Widget? { @@ -53,30 +55,29 @@ internal class WidgetFactory @Inject constructor(private val userDataSource: Use } } val isAddedByMe = widgetEvent.senderId == userId - val computedUrl = widgetContent.computeURL(widgetEvent.roomId, widgetId) return Widget( widgetContent = widgetContent, event = widgetEvent, widgetId = widgetId, senderInfo = senderInfo, isAddedByMe = isAddedByMe, - computedUrl = computedUrl, type = WidgetType.fromString(type) ) } // Ref: https://github.com/matrix-org/matrix-widget-api/blob/master/src/templating/url-template.ts#L29-L33 - private fun WidgetContent.computeURL(roomId: String?, widgetId: String): String? { - var computedUrl = url ?: return null + fun computeURL(widget: Widget, isLightTheme: Boolean): String? { + var computedUrl = widget.widgetContent.url ?: return null val myUser = userDataSource.getUser(userId) - val keyValue = data.mapKeys { "\$${it.key}" }.toMutableMap() + val keyValue = widget.widgetContent.data.mapKeys { "\$${it.key}" }.toMutableMap() keyValue[WIDGET_PATTERN_MATRIX_USER_ID] = userId keyValue[WIDGET_PATTERN_MATRIX_DISPLAY_NAME] = myUser?.getBestName() ?: userId - keyValue[WIDGET_PATTERN_MATRIX_AVATAR_URL] = myUser?.avatarUrl ?: "" - keyValue[WIDGET_PATTERN_MATRIX_WIDGET_ID] = widgetId - keyValue[WIDGET_PATTERN_MATRIX_ROOM_ID] = roomId ?: "" + keyValue[WIDGET_PATTERN_MATRIX_AVATAR_URL] = urlResolver.resolveFullSize(myUser?.avatarUrl) ?: "" + keyValue[WIDGET_PATTERN_MATRIX_WIDGET_ID] = widget.widgetId + keyValue[WIDGET_PATTERN_MATRIX_ROOM_ID] = widget.event.roomId ?: "" + keyValue[WIDGET_PATTERN_THEME] = getTheme(isLightTheme) for ((key, value) in keyValue) { computedUrl = computedUrl.replace(key, URLEncoder.encode(value.toString(), "utf-8")) @@ -84,6 +85,10 @@ internal class WidgetFactory @Inject constructor(private val userDataSource: Use return computedUrl } + private fun getTheme(isLightTheme: Boolean): String { + return if (isLightTheme) "light" else "dark" + } + companion object { // Value to be replaced in URLS const val WIDGET_PATTERN_MATRIX_USER_ID = "\$matrix_user_id" @@ -91,5 +96,6 @@ internal class WidgetFactory @Inject constructor(private val userDataSource: Use const val WIDGET_PATTERN_MATRIX_AVATAR_URL = "\$matrix_avatar_url" const val WIDGET_PATTERN_MATRIX_WIDGET_ID = "\$matrix_widget_id" const val WIDGET_PATTERN_MATRIX_ROOM_ID = "\$matrix_room_id" + const val WIDGET_PATTERN_THEME = "\$theme" } } diff --git a/matrix-sdk-android/src/main/res/values-ar/strings.xml b/matrix-sdk-android/src/main/res/values-ar/strings.xml index 0fc7bd1b49..0da4dc8f8e 100644 --- a/matrix-sdk-android/src/main/res/values-ar/strings.xml +++ b/matrix-sdk-android/src/main/res/values-ar/strings.xml @@ -1,147 +1,150 @@ - + - - أرسل ⁨%1$s⁩ صورة. - - دعوة من ⁨%s⁩ - دعى ⁨%1$s⁩ ⁨%2$s⁩ - دعاك ⁨%1$s⁩ - انضمّ ⁨%1$s⁩ إلى الغرفة - غادر ⁨%1$s⁩ الغرفة - رفض ⁨%1$s⁩ الدعوة - طرد ⁨%1$s⁩ ⁨%2$s⁩ - رفع ⁨%1$s⁩ المنع عن ⁨%2$s⁩ - منع ⁨%1$s⁩ ⁨%2$s⁩ - غيّر ⁨%1$s⁩ صورته - ضبط ⁨%1$s⁩ اسم العرض على ⁨%2$s⁩ - غيّر ⁨%1$s⁩ اسم العرض من ⁨%2$s⁩ إلى ⁨%3$s⁩ - أزال ⁨%1$s⁩ اسم العرض (⁨كان ⁨%2$s⁩) - غيّر ⁨%1$s⁩ الموضوع إلى: ⁨%2$s⁩ - غيّر ⁨%1$s⁩ اسم الغرفة إلى: ⁨%2$s⁩ - ردّ ⁨%s⁩ على المكالمة. - أنهى ⁨%s⁩ المكالمة. - جعل ⁨%1$s⁩ تأريخ الغرفة مستقبلًا ظاهرًا على ⁨%2$s⁩ - كل أعضاء الغرفة من لحظة دعوتهم. - كل أعضاء الغرفة من لحظة انضمامهم. - كل أعضاء الغرفة. - الكل. - المجهول (⁨%s⁩). - فعّل ⁨%1$s⁩ تعمية الطرفين (⁨%2$s⁩) - - طلب ⁨%1$s⁩ اجتماع VoIP - بدأ اجتماع VoIP - انتهى اجتماع VoIP - - أزال ⁨%1$s⁩ اسم الغرفة - أزال ⁨%1$s⁩ موضوع الغرفة + %1$s قد أرسل صورة. + دعوة من %s + %1$s قد دعى %2$s + %1$s قد دعاك أنت + %1$s قد إنضّم إلى الغرفة + %1$s قد غادر الغرفة + %1$s قد رفض الدعوة + %1$s قد طرد %2$s + %1$s قد رفع الحظر عن %2$s + %1$s قد حظر %2$s + %1$s قد غيّر صورته الشخصية + %1$s قد عيّن اسمه الظاهر إلى %2$s + %1$s قد غيّر اسمه الظاهر من %2$s إلى %3$s + %1$s قد أزال اسمه الظاهر (لقد كان %2$s) + %1$s قد غيّر الموضوع إلى: %2$s + %1$s قد غيّر اسم الغرفة إلى: %2$s + %s قد أجاب على المُكالمة. + %s قد أنهى المُكالمة. + %1$s قد جعل التأريخ المُستقبلي للغرفة مرئيًا لـ %2$s + جميع أعضاء الغرفة، من اللحظة التي تمت دعوتهم. + جميع أعضاء الغرفة، من لحظة انضمامهم. + جميع أعضاء الغرفة. + أيُّ شخص. + غير معروف (%s). + %1$s قد فعّل تعمية النهاية-إلى-النهاية (%2$s) + %1$s قد طلب اجتماع VoIP + اجتماع VoIP قد بدأ + اجتماع VoIP قد انتهى + %1$s قد أزال اسم الغرفة + %1$s قد أزال موضوع الغرفة حدّث ⁨%1$s⁩ اللاحة ⁨%2$s⁩ أرسل ⁨%1$s⁩ دعوة إلى ⁨%2$s⁩ للانضمام إلى الغرفة ** تعذّر فك التعمية: ⁨%s⁩ ** لم يُرسل جهاز المرسل مفاتيح هذه الرسالة. - تعذّر إرسال الرسالة - فشل رفع الصورة - خطأ في الشبكة خطأ في «ماترِكس» - لا يمكنك حاليًا الانضمام ثانيةً إلى غرفة فارغة. - رسالة معمّاة - عنوان البريد الإلكتروني رقم الهاتف - ‏‏⁨%1$s⁩: ‏⁨%2$s⁩ - انسحب ⁨%1$s⁩ من دعوة ⁨%2$s⁩ - أجرى ⁨%s⁩ مكالمة مرئية. - أجرى ⁨%s⁩ مكالمة صوتية. + %1$s قد سحب دعوة %2$s + %s قد أجرى مُكالمة مرئية. + %s قد أجرى مُكالمة صوتية. قَبِل ⁨%1$s⁩ دعوة ⁨%2$s⁩ - تعذر التهذيب - أرسل ⁨%1$s⁩ ملصقًا. - - (تغيّرت الصورة أيضا) - + %1$s قد أرسل مُلصقًا. + (تمَّ تغيير الصورة أيضًا) دعوة من ⁨%s⁩ غرفة فارغة - ‏⁨%1$s⁩ و ⁨%2$s⁩ دعوة إلى غرفة - - - - - - - + + + + + + - - أرسلت صورة. - أرسلت ملصقًا. - + أنت قد أرسلت صورة. + أنت قد أرسلت مُلصقًا. دعوة منك أنت - أنشأ ⁨%1$s⁩ الغرفة - أنشأت الغرفة - دعوت ⁨%1$s⁩ - انضممت إلى الغرفة - غادرت الغرفة - رفضت الدعوة - طردت ⁨%1$s⁩ - رفعت المنع عن ⁨%1$s⁩ - منعت ⁨%1$s⁩ - انسحبت من دعوة ⁨%1$s⁩ - غيّرت صورتك - ضبطت اسم العرض على ⁨%1$s⁩ - غيّرت اسم العرض من ⁨%1$s⁩ إلى ⁨%2$s⁩ - أزلت اسم العرض (كان ⁨%1$s⁩) - غيّرت الموضوع إلى: ⁨%1$s⁩ - غيّر ⁨%1$s⁩ صورة الغرفة - غيّرت صورة الغرفة - غيّرت اسم الغرفة إلى: ⁨%1$s⁩ - أجريت مكالمة مرئية. - أجريت مكالمة صوتية. - أرسل ⁨%s⁩ البيانات لإعداد المكالمة. - أرسلت البيانات لإعداد المكالمة. - رددت على المكالمة. - أنهيت المكالمة. - جعلت تأريخ الغرفة مستقبلًا ظاهرًا على ⁨%1$s⁩ - فعّلت تعمية الطرفين (⁨%1$s⁩) - رقّى ⁨%s⁩ هذه الغرفة. - رقّيت هذه الغرفة. - - طلبت اجتماع VoIP - أزلت اسم الغرفة - أزلت موضوع الغرفة - أزال ⁨%1$s⁩ صورة الغرفة - أزلت صورة الغرفة - أُزيلت الرسالة - أزال ⁨%1$s⁩ الرسالة + %1$s قد أنشأ الغرفة + أنت قد أنشأت الغرفة + أنت قد دعوت %1$s + أنت قد انضممت إلى الغرفة + أنت قد غادرت الغرفة + أنت قد رفضت الدعوة + أنت قد طردت %1$s + أنت قد رفعت الحظر عن %1$s + أنت قد حظرت %1$s + أنت قد سحبت دعوة %1$s + أنت قد غيّرت صورتك الشخصية + أنت قد عيّنت اسمك الظاهر إلى %1$s + أنت قد غيّرت اسمك الظاهر من ⁨%1$s⁩ إلى ⁨%2$s⁩ + أنت قد أزلت اسمك الظاهر (لقد كان ⁨%1$s⁩) + أنت قد غيّرت الموضوع إلى: ⁨%1$s⁩ + %1$s قد غيّر صورة الغرفة + أنت قد غيّرت صورة الغرفة + أنت قد غيّرت اسم الغرفة إلى: %1$s + أنت قد أجريت مُكالمة مرئية. + أنت قد أجريت مُكالمة صوتية. + %s قد أرسل بيانات لإعداد مُكالمة. + أنت قد أرسلت بيانات لإعداد مُكالمة. + أنت قد أجبت على المُكالمة. + أنت قد أنهيت المُكالمة. + أنت قد جعلت التأريخ المُستقبلي للغرفة مرئيًا لـ %1$s + أنت قد فعّلت تعيمية النهاية-إلى-النهاية (%1$s) + %s قد قام بترقية هذه الغرفة. + أنت قد رقّيتَ هذه الغرفة. + أنت قد طلبت اجتماع VoIP + أنت قد أزلت اسم الغرفة + أنت قد أزلت موضوع الغرفة + %1$s قد أزال صورة الغرفة + أنت قد أزلت صورة الغرفة + تمت إزالة الرسالة + الرسالة قد أُزيلت بواسطة %1$s أُزيلت الرسالة [السبب: ⁨%1$s⁩] أزال ⁨%1$s⁩ الرسالة [السبب: ⁨%2$s⁩] أرسلت دعوة إلى ⁨%1$s⁩ للانضمام إلى الغرفة سحب ⁨%1$s⁩ دعوة ⁨%2$s⁩ للانضمام إلى الغرفة سحبت دعوة ⁨%1$s⁩ للانضمام إلى الغرفة قَبِلت دعوة ⁨%1$s⁩ - أضاف ⁨%1$s⁩ الودجة ⁨%2$s⁩ أضفت الودجة ⁨%1$s⁩ أزال ⁨%1$s⁩ الودجة ⁨%2$s⁩ أزلت الودجة ⁨%1$s⁩ عدّل ⁨%1$s⁩ الودجة ⁨%2$s⁩ عدّلت الودجة ⁨%1$s⁩ - مدير المبدئي مخصّص (⁨%1$d⁩) مخصّص - غيّرت مستوى قوّة %1$s⁩. غيّر ⁨%1$s⁩ مستوى قوّة %2$s⁩. ‏⁨%1$s⁩ من ⁨%2$s⁩ إلى ⁨%3$s⁩ - المزامنة الأولية: \nيستورد الحساب… - + 🎉 جميع الخوادم محظورة من المُشاركة! لم يعُد من الممكن استخدام هذه الغرفة. + لا تغيير. + • خوادم مُطابقة IP الحرفية محظورة الآن. + • الخادم المُطابق لـ %s قد أُزيل من قائمة السماح. + • الخادم المُطابق لـ %s مسموح الآن. + • الخادم المُطابق لـ %s قد أُزيل من قائمة الحظر. + • الخادم المُطابق لـ %s محظور الآن. + • خوادم مُطابقة IP الحرفية مسموحة الآن. + أنت قد غيّرت خادم الـACLs لهذه الغرفة. + %s قد غيّر خادم الـACLs لهذه الغرفة. + • الخادم يحظر مُطابقة القيم الحرفية للـIP. + • الخادم المُطابق لـ %s مسموح. + • الخادم المُطابق لـ %s محظور. + • الخادم يسمح بمُطابقة القيم الحرفية للـIP. + أنت قد عيّنت خادم الـACLs لهذه الغرفة. + %s قد عيّن خادم الـACLs لهذه الغرفة. + أنت قد قمت بالترقية هُنا. + %s قد قام بالترقية هُنا. + أنت قد جعلت الرسائل المُستقبلية مرئية لـ %1$s + %1$s قد جعل الرسائل المُستقبلية مرئية لـ %2$s + أنت قد غادرت الغرفة + %1$s قد غادر الغرفة + أنت قد انضممت + %1$s قد انضم + أنت قد أنشأت المُناقشة + %1$s قد أنشأ المُناقشة + \ No newline at end of file diff --git a/matrix-sdk-android/src/main/res/values-ca/strings.xml b/matrix-sdk-android/src/main/res/values-ca/strings.xml index 728c4f6f60..98bda2c4ed 100644 --- a/matrix-sdk-android/src/main/res/values-ca/strings.xml +++ b/matrix-sdk-android/src/main/res/values-ca/strings.xml @@ -259,4 +259,10 @@ %s ha canviat les ACLs de servidor d\'aquesta sala. Has establert les ACLs de servidor per aquesta sala. %s ha establert les ACLs de servidor d\'aquesta sala. + Has modificat la videoconferència + %1$s ha modificat la videoconferència + Has finalitzat la videoconferència + %1$s ha iniciat una videoconferència + Has iniciat una videoconferència + %1$s ha finalitzat la videoconferència \ No newline at end of file diff --git a/matrix-sdk-android/src/main/res/values-de/strings.xml b/matrix-sdk-android/src/main/res/values-de/strings.xml index dfe29100c4..81cf01983d 100644 --- a/matrix-sdk-android/src/main/res/values-de/strings.xml +++ b/matrix-sdk-android/src/main/res/values-de/strings.xml @@ -266,4 +266,10 @@ • Server, die mit %s übereinstimmen, sind gesperrt. Du hast die Server-ACL für diesen Raum gesetzt. %s hat die Server-Zugriffssteuerungsliste (ACL) für diesen Raum gesetzt. + Du hast eine Videokonferenz geändert + Videokonferenz von %1$s geändert + Videokonferenz von %1$s beendet + Du hast eine Videokonferenz beendet + Du hast eine Videokonferenz gestartet + Videokonferenz von %1$s gestartet \ No newline at end of file diff --git a/matrix-sdk-android/src/main/res/values-et/strings.xml b/matrix-sdk-android/src/main/res/values-et/strings.xml index af2cc33b99..0cf5c3e438 100644 --- a/matrix-sdk-android/src/main/res/values-et/strings.xml +++ b/matrix-sdk-android/src/main/res/values-et/strings.xml @@ -258,4 +258,10 @@ %1$s lisas sellele jututoale täiendavad aadressid %2$s. Sa muutsid selle jututoa aadresse. + Sina muutsid videokoosolekut + Sina lõpetasid videokoosoleku + %1$s lõpetas videokoosoleku + Sina algatasid videokoosoleku + %1$s algatas videokoosoleku + %1$s muutis videokoosolekut \ No newline at end of file diff --git a/matrix-sdk-android/src/main/res/values-gl/strings.xml b/matrix-sdk-android/src/main/res/values-gl/strings.xml index 21eb8cb70c..6ae9b33b66 100644 --- a/matrix-sdk-android/src/main/res/values-gl/strings.xml +++ b/matrix-sdk-android/src/main/res/values-gl/strings.xml @@ -80,4 +80,22 @@ O teu convite Enviaches un adhesivo. Enviaches unha imaxe. + • Servidores con literais IP están vetados. + • Servidores con IP literais están permitidos. + • Servidores con %s están vetados. + • Servidores con %s están permitidos. + Estableceches os ACLs do servidor para esta sala. + %s estableceu os ACLs do servidor para esta sala. + %s actualizou aquí. + Actualizaches aquí. + Actualizaches esta sala. + %s actualizou esta sala. + Activaches o cifrado extremo-a-extremo (%1$s) + Fixeches visibles as mensaxes futuras para %1$s + %1$s fixo visibles as mensaxes futuras para %2$s + Fixeches visible no futuro o historial da sala para %1$s + Remataches a chamada. + Respondeches á chamada. + Enviaches datos para configurar a chamada. + %s enviou datos para configurar a chamada. \ No newline at end of file diff --git a/matrix-sdk-android/src/main/res/values-it/strings.xml b/matrix-sdk-android/src/main/res/values-it/strings.xml index ec19cd5c17..e1f57ddac0 100644 --- a/matrix-sdk-android/src/main/res/values-it/strings.xml +++ b/matrix-sdk-android/src/main/res/values-it/strings.xml @@ -259,4 +259,10 @@ %1$s ha aggiunto l\'indirizzo alternativo %2$s per questa stanza. %1$s ha aggiunto gli indirizzi alternativi %2$s per questa stanza. + Hai modificato la video conferenza + Video conferenza modificata da %1$s + Hai iniziato la video conferenza + Hai terminato la video conferenza + Video conferenza terminata da %1$s + Video conferenza iniziata da %1$s \ No newline at end of file diff --git a/matrix-sdk-android/src/main/res/values-lv/strings.xml b/matrix-sdk-android/src/main/res/values-lv/strings.xml index ec107b47d6..815760f394 100644 --- a/matrix-sdk-android/src/main/res/values-lv/strings.xml +++ b/matrix-sdk-android/src/main/res/values-lv/strings.xml @@ -2,46 +2,46 @@ %1$s: %2$s %1$s nosūtīja attēlu. - %s\'s uzaicinājums + Uzaicinājums no %s %1$s uzaicināja %2$s - %1$s uzaicināja tevi + %1$s uzaicināja jūs %1$s pievienojās - %1$s atstāja + %1$s pameta istabu %1$s noraidīja uzaicinājumu - %1$s \"izspēra\" ārā %2$s - %1$s atbanoja (atcēla pieejas liegumu) %2$s - %1$s liedza pieeju (banoja) %2$s + %1$s padzina %2$s + %1$s atcēla pieejas liegumu %2$s + %1$s liedza pieeju %2$s %1$s atsauca %2$s uzaicinājumu - %1$s nomainīja profila attēlu - %1$s uzstādīja redzamo vārdu uz %2$s - %1$s nomainīja redzamo vārdu no %2$s uz %3$s - %1$s dzēsa savu redzamo vārdu (%2$s) - %1$s nomainīja tēmas nosaukumu uz: %2$s - %1$s nomainīja istabas nosaukumu uz: %2$s + %1$s nomainīja avataru + %1$s uzstādīja parādāmo vārdu uz %2$s + %1$s nomainīja parādāmo vārdu no %2$s uz %3$s + %1$s dzēsa savu parādāmo vārdu (iepriekš %2$s) + %1$s nomainīja tematu uz %2$s + %1$s nomainīja istabas nosaukumu uz %2$s %s veica video zvanu. %s veica audio zvanu. - %s atbildēja zvanam. + %s atbildēja uz zvanu. %s beidza zvanu. - %1$s padarīja istabas nākamo ziņu vēsturi redzamu %2$s + %1$s padarīja istabas turpmāko ziņu vēsturi redzamu %2$s visi istabas biedri no brīža, kad tika uzaicināti. visi istabas biedri no brīža, kad tika pievienojušies. visi istabas biedri. ikviens. nezināms (%s). - %1$s ieslēdza ierīce-ierīce šifrēšanu (%2$s) - %1$s vēlas VoIP konferenci - VoIP konference sākusies - VoIP konference ir beigusies - (arī profila attēls mainījās) + %1$s ieslēdza pilnīgu šifrēšanu (%2$s) + %1$s pieprasīja VoIP konferenci + VoIP konference sākās + VoIP konference beidzās + (arī avatars tika nomainīts) %1$s dzēsa istabas nosaukumu - %1$s dzēsa istabas tēmas nosaukumu - %1$s atjaunoja profila informāciju %2$s - %1$s nosūtīja uzaicinājumu %2$s pievienoties istabai - %1$s apstiprināja uzaicinājumu priekš %2$s - ** Nav iespējams atkodēt: %s ** - Sūtītāja ierīce mums nenosūtīja atslēgas priekš šīs ziņas. + %1$s izdzēsa istabas tematu + %1$s atjaunoja savu profilu %2$s + %1$s nosūtīja %2$s uzaicinājumu pievienoties istabai + %1$s pieņēma uzaicinājumu %2$s + ** Neizdodas atšifrēt: %s ** + Sūtītāja ierīce mums nav nenosūtījusi atslēgas priekš šīs ziņas. Nevarēja rediģēt - Nav iespējams nosūtīt ziņu + Neizdodas nosūtīt ziņu Neizdevās augšuplādēt attēlu Tīkla kļūda Matrix kļūda @@ -58,27 +58,188 @@ %1$s un %2$d citi %1$s un %2$d citu - Tu nomainīji savu attēlojamo vārdu no %1$s uz %2$s - Tu nomainījis savu attēlojamo vārdu uz %1$s - Tu nomainīji savu avataru - Tu atsauci %1$s uzaicinājumu - Tu nobanoji %1$s - Tu atbanoji %1$s - Tu izspēri %1$s - Tu noraidīji uzaicinājumu - Tu pameti telpu - %1$s atstāja telpu - Tu atstāji telpu - Tu pievienojies - %1$s pievienojās telpai - Tu pievienojies telpai - Tu uzaicināji %1$s - Tu izveidoji apspriedi (diskusiju) - %1$s izveidoja apspriedi (diskusiju) - Tu izveidoji istabu - %1$s izveidoja telpu - Tavs uzaicinājums - Tu nosūtīji uzlīmi/lipekli. - %1$s nosūtīja uzlīmi/lipekli. - Tu nosūtīji attēlu. + Jūs nomainījāt savu parādāmo vārdu no %1$s uz %2$s + Jūs nomainījāt savu parādāmo vārdu uz %1$s + Jūs nomainījāt savu avataru + Jūs atsaucāt %1$s uzaicinājumu + Jūs liedzāt pieeju %1$s + Jūs atcēlāt pieejas liegumu %1$s + Jūs padzināt %1$s + Jūs noraidījāt uzaicinājumu + Jūs pametāt istabu + %1$s pameta istabu + Jūs pametāt istabu + Jūs pievienojāties + %1$s pievienojās istabai + Jūs pievienojāties istabai + Jūs uzaicinājāt %1$s + Jūs izveidojāt diskusiju + %1$s izveidoja diskusiju + Jūs izveidojāt istabu + %1$s izveidoja istabu + Jūsu uzaicinājums + Jūs nosūtījāt uzlīmi. + %1$s nosūtīja uzlīmi. + Jūs nosūtījāt attēlu. + %s pieprasa verificēt jūsu atslēgu, taču jūsu klients neatbalsta tērzēšanas atslēgas verifikāciju. Lai verificētu atslēgas, jums būs jāizmanto atslēgu verifikācija novecojušā veidā. + Jūs ieslēdzāt pilnīgu šifrēšanu (neatpazīts algoritms %1$s). + %1$s ieslēdza pilnīgu šifrēšanu (neatpazīts algoritms %2$s). + Jūs ieslēdzāt pilnīgu šifrēšanu. + %1$s ieslēdza pilnīgu šifrēšanu. + Jūs esat novērsis iespēju viesiem pievienoties istabai. + %1$s ir novērsis iespēju viesiem pievienoties istabai. + Jūs esat novērsis iespēju viesiem pievienoties istabai. + %1$s ir novērsis iespēju viesiem pievienoties istabai. + Jūs esat atļāvis viesiem pievienoties istabai. + %1$s ir atļāvis viesiem pievienoties istabai. + Jūs esat atļāvis viesiem pievienoties istabai. + %1$s ir atļāvis viesiem pievienoties istabai. + Jūs nomainījāt adreses šai istabai. + %1$s nomainīja adreses šai istabai. + Jūs nomainījāt galveno un alternatīvās adreses šai istabai. + %1$s nomainīja galveno un alternatīvās adreses šai istabai. + Jūs nomainījāt alternatīvās adreses šai istabai. + %1$s nomainīja alternatīvās adreses šai istabai. + + Jūs izdzēsāt šīs istabas alternatīvo adresi %1$s. + Jūs izdzēsāt šīs istabas alternatīvās adreses %1$s. + Jūs izdzēsāt šīs istabas alternatīvās adreses %1$s. + + + %1$s izdzēsa šīs istabas alternatīvo adresi %2$s. + %1$s izdzēsa šīs istabas alternatīvās adreses %2$s. + %1$s izdzēsa šīs istabas alternatīvās adreses %2$s. + + + Jūs pievienojāt šīs istabas alternatīvo adresi %1$s. + Jūs pievienojāt šīs istabas alternatīvās adreses %1$s. + Jūs pievienojāt šīs istabas alternatīvās adreses %1$s. + + + %1$s pievienoja šīs istabas alternatīvo adresi %2$s. + %1$s pievienoja šīs istabas alternatīvās adreses %2$s. + %1$s pievienoja šīs istabas alternatīvās adreses %2$s. + + Jūs izdzēsāt šis istabas galveno adresi. + %1$s izdzēsa šis istabas galveno adresi. + Jūs iestatījāt %1$s kā šis istabas galveno adresi. + %1$s iestatīja %2$s kā šis istabas galveno adresi. + Jūs pievienojāt %1$s un izdzēsāt %2$s kā šīs istabas adreses. + %1$s pievienoja %2$s un izdzēsa %3$s kā šīs istabas adreses. + + Jūs izdzēsāt %1$s kā šīs istabas adresi. + Jūs izdzēsāt %1$s kā šīs istabas adreses. + Jūs izdzēsāt %1$s kā šīs istabas adreses. + + + %1$s izdzēsa %2$s kā šīs istabas adresi. + %1$s izdzēsa %2$s kā šīs istabas adreses. + %1$s izdzēsa %2$s kā šīs istabas adreses. + + + Jūs pievienojāt %1$s kā šīs istabas adresi. + Jūs pievienojāt %1$s kā šīs istabas adreses. + Jūs pievienojāt %1$s kā šīs istabas adreses. + + + %1$s pievienoja %2$s kā šīs istabas adresi. + %1$s pievienoja %2$s kā šis istabas adreses. + %1$s pievienoja %2$s kā šīs istabas adreses. + + Jūs atsaucāt %1$s uzaicinājumu. Iemesls: %2$s + %1$s atsauca uzaicinājumu %2$s. Iemesls: %3$s + Jūs pieņēmāt uzaicinājumu %1$s. Iemesls: %2$s + %1$s pieņēma uzaicinājumu %2$s. Iemesls: %3$s + Jūs atsaucāt uzaicinājumu %1$s pievienoties istabai. Iemesls: %2$s + %1$s atsauca uzaicinājumu %2$s pievienoties istabai. Iemesls: %3$s + Jūs nosūtījāt uzaicinājumu %1$s pievienoties istabai. Iemesls: %2$s + %1$s nosūtīja uzaicinājumu %2$s pievienoties istabai. Iemesls: %3$s + Jūs liedzāt pieeju %1$s. Iemesls: %2$s + %1$s liedza pieeju %2$s. Iemesls: %3$s + Jūs atcēlāt pieejas liegumu %1$s. Iemesls: %2$s + %1$s atcēla %2$s pieejas liegumu. Iemesls: %3$s + Jūs padzināt %1$s. Iemesls: %2$s + %1$s padzina %2$s. Iemesls: %3$s + Jūs noraidījāt uzaicinājumu. Iemesls: %1$s + %1$s noraidīja uzaicinājumu. Iemesls: %2$s + Jūs izgājāt. Iemesls: %1$s + %1$s izgāja. Iemels: %2$s + Jūs pametāt istabu. Iemesls: %1$s + %1$s pameta istabu. Iemesls: %2$s + Jūs pievienojāties. Iemesls: %1$s + %1$s pievienojās. Iemesls: %2$s + Jūs pievienojāties istabai. Iemesls: %1$s + %1$s pievienojās istabai. Iemesls: %2$s + %1$s uzaicināja jūs. Iemesls: %2$s + Jūs uzaicinājāt %1$s. Iemesls: %2$s + %1$s uzaicināja %2$s. Iemesls: %3$s + Jūsu uzaicinājums. Iemesls: %1$s + %1$s uzaicinājums. Iemesls: %2$s + Notīrīt sūtīšanas rindu + Sūta ziņu… + Sākotnējā sinhronizācija: +\nImportē konta datus + Sākotnējā sinhronizācija: +\nImportē kopienas + Sākotnējā sinhronizācija: +\nImportē pamestās istabas + Sākotnējā sinhronizācija: +\nImportē istabas, uz kurām uzaicināts + Sākotnējā sinhronizācija: +\nImportē istabas, kurās ieiets + Sākotnējā sinhronizācija: +\nImportē istabas + Sākotnējā sinhronizācija: +\nImportē kriptogrāfiju + Sākotnējā sinhronizācija: +\nImportē kontu… + Tukša istaba (bija %s) + + %1$s, %2$s, %3$s un %4$d citi + %1$s, %2$s, %3$s un %4$d cits + %1$s, %2$s, %3$s un %4$d citi + + %1$s, %2$s, %3$s un %4$s + %1$s, %2$s un %3$s + %1$s no %2$s uz %3$s + %1$s nomainīja %2$s pieejas līmeni. + Jūs nomainījāt %1$s pieejas līmeni. + Pielāgots + Pielāgots (%1$d) + Noklusējuma + Moderators + Administrators + Jūs pieņēmāt uzaicinājumu %1$s + Jūs atsaucāt uzaicinājumu %1$s + %1$s atsauca uzaicinājumu %2$s + Jūs atsaucāt uzaicinājumu %1$s pievienoties istabai + %1$s atsauca uzaicinājumu %2$s pievienoties istabai + Jūs uzaicinājāt %1$s + %1$s uzaicināja %2$s + Jūs nosūtījāt %1$s uzaicinājumu pievienoties istabai + Jūs atjaunojāt savu profilu %1$s + %1$s izdzēsa ziņu [iemesls: %2$s] + Ziņa izdzēsta [iemesls: %1$s] + %1$s izdzēsa ziņu + Ziņa izdzēsta + Jūs izdzēsāt istabas avataru + %1$s izdzēsa istabas avataru + Jūs izdzēsāt istabas tematu + Jūs dzēsāt istabas nosaukumu + Jūs pieprasījāt VoIP konferenci + Jūs ieslēdzāt pilnīgu šifrēšanu (%1$s) + Jūs padarījāt turpmākās ziņas redzamas %1$s + %1$s padarīja turpmākās ziņas redzamas %2$s + Jūs padarījāt istabas turpmāko ziņu vēsturi redzamu %1$s + Jūs beidzāt zvanu. + Jūs atbildējāt uz zvanu. + Jūs nosūtījāt datus zvana uzsākšanai. + %s nosūtīja datus zvana uzsākšanai. + Jūs veicāt balss zvanu. + Jūs veicāt video zvanu. + Jūs nomainījāt istabas nosaukumu uz %1$s + Jūs nomainījāt istabas avataru + %1$s nomainīja istabas avataru + Jūs nomainījāt tematu uz %1$s + Jūs dzēsāt savu parādāmo vārdu (iepriekš %1$s) \ No newline at end of file diff --git a/matrix-sdk-android/src/main/res/values-sr/strings.xml b/matrix-sdk-android/src/main/res/values-sr/strings.xml index 5e0a72a4ea..c55c92d58a 100644 --- a/matrix-sdk-android/src/main/res/values-sr/strings.xml +++ b/matrix-sdk-android/src/main/res/values-sr/strings.xml @@ -268,4 +268,10 @@ %1$s уклони %2$s као адресе ове собе. %1$s уклони %2$s као адресе ове собе. + Изменили сте видео конференцију + %1$s измени видео конференцију + Завршили сте видео конференцију + %1$s заврши видео конференцију + Покренули сте видео конференцију + %1$s покрену видео конференцију \ No newline at end of file diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt index 2306eaed8b..e9b29d99ba 100644 --- a/tools/check/forbidden_strings_in_code.txt +++ b/tools/check/forbidden_strings_in_code.txt @@ -161,7 +161,7 @@ Formatter\.formatShortFileSize===1 # android\.text\.TextUtils ### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If the enum is not used as a Json class, change the value in file forbidden_strings_in_code.txt -enum class===85 +enum class===88 ### Do not import temporary legacy classes import org.matrix.android.sdk.internal.legacy.riot===3 diff --git a/tools/jitsi/build_jisti_libs.sh b/tools/jitsi/build_jisti_libs.sh index 1341a87d10..34051d463c 100755 --- a/tools/jitsi/build_jisti_libs.sh +++ b/tools/jitsi/build_jisti_libs.sh @@ -25,8 +25,8 @@ cd jitsi-meet # This is commit after version 2.2.2, which does not compile # git checkout 5a934c071a5cbe64de275a25d0ed62d8193cdd03 -# Version android-sdk-2.9.3, commit abcbbbea12e3ef88012b14723bb8cd42dbefc988 -git checkout android-sdk-2.9.3 +# Version android-sdk-3.1.0, commit 7a64bf006ea027b77564d8847570e1ac46ff0ec0 +git checkout android-sdk-3.1.0 echo echo "##################################################" diff --git a/tools/release/download_buildkite_artifacts.py b/tools/release/download_buildkite_artifacts.py index 067a1a4dfe..cd1abecfa5 100755 --- a/tools/release/download_buildkite_artifacts.py +++ b/tools/release/download_buildkite_artifacts.py @@ -41,6 +41,9 @@ parser.add_argument('-b', type=int, required=True, help='the buildkite build number.') +parser.add_argument('-f', + '--filename', + help='the filename, to download only one artifact.') parser.add_argument('-e', '--expecting', type=int, @@ -148,6 +151,8 @@ for elt in data: print(" %s: %s" % (key, str(value))) url = elt.get("download_url") filename = elt.get("filename") + if args.filename is not None and args.filename != filename: + continue target = targetDir + "/" + filename print("Downloading %s to '%s'..." % (filename, targetDir)) if not args.simulate: diff --git a/vector/build.gradle b/vector/build.gradle index 486e1b0ab8..42cedaaf3e 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -12,8 +12,8 @@ kapt { // Note: 2 digits max for each value ext.versionMajor = 1 -ext.versionMinor = 0 -ext.versionPatch = 17 +ext.versionMinor = 1 +ext.versionPatch = 0 static def getGitTimestamp() { def cmd = 'git show -s --format=%ct' @@ -114,6 +114,9 @@ android { targetSdkVersion 30 multiDexEnabled true + renderscriptTargetApi 24 + renderscriptSupportModeEnabled true + // `develop` branch will have version code from timestamp, to ensure each build from CI has a incremented versionCode. // Other branches (master, features, etc.) will have version code based on application version. versionCode project.getVersionCode() @@ -232,7 +235,7 @@ android { productFlavors { gplay { dimension "store" - + isDefault = true versionName "${versionMajor}.${versionMinor}.${versionPatch}${getGplayVersionSuffix()}" resValue "bool", "isGplay", "true" @@ -319,6 +322,7 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation "androidx.sharetarget:sharetarget:1.0.0" implementation 'androidx.core:core-ktx:1.3.2' + implementation "androidx.media:media:1.2.1" implementation "org.threeten:threetenbp:1.4.0:no-tzdb" implementation "com.gabrielittner.threetenbp:lazythreetenbp:0.7.0" @@ -373,6 +377,7 @@ dependencies { implementation 'me.saket:better-link-movement-method:2.2.0' implementation 'com.google.android:flexbox:1.1.1' implementation "androidx.autofill:autofill:$autofill_version" + implementation 'jp.wasabeef:glide-transformations:4.3.0' implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12' // Custom Tab @@ -430,7 +435,10 @@ dependencies { // WebRTC // org.webrtc:google-webrtc is for development purposes only // implementation 'org.webrtc:google-webrtc:1.0.+' - implementation('org.jitsi.react:jitsi-meet-sdk:2.9.3') { transitive = true } + implementation('com.facebook.react:react-native-webrtc:1.87.3-jitsi-6624067@aar') + + // Jitsi + implementation('org.jitsi.react:jitsi-meet-sdk:3.1.0') // QR-code // Stick to 3.3.3 because of https://github.com/zxing/zxing/issues/1170 @@ -441,6 +449,8 @@ dependencies { implementation 'com.vanniktech:emoji-material:0.7.0' implementation 'com.vanniktech:emoji-google:0.7.0' + implementation 'im.dlg:android-dialer:1.2.5' + // TESTS testImplementation 'junit:junit:4.13' testImplementation "org.amshove.kluent:kluent-android:$kluent_version" diff --git a/vector/lint.xml b/vector/lint.xml index 572f937406..28da13ca8d 100644 --- a/vector/lint.xml +++ b/vector/lint.xml @@ -17,6 +17,10 @@ + + + + diff --git a/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt b/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt index a4b9983ff4..285f40aaf3 100644 --- a/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt +++ b/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt @@ -23,11 +23,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import org.junit.Assert import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig -import org.matrix.android.sdk.api.auth.data.LoginFlowResult import org.matrix.android.sdk.api.auth.registration.RegistrationResult import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.sync.SyncState @@ -47,22 +47,21 @@ abstract class VerificationTestBase { withInitialSync: Boolean): Session { val hs = createHomeServerConfig() - doSync { - matrix.authenticationService() - .getLoginFlow(hs, it) + runBlockingTest { + matrix.authenticationService().getLoginFlow(hs) } - doSync { + runBlockingTest { matrix.authenticationService() .getRegistrationWizard() - .createAccount(userName, password, null, it) + .createAccount(userName, password, null) } // Perform dummy step - val registrationResult = doSync { + val registrationResult = runBlockingTest { matrix.authenticationService() .getRegistrationWizard() - .dummy(it) + .dummy() } Assert.assertTrue(registrationResult is RegistrationResult.Success) @@ -80,6 +79,14 @@ abstract class VerificationTestBase { .build() } + protected fun runBlockingTest(timeout: Long = 20_000, block: suspend () -> T): T { + return runBlocking { + withTimeout(timeout) { + block() + } + } + } + // Transform a method with a MatrixCallback to a synchronous method inline fun doSync(block: (MatrixCallback) -> Unit): T { val lock = CountDownLatch(1) diff --git a/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt b/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt index 2d0077fc55..338d57fea8 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt @@ -277,8 +277,23 @@ class UiAllScreensSanityTest { assertDisplayed(R.id.roomProfileAvatarView) - // Leave + // Room addresses clickListItem(R.id.matrixProfileRecyclerView, 13) + onView(isRoot()).perform(waitForView(withText(R.string.room_alias_published_alias_title))) + pressBack() + + // Room permissions + clickListItem(R.id.matrixProfileRecyclerView, 15) + onView(isRoot()).perform(waitForView(withText(R.string.room_permissions_title))) + clickOn(R.string.room_permissions_change_room_avatar) + clickDialogNegativeButton() + // Toggle + clickOn(R.string.show_advanced) + clickOn(R.string.hide_advanced) + pressBack() + + // Leave + clickListItem(R.id.matrixProfileRecyclerView, 17) clickDialogNegativeButton() // Menu share @@ -289,27 +304,12 @@ class UiAllScreensSanityTest { } private fun navigateToRoomParameters() { - // Room addresses - clickListItem(R.id.roomSettingsRecyclerView, 4) - onView(isRoot()).perform(waitForView(withText(R.string.room_alias_published_alias_title))) - pressBack() - - // Room permissions - clickListItem(R.id.roomSettingsRecyclerView, 6) - onView(isRoot()).perform(waitForView(withText(R.string.room_permissions_title))) - clickOn(R.string.room_permissions_change_room_avatar) - clickDialogNegativeButton() - // Toggle - clickOn(R.string.show_advanced) - clickOn(R.string.hide_advanced) - pressBack() - // Room history readability - clickListItem(R.id.roomSettingsRecyclerView, 8) + clickListItem(R.id.roomSettingsRecyclerView, 4) pressBack() // Room access - clickListItem(R.id.roomSettingsRecyclerView, 10) + clickListItem(R.id.roomSettingsRecyclerView, 6) pressBack() } diff --git a/vector/src/debug/res/layout/activity_test_material_theme.xml b/vector/src/debug/res/layout/activity_test_material_theme.xml index f6f12942fe..21a2d3fbf5 100644 --- a/vector/src/debug/res/layout/activity_test_material_theme.xml +++ b/vector/src/debug/res/layout/activity_test_material_theme.xml @@ -159,6 +159,7 @@ diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index bfaea39cc6..52932920d4 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -36,6 +36,9 @@ android:name="android.permission.WRITE_CALENDAR" tools:node="remove" /> + + + - + @@ -240,6 +244,7 @@ + --> + @@ -385,6 +386,11 @@ SOFTWARE.
Copyright 2016 JetRadar +
  • + dialogs / android-dialer +
    + Copyright (c) 2017-present, dialog LLC <info@dlg.im> +
  •  Apache License
    @@ -565,20 +571,24 @@ Apache License
     
     
         CC-BY 4.0
    +
    +
    • Twitter/twemoji Graphics
    • -
    +
         ISC License
    +
    +
    • DanielMartinus / Konfetti
      Copyright (c) 2017 Dion Segijn
    • - +
    diff --git a/vector/src/main/java/im/vector/app/VectorApplication.kt b/vector/src/main/java/im/vector/app/VectorApplication.kt index 921e8c0780..1a7fe35745 100644 --- a/vector/src/main/java/im/vector/app/VectorApplication.kt +++ b/vector/src/main/java/im/vector/app/VectorApplication.kt @@ -44,7 +44,7 @@ import im.vector.app.core.di.HasVectorInjector import im.vector.app.core.di.VectorComponent import im.vector.app.core.extensions.configureAndStart import im.vector.app.core.rx.RxConfig -import im.vector.app.features.call.WebRtcPeerConnectionManager +import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.configuration.VectorConfiguration import im.vector.app.features.disclaimer.doNotShowDisclaimerDialog import im.vector.app.features.lifecycle.VectorActivityLifecycleCallbacks @@ -92,7 +92,7 @@ class VectorApplication : @Inject lateinit var rxConfig: RxConfig @Inject lateinit var popupAlertManager: PopupAlertManager @Inject lateinit var pinLocker: PinLocker - @Inject lateinit var webRtcPeerConnectionManager: WebRtcPeerConnectionManager + @Inject lateinit var callManager: WebRtcCallManager lateinit var vectorComponent: VectorComponent @@ -177,7 +177,7 @@ class VectorApplication : }) ProcessLifecycleOwner.get().lifecycle.addObserver(appStateHandler) ProcessLifecycleOwner.get().lifecycle.addObserver(pinLocker) - ProcessLifecycleOwner.get().lifecycle.addObserver(webRtcPeerConnectionManager) + ProcessLifecycleOwner.get().lifecycle.addObserver(callManager) // This should be done as early as possible // initKnownEmojiHashSet(appContext) diff --git a/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt b/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt index 1c47b38fdc..e739cac95f 100644 --- a/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt +++ b/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt @@ -18,12 +18,11 @@ package im.vector.app.core.di import arrow.core.Option import im.vector.app.ActiveSessionDataSource -import im.vector.app.features.call.WebRtcPeerConnectionManager +import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.crypto.keysrequest.KeyRequestHandler import im.vector.app.features.crypto.verification.IncomingVerificationRequestHandler import im.vector.app.features.notifications.PushRuleTriggerListener import im.vector.app.features.session.SessionListener -import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.session.Session import timber.log.Timber import java.util.concurrent.atomic.AtomicReference @@ -31,11 +30,10 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class ActiveSessionHolder @Inject constructor(private val authenticationService: AuthenticationService, - private val sessionObservableStore: ActiveSessionDataSource, +class ActiveSessionHolder @Inject constructor(private val sessionObservableStore: ActiveSessionDataSource, private val keyRequestHandler: KeyRequestHandler, private val incomingVerificationRequestHandler: IncomingVerificationRequestHandler, - private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager, + private val callManager: WebRtcCallManager, private val pushRuleTriggerListener: PushRuleTriggerListener, private val sessionListener: SessionListener, private val imageManager: ImageManager @@ -52,7 +50,7 @@ class ActiveSessionHolder @Inject constructor(private val authenticationService: incomingVerificationRequestHandler.start(session) session.addListener(sessionListener) pushRuleTriggerListener.startWithSession(session) - session.callSignalingService().addCallListener(webRtcPeerConnectionManager) + session.callSignalingService().addCallListener(callManager) imageManager.onSessionStarted(session) } @@ -60,7 +58,7 @@ class ActiveSessionHolder @Inject constructor(private val authenticationService: // Do some cleanup first getSafeActiveSession()?.let { Timber.w("clearActiveSession of ${it.myUserId}") - it.callSignalingService().removeCallListener(webRtcPeerConnectionManager) + it.callSignalingService().removeCallListener(callManager) it.removeListener(sessionListener) } diff --git a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt index 5e4137053e..eff484fb9a 100644 --- a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt @@ -45,6 +45,10 @@ import im.vector.app.features.crypto.verification.emoji.VerificationEmojiCodeFra import im.vector.app.features.crypto.verification.qrconfirmation.VerificationQRWaitingFragment import im.vector.app.features.crypto.verification.qrconfirmation.VerificationQrScannedByOtherFragment import im.vector.app.features.crypto.verification.request.VerificationRequestFragment +import im.vector.app.features.devtools.RoomDevToolEditFragment +import im.vector.app.features.devtools.RoomDevToolFragment +import im.vector.app.features.devtools.RoomDevToolSendFormFragment +import im.vector.app.features.devtools.RoomDevToolStateEventListFragment import im.vector.app.features.discovery.DiscoverySettingsFragment import im.vector.app.features.discovery.change.SetIdentityServerFragment import im.vector.app.features.grouplist.GroupListFragment @@ -594,4 +598,24 @@ interface FragmentModule { @IntoMap @FragmentKey(ShowUserCodeFragment::class) fun bindShowUserCodeFragment(fragment: ShowUserCodeFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(RoomDevToolFragment::class) + fun bindRoomDevToolFragment(fragment: RoomDevToolFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(RoomDevToolStateEventListFragment::class) + fun bindRoomDevToolStateEventListFragment(fragment: RoomDevToolStateEventListFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(RoomDevToolEditFragment::class) + fun bindRoomDevToolEditFragment(fragment: RoomDevToolEditFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(RoomDevToolSendFormFragment::class) + fun bindRoomDevToolSendFormFragment(fragment: RoomDevToolSendFormFragment): Fragment } diff --git a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt index 5eb515c64b..a1de892c4e 100644 --- a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt @@ -29,12 +29,14 @@ import im.vector.app.features.auth.ReAuthActivity import im.vector.app.features.call.CallControlsBottomSheet import im.vector.app.features.call.VectorCallActivity import im.vector.app.features.call.conference.VectorJitsiActivity +import im.vector.app.features.call.transfer.CallTransferActivity import im.vector.app.features.createdirect.CreateDirectRoomActivity import im.vector.app.features.crypto.keysbackup.settings.KeysBackupManageActivity import im.vector.app.features.crypto.quads.SharedSecureStorageActivity import im.vector.app.features.crypto.recover.BootstrapBottomSheet import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.debug.DebugMenuActivity +import im.vector.app.features.devtools.RoomDevToolActivity import im.vector.app.features.home.HomeActivity import im.vector.app.features.home.HomeModule import im.vector.app.features.home.room.detail.RoomDetailActivity @@ -146,7 +148,9 @@ interface ScreenComponent { fun inject(activity: VectorJitsiActivity) fun inject(activity: SearchActivity) fun inject(activity: UserCodeActivity) + fun inject(activity: CallTransferActivity) fun inject(activity: ReAuthActivity) + fun inject(activity: RoomDevToolActivity) /* ========================================================================================== * BottomSheets diff --git a/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt b/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt index 273a142ff1..23d6b618fe 100644 --- a/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt @@ -29,7 +29,7 @@ import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.pushers.PushersManager import im.vector.app.core.utils.AssetReader import im.vector.app.core.utils.DimensionConverter -import im.vector.app.features.call.WebRtcPeerConnectionManager +import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.configuration.VectorConfiguration import im.vector.app.features.crypto.keysrequest.KeyRequestHandler import im.vector.app.features.crypto.verification.IncomingVerificationRequestHandler @@ -38,6 +38,7 @@ import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.HomeRoomListDataSource import im.vector.app.features.home.room.detail.RoomDetailPendingActionStore import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider +import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder import im.vector.app.features.html.EventHtmlRenderer import im.vector.app.features.html.VectorHtmlCompressor import im.vector.app.features.login.ReAuthHelper @@ -156,7 +157,9 @@ interface VectorComponent { fun pinLocker(): PinLocker - fun webRtcPeerConnectionManager(): WebRtcPeerConnectionManager + fun webRtcCallManager(): WebRtcCallManager + + fun roomSummaryHolder(): RoomSummariesHolder @Component.Factory interface Factory { diff --git a/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt index bed2e0b850..8409021845 100644 --- a/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt @@ -22,7 +22,7 @@ import dagger.Binds import dagger.Module import dagger.multibindings.IntoMap import im.vector.app.core.platform.ConfigurationViewModel -import im.vector.app.features.call.SharedActiveCallViewModel +import im.vector.app.features.call.SharedKnownCallsViewModel import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreFromKeyViewModel import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreFromPassphraseViewModel import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreSharedViewModel @@ -85,8 +85,8 @@ interface ViewModelModule { @Binds @IntoMap - @ViewModelKey(SharedActiveCallViewModel::class) - fun bindSharedActiveCallViewModel(viewModel: SharedActiveCallViewModel): ViewModel + @ViewModelKey(SharedKnownCallsViewModel::class) + fun bindSharedActiveCallViewModel(viewModel: SharedKnownCallsViewModel): ViewModel @Binds @IntoMap diff --git a/vector/src/main/java/im/vector/app/core/dialogs/ExportKeysDialog.kt b/vector/src/main/java/im/vector/app/core/dialogs/ExportKeysDialog.kt index e137eb1b70..23018fe758 100644 --- a/vector/src/main/java/im/vector/app/core/dialogs/ExportKeysDialog.kt +++ b/vector/src/main/java/im/vector/app/core/dialogs/ExportKeysDialog.kt @@ -61,7 +61,7 @@ class ExportKeysDialog { passwordVisible = !passwordVisible views.exportDialogEt.showPassword(passwordVisible) views.exportDialogEtConfirm.showPassword(passwordVisible) - views.exportDialogShowPassword.setImageResource(if (passwordVisible) R.drawable.ic_eye_closed else R.drawable.ic_eye) + views.exportDialogShowPassword.render(passwordVisible) } val exportDialog = builder.show() diff --git a/vector/src/main/java/im/vector/app/core/dialogs/PromptPasswordDialog.kt b/vector/src/main/java/im/vector/app/core/dialogs/PromptPasswordDialog.kt index 6d7b721976..1839a8b11c 100644 --- a/vector/src/main/java/im/vector/app/core/dialogs/PromptPasswordDialog.kt +++ b/vector/src/main/java/im/vector/app/core/dialogs/PromptPasswordDialog.kt @@ -44,7 +44,7 @@ class PromptPasswordDialog { views.promptPasswordPasswordReveal.setOnClickListener { passwordVisible = !passwordVisible views.promptPassword.showPassword(passwordVisible) - views.promptPasswordPasswordReveal.setImageResource(if (passwordVisible) R.drawable.ic_eye_closed else R.drawable.ic_eye) + views.promptPasswordPasswordReveal.render(passwordVisible) } AlertDialog.Builder(activity) diff --git a/vector/src/main/java/im/vector/app/core/epoxy/TimelineEmptyItem.kt b/vector/src/main/java/im/vector/app/core/epoxy/TimelineEmptyItem.kt new file mode 100644 index 0000000000..b77670ba76 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/epoxy/TimelineEmptyItem.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.epoxy + +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.features.home.room.detail.timeline.item.ItemWithEvents + +@EpoxyModelClass(layout = R.layout.item_timeline_empty) +abstract class TimelineEmptyItem : VectorEpoxyModel(), ItemWithEvents { + + @EpoxyAttribute lateinit var eventId: String + + override fun getEventIds(): List { + return listOf(eventId) + } + + class Holder : VectorEpoxyHolder() +} diff --git a/vector/src/main/java/im/vector/app/core/epoxy/profiles/BaseProfileMatrixItem.kt b/vector/src/main/java/im/vector/app/core/epoxy/profiles/BaseProfileMatrixItem.kt index aa8075819e..ccb3bea25a 100644 --- a/vector/src/main/java/im/vector/app/core/epoxy/profiles/BaseProfileMatrixItem.kt +++ b/vector/src/main/java/im/vector/app/core/epoxy/profiles/BaseProfileMatrixItem.kt @@ -21,7 +21,6 @@ import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.extensions.setTextOrHide -import im.vector.app.features.crypto.util.toImageRes import im.vector.app.features.home.AvatarRenderer import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel import org.matrix.android.sdk.api.util.MatrixItem @@ -47,6 +46,6 @@ abstract class BaseProfileMatrixItem : VectorEpoxy holder.subtitleView.setTextOrHide(matrixId) holder.editableView.isVisible = editable avatarRenderer.render(matrixItem, holder.avatarImageView) - holder.avatarDecorationImageView.setImageResource(userEncryptionTrustLevel.toImageRes()) + holder.avatarDecorationImageView.render(userEncryptionTrustLevel) } } diff --git a/vector/src/main/java/im/vector/app/core/epoxy/profiles/ProfileMatrixItem.kt b/vector/src/main/java/im/vector/app/core/epoxy/profiles/ProfileMatrixItem.kt index e8fc671d33..6eb06722b9 100644 --- a/vector/src/main/java/im/vector/app/core/epoxy/profiles/ProfileMatrixItem.kt +++ b/vector/src/main/java/im/vector/app/core/epoxy/profiles/ProfileMatrixItem.kt @@ -23,6 +23,7 @@ import android.widget.TextView import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.ui.views.ShieldImageView @EpoxyModelClass(layout = R.layout.item_profile_matrix_item) abstract class ProfileMatrixItem : BaseProfileMatrixItem() { @@ -31,7 +32,7 @@ abstract class ProfileMatrixItem : BaseProfileMatrixItem(R.id.matrixItemTitle) val subtitleView by bind(R.id.matrixItemSubtitle) val avatarImageView by bind(R.id.matrixItemAvatar) - val avatarDecorationImageView by bind(R.id.matrixItemAvatarDecoration) + val avatarDecorationImageView by bind(R.id.matrixItemAvatarDecoration) val editableView by bind(R.id.matrixItemEditable) } } diff --git a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt index 8258370fe0..d7f003574c 100644 --- a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt +++ b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt @@ -18,6 +18,7 @@ package im.vector.app.core.error import im.vector.app.R import im.vector.app.core.resources.StringProvider +import im.vector.app.features.call.dialpad.DialPadLookup import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.failure.isInvalidPassword @@ -112,7 +113,9 @@ class DefaultErrorFormatter @Inject constructor( throwable.localizedMessage } } - else -> throwable.localizedMessage + is DialPadLookup.Failure -> + stringProvider.getString(R.string.call_dial_pad_lookup_error) + else -> throwable.localizedMessage } ?: stringProvider.getString(R.string.unknown_error) } diff --git a/vector/src/main/java/im/vector/app/core/extensions/LiveData.kt b/vector/src/main/java/im/vector/app/core/extensions/LiveData.kt index 192953f609..588063e2a4 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/LiveData.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/LiveData.kt @@ -19,17 +19,16 @@ package im.vector.app.core.extensions import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer import im.vector.app.core.utils.EventObserver import im.vector.app.core.utils.FirstThrottler import im.vector.app.core.utils.LiveEvent inline fun LiveData.observeK(owner: LifecycleOwner, crossinline observer: (T?) -> Unit) { - this.observe(owner, Observer { observer(it) }) + this.observe(owner, { observer(it) }) } inline fun LiveData.observeNotNull(owner: LifecycleOwner, crossinline observer: (T) -> Unit) { - this.observe(owner, Observer { it?.run(observer) }) + this.observe(owner, { it?.run(observer) }) } inline fun LiveData>.observeEvent(owner: LifecycleOwner, crossinline observer: (T) -> Unit) { diff --git a/vector/src/main/java/im/vector/app/core/extensions/Set.kt b/vector/src/main/java/im/vector/app/core/extensions/Set.kt index a78fb85a1d..e1787076b9 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/Set.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/Set.kt @@ -17,10 +17,18 @@ package im.vector.app.core.extensions // Create a new Set including the provided element if not already present, or removing the element if already present -fun Set.toggle(element: T): Set { +fun Set.toggle(element: T, singleElement: Boolean = false): Set { return if (contains(element)) { - minus(element) + if (singleElement) { + emptySet() + } else { + minus(element) + } } else { - plus(element) + if (singleElement) { + setOf(element) + } else { + plus(element) + } } } diff --git a/vector/src/main/java/im/vector/app/core/extensions/TextView.kt b/vector/src/main/java/im/vector/app/core/extensions/TextView.kt index 44b85df93a..b7f97dc6f7 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/TextView.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/TextView.kt @@ -18,11 +18,19 @@ package im.vector.app.core.extensions import android.text.Spannable import android.text.SpannableString +import android.text.TextPaint +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan import android.text.style.ForegroundColorSpan import android.text.style.UnderlineSpan +import android.view.View import android.widget.TextView import androidx.annotation.AttrRes +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.isVisible import com.google.android.material.snackbar.Snackbar import im.vector.app.R @@ -48,11 +56,13 @@ fun TextView.setTextOrHide(newText: CharSequence?, hideWhenBlank: Boolean = true * @param coloredTextRes the resource id of the colored part of the text * @param colorAttribute attribute of the color. Default to colorAccent * @param underline true to also underline the text. Default to false + * @param onClick attributes to handle click on the colored part if needed */ fun TextView.setTextWithColoredPart(@StringRes fullTextRes: Int, @StringRes coloredTextRes: Int, @AttrRes colorAttribute: Int = R.attr.colorAccent, - underline: Boolean = false) { + underline: Boolean = false, + onClick: (() -> Unit)?) { val coloredPart = resources.getString(coloredTextRes) // Insert colored part into the full text val fullText = resources.getString(fullTextRes, coloredPart) @@ -65,12 +75,38 @@ fun TextView.setTextWithColoredPart(@StringRes fullTextRes: Int, text = SpannableString(fullText) .apply { setSpan(foregroundSpan, index, index + coloredPart.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + if (onClick != null) { + val clickableSpan = object : ClickableSpan() { + override fun onClick(widget: View) { + onClick() + } + + override fun updateDrawState(ds: TextPaint) { + ds.color = color + ds.isUnderlineText = !underline + } + } + setSpan(clickableSpan, index, index + coloredPart.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + movementMethod = LinkMovementMethod.getInstance() + } if (underline) { setSpan(UnderlineSpan(), index, index + coloredPart.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } } } +fun TextView.setLeftDrawable(@DrawableRes iconRes: Int, @ColorRes tintColor: Int? = null) { + val icon = if (tintColor != null) { + val tint = ContextCompat.getColor(context, tintColor) + ContextCompat.getDrawable(context, iconRes)?.also { + DrawableCompat.setTint(it.mutate(), tint) + } + } else { + ContextCompat.getDrawable(context, iconRes) + } + setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) +} + /** * Set long click listener to copy the current text of the TextView to the clipboard and show a Snackbar */ diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt b/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt index d8b61f3cba..43c96d2468 100644 --- a/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt +++ b/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt @@ -40,11 +40,11 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentFactory import androidx.fragment.app.FragmentManager -import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.viewbinding.ViewBinding import com.bumptech.glide.util.Util import com.google.android.material.snackbar.Snackbar +import com.jakewharton.rxbinding3.view.clicks import im.vector.app.BuildConfig import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder @@ -87,6 +87,7 @@ import io.reactivex.disposables.Disposable import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.failure.GlobalError import timber.log.Timber +import java.util.concurrent.TimeUnit import kotlin.system.measureTimeMillis abstract class VectorBaseActivity : AppCompatActivity(), HasScreenInjector { @@ -116,6 +117,18 @@ abstract class VectorBaseActivity : AppCompatActivity(), HasScr .disposeOnDestroy() } + /* ========================================================================================== + * Views + * ========================================================================================== */ + + protected fun View.debouncedClicks(onClicked: () -> Unit) { + clicks() + .throttleFirst(300, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { onClicked() } + .disposeOnDestroy() + } + /* ========================================================================================== * DATA * ========================================================================================== */ @@ -194,12 +207,12 @@ abstract class VectorBaseActivity : AppCompatActivity(), HasScr navigator = screenComponent.navigator() activeSessionHolder = screenComponent.activeSessionHolder() vectorPreferences = vectorComponent.vectorPreferences() - configurationViewModel.activityRestarter.observe(this, Observer { + configurationViewModel.activityRestarter.observe(this) { if (!it.hasBeenHandled) { // Recreate the Activity because configuration has changed restart() } - }) + } pinLocker.getLiveState().observeNotNull(this) { if (this@VectorBaseActivity !is UnlockedActivity && it == PinLocker.State.LOCKED) { navigator.openPinCode(this, pinStartForActivityResult, PinMode.AUTH) diff --git a/vector/src/main/java/im/vector/app/core/services/CallRingPlayer.kt b/vector/src/main/java/im/vector/app/core/services/CallRingPlayer.kt index d5d8bb14dd..f725742711 100644 --- a/vector/src/main/java/im/vector/app/core/services/CallRingPlayer.kt +++ b/vector/src/main/java/im/vector/app/core/services/CallRingPlayer.kt @@ -16,33 +16,76 @@ package im.vector.app.core.services +import android.app.NotificationChannel import android.content.Context -import android.media.Ringtone -import android.media.RingtoneManager import android.media.AudioAttributes import android.media.AudioManager import android.media.MediaPlayer +import android.media.Ringtone +import android.media.RingtoneManager import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator import androidx.core.content.getSystemService import im.vector.app.R +import im.vector.app.features.notifications.NotificationUtils +import org.matrix.android.sdk.api.extensions.orFalse import timber.log.Timber class CallRingPlayerIncoming( - context: Context + context: Context, + private val notificationUtils: NotificationUtils ) { private val applicationContext = context.applicationContext - private var r: Ringtone? = null + private var ringtone: Ringtone? = null + private var vibrator: Vibrator? = null - fun start() { - val notification = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) - r = RingtoneManager.getRingtone(applicationContext, notification) - Timber.v("## VOIP Starting ringing incomming") - r?.play() + private val VIBRATE_PATTERN = longArrayOf(0, 400, 600) + + fun start(fromBg: Boolean) { + val audioManager = applicationContext.getSystemService() + val incomingCallChannel = notificationUtils.getChannelForIncomingCall(fromBg) + val ringerMode = audioManager?.ringerMode + if (ringerMode == AudioManager.RINGER_MODE_NORMAL) { + playRingtoneIfNeeded(incomingCallChannel) + } else if (ringerMode == AudioManager.RINGER_MODE_VIBRATE) { + vibrateIfNeeded(incomingCallChannel) + } + } + + private fun playRingtoneIfNeeded(incomingCallChannel: NotificationChannel?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && incomingCallChannel?.sound != null) { + Timber.v("Ringtone already configured by notification channel") + return + } + val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + ringtone = RingtoneManager.getRingtone(applicationContext, ringtoneUri) + Timber.v("Play ringtone for incoming call") + ringtone?.play() + } + + private fun vibrateIfNeeded(incomingCallChannel: NotificationChannel?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && incomingCallChannel?.shouldVibrate().orFalse()) { + Timber.v("## Vibration already configured by notification channel") + return + } + vibrator = applicationContext.getSystemService() + Timber.v("Vibrate for incoming call") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val vibrationEffect = VibrationEffect.createWaveform(VIBRATE_PATTERN, 0) + vibrator?.vibrate(vibrationEffect) + } else { + @Suppress("DEPRECATION") + vibrator?.vibrate(VIBRATE_PATTERN, 0) + } } fun stop() { - r?.stop() + ringtone?.stop() + ringtone = null + vibrator?.cancel() + vibrator = null } } @@ -55,12 +98,12 @@ class CallRingPlayerOutgoing( private var player: MediaPlayer? = null fun start() { - val audioManager = applicationContext.getSystemService()!! + val audioManager: AudioManager? = applicationContext.getSystemService() player?.release() player = createPlayer() // Check if sound is enabled - val ringerMode = audioManager.ringerMode + val ringerMode = audioManager?.ringerMode if (player != null && ringerMode == AudioManager.RINGER_MODE_NORMAL) { try { if (player?.isPlaying == false) { @@ -89,14 +132,14 @@ class CallRingPlayerOutgoing( mediaPlayer.setOnErrorListener(MediaPlayerErrorListener()) mediaPlayer.isLooping = true - if (Build.VERSION.SDK_INT <= 21) { - @Suppress("DEPRECATION") - mediaPlayer.setAudioStreamType(AudioManager.STREAM_RING) - } else { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) { mediaPlayer.setAudioAttributes(AudioAttributes.Builder() .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) .build()) + } else { + @Suppress("DEPRECATION") + mediaPlayer.setAudioStreamType(AudioManager.STREAM_RING) } return mediaPlayer } catch (failure: Throwable) { diff --git a/vector/src/main/java/im/vector/app/core/services/CallService.kt b/vector/src/main/java/im/vector/app/core/services/CallService.kt index 075b237be2..e9e855e760 100644 --- a/vector/src/main/java/im/vector/app/core/services/CallService.kt +++ b/vector/src/main/java/im/vector/app/core/services/CallService.kt @@ -22,30 +22,41 @@ import android.content.Intent import android.os.Binder import android.support.v4.media.session.MediaSessionCompat import android.view.KeyEvent +import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.media.session.MediaButtonReceiver +import com.airbnb.mvrx.MvRx import im.vector.app.core.extensions.vectorComponent -import im.vector.app.features.call.WebRtcPeerConnectionManager +import im.vector.app.features.call.CallArgs +import im.vector.app.features.call.VectorCallActivity import im.vector.app.features.call.telecom.CallConnection +import im.vector.app.features.call.webrtc.WebRtcCall +import im.vector.app.features.call.webrtc.WebRtcCallManager +import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.notifications.NotificationUtils +import im.vector.app.features.popup.IncomingCallAlert +import im.vector.app.features.popup.PopupAlertManager +import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.toMatrixItem import timber.log.Timber /** * Foreground service to manage calls */ -class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListener, BluetoothHeadsetReceiver.EventListener { +class CallService : VectorService() { private val connections = mutableMapOf() + private val knownCalls = mutableSetOf() + private lateinit var notificationManager: NotificationManagerCompat private lateinit var notificationUtils: NotificationUtils - private lateinit var webRtcPeerConnectionManager: WebRtcPeerConnectionManager + private lateinit var callManager: WebRtcCallManager + private lateinit var avatarRenderer: AvatarRenderer + private lateinit var alertManager: PopupAlertManager private var callRingPlayerIncoming: CallRingPlayerIncoming? = null private var callRingPlayerOutgoing: CallRingPlayerOutgoing? = null - private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null - private var bluetoothHeadsetStateReceiver: BluetoothHeadsetReceiver? = null - // A media button receiver receives and helps translate hardware media playback buttons, // such as those found on wired and wireless headsets, into the appropriate callbacks in your app private var mediaSession: MediaSessionCompat? = null @@ -53,7 +64,7 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean { val keyEvent = mediaButtonEvent?.getParcelableExtra(Intent.EXTRA_KEY_EVENT) ?: return false if (keyEvent.keyCode == KeyEvent.KEYCODE_HEADSETHOOK) { - webRtcPeerConnectionManager.headSetButtonTapped() + callManager.headSetButtonTapped() return true } return false @@ -62,22 +73,19 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe override fun onCreate() { super.onCreate() + notificationManager = NotificationManagerCompat.from(this) notificationUtils = vectorComponent().notificationUtils() - webRtcPeerConnectionManager = vectorComponent().webRtcPeerConnectionManager() - callRingPlayerIncoming = CallRingPlayerIncoming(applicationContext) + callManager = vectorComponent().webRtcCallManager() + avatarRenderer = vectorComponent().avatarRenderer() + alertManager = vectorComponent().alertManager() + callRingPlayerIncoming = CallRingPlayerIncoming(applicationContext, notificationUtils) callRingPlayerOutgoing = CallRingPlayerOutgoing(applicationContext) - wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(this, this) - bluetoothHeadsetStateReceiver = BluetoothHeadsetReceiver.createAndRegister(this, this) } override fun onDestroy() { super.onDestroy() callRingPlayerIncoming?.stop() callRingPlayerOutgoing?.stop() - wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(this, it) } - wiredHeadsetStateReceiver = null - bluetoothHeadsetStateReceiver?.let { BluetoothHeadsetReceiver.unRegister(this, it) } - bluetoothHeadsetStateReceiver = null mediaSession?.release() mediaSession = null } @@ -89,21 +97,17 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe 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) { + when (intent?.action) { ACTION_INCOMING_RINGING_CALL -> { mediaSession?.isActive = true - callRingPlayerIncoming?.start() + val fromBg = intent.getBooleanExtra(EXTRA_IS_IN_BG, false) + callRingPlayerIncoming?.start(fromBg) displayIncomingCallNotification(intent) } ACTION_OUTGOING_RINGING_CALL -> { @@ -111,33 +115,28 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe callRingPlayerOutgoing?.start() displayOutgoingRingingCallNotification(intent) } - ACTION_ONGOING_CALL -> { + ACTION_ONGOING_CALL -> { callRingPlayerIncoming?.stop() callRingPlayerOutgoing?.stop() displayCallInProgressNotification(intent) } - ACTION_NO_ACTIVE_CALL -> hideCallNotifications() - ACTION_CALL_CONNECTING -> { + ACTION_CALL_CONNECTING -> { // lower notification priority displayCallInProgressNotification(intent) // stop ringing callRingPlayerIncoming?.stop() callRingPlayerOutgoing?.stop() } - ACTION_ONGOING_CALL_BG -> { - // there is an ongoing call but call activity is in background - displayCallOnGoingInBackground(intent) + ACTION_CALL_TERMINATED -> { + handleCallTerminated(intent) } else -> { - // Should not happen - callRingPlayerIncoming?.stop() - callRingPlayerOutgoing?.stop() - myStopSelf() + handleUnexpectedState(null) } } // We want the system to restore the service if killed - return START_STICKY + return START_REDELIVER_INTENT } // ================================================================================ @@ -147,64 +146,90 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe /** * Display a permanent notification when there is an incoming call. * - * @param session the session - * @param isVideo true if this is a video call, false for voice call - * @param room the room - * @param callId the callId */ private fun displayIncomingCallNotification(intent: Intent) { 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 == webRtcPeerConnectionManager.currentCall) -// { - val callId = intent.getStringExtra(EXTRA_CALL_ID) - + val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: "" + val call = callManager.getCallById(callId) ?: return Unit.also { + handleUnexpectedState(callId) + } + val isVideoCall = call.mxCall.isVideoCall + val fromBg = intent.getBooleanExtra(EXTRA_IS_IN_BG, false) + val opponentMatrixItem = getOpponentMatrixItem(call) Timber.v("displayIncomingCallNotification : display the dedicated notification") + val incomingCallAlert = IncomingCallAlert(callId, + shouldBeDisplayedIn = { activity -> + if (activity is VectorCallActivity) { + activity.intent.getParcelableExtra(MvRx.KEY_ARG)?.callId != call.callId + } else true + } + ).apply { + viewBinder = IncomingCallAlert.ViewBinder( + matrixItem = opponentMatrixItem, + avatarRenderer = avatarRenderer, + isVideoCall = isVideoCall, + onAccept = { showCallScreen(call, VectorCallActivity.INCOMING_ACCEPT) }, + onReject = { call.endCall() } + ) + dismissedAction = Runnable { call.endCall() } + contentAction = Runnable { showCallScreen(call, VectorCallActivity.INCOMING_RINGING) } + } + alertManager.postVectorAlert(incomingCallAlert) val notification = notificationUtils.buildIncomingCallNotification( - intent.getBooleanExtra(EXTRA_IS_VIDEO, false), - intent.getStringExtra(EXTRA_ROOM_NAME) ?: "", - intent.getStringExtra(EXTRA_ROOM_ID) ?: "", - callId ?: "") - startForeground(NOTIFICATION_ID, notification) + mxCall = call.mxCall, + title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId, + fromBg = fromBg + ) + if (knownCalls.isEmpty()) { + startForeground(callId.hashCode(), notification) + } else { + notificationManager.notify(callId.hashCode(), notification) + } + knownCalls.add(callId) + } -// mIncomingCallId = callId + private fun handleCallTerminated(intent: Intent) { + val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: "" + alertManager.cancelAlert(callId) + if (!knownCalls.remove(callId)) { + Timber.v("Call terminated for unknown call $callId$") + handleUnexpectedState(callId) + return + } + val notification = notificationUtils.buildCallEndedNotification() + notificationManager.notify(callId.hashCode(), notification) + if (knownCalls.isEmpty()) { + mediaSession?.isActive = false + myStopSelf() + } + } - // turn the screen on for 3 seconds -// if (Matrix.getInstance(VectorApp.getInstance())!!.pushManager.isScreenTurnedOn) { -// try { -// val pm = getSystemService()!! -// 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") -// } + private fun showCallScreen(call: WebRtcCall, mode: String) { + val intent = VectorCallActivity.newIntent( + context = this, + mxCall = call.mxCall, + mode = mode + ) + startActivity(intent) } private fun displayOutgoingRingingCallNotification(intent: Intent) { - val callId = intent.getStringExtra(EXTRA_CALL_ID) - + val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: "" + val call = callManager.getCallById(callId) ?: return Unit.also { + handleUnexpectedState(callId) + } + val opponentMatrixItem = getOpponentMatrixItem(call) Timber.v("displayOutgoingCallNotification : display the dedicated notification") 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) + mxCall = call.mxCall, + title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId + ) + if (knownCalls.isEmpty()) { + startForeground(callId.hashCode(), notification) + } else { + notificationManager.notify(callId.hashCode(), notification) + } + knownCalls.add(callId) } /** @@ -213,125 +238,78 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe private fun displayCallInProgressNotification(intent: Intent) { Timber.v("## VOIP displayCallInProgressNotification") val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: "" - + val call = callManager.getCallById(callId) ?: return Unit.also { + handleUnexpectedState(callId) + } + val opponentMatrixItem = getOpponentMatrixItem(call) + alertManager.cancelAlert(callId) val notification = notificationUtils.buildPendingCallNotification( - intent.getBooleanExtra(EXTRA_IS_VIDEO, false), - intent.getStringExtra(EXTRA_ROOM_NAME) ?: "", - intent.getStringExtra(EXTRA_ROOM_ID) ?: "", - intent.getStringExtra(EXTRA_MATRIX_ID) ?: "", - callId) - - startForeground(NOTIFICATION_ID, notification) - - // mCallIdInProgress = callId + mxCall = call.mxCall, + title = opponentMatrixItem?.getBestName() ?: call.mxCall.opponentUserId + ) + if (knownCalls.isEmpty()) { + startForeground(callId.hashCode(), notification) + } else { + notificationManager.notify(callId.hashCode(), notification) + } + knownCalls.add(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 - } - - /** - * Hide the permanent call notifications - */ - private fun hideCallNotifications() { + private fun handleUnexpectedState(callId: String?) { + Timber.v("Fallback to clear everything") + callRingPlayerIncoming?.stop() + callRingPlayerOutgoing?.stop() + if (callId != null) { + notificationManager.cancel(callId.hashCode()) + } val notification = notificationUtils.buildCallEndedNotification() - - mediaSession?.isActive = false - // It's mandatory to startForeground to avoid crash - startForeground(NOTIFICATION_ID, notification) - - myStopSelf() + startForeground(DEFAULT_NOTIFICATION_ID, notification) + if (knownCalls.isEmpty()) { + mediaSession?.isActive = false + myStopSelf() + } } fun addConnection(callConnection: CallConnection) { connections[callConnection.callId] = callConnection } + private fun getOpponentMatrixItem(call: WebRtcCall): MatrixItem? { + return vectorComponent().currentSession().getUser(call.mxCall.opponentUserId)?.toMatrixItem() + } + companion object { - private const val NOTIFICATION_ID = 6480 + private const val DEFAULT_NOTIFICATION_ID = 6480 private const val ACTION_INCOMING_RINGING_CALL = "im.vector.app.core.services.CallService.ACTION_INCOMING_RINGING_CALL" private const val ACTION_OUTGOING_RINGING_CALL = "im.vector.app.core.services.CallService.ACTION_OUTGOING_RINGING_CALL" private const val ACTION_CALL_CONNECTING = "im.vector.app.core.services.CallService.ACTION_CALL_CONNECTING" private const val ACTION_ONGOING_CALL = "im.vector.app.core.services.CallService.ACTION_ONGOING_CALL" - private const val ACTION_ONGOING_CALL_BG = "im.vector.app.core.services.CallService.ACTION_ONGOING_CALL_BG" + private const val ACTION_CALL_TERMINATED = "im.vector.app.core.services.CallService.ACTION_CALL_TERMINATED" private const val ACTION_NO_ACTIVE_CALL = "im.vector.app.core.services.CallService.NO_ACTIVE_CALL" // private const val ACTION_ACTIVITY_VISIBLE = "im.vector.app.core.services.CallService.ACTION_ACTIVITY_VISIBLE" // private const val ACTION_STOP_RINGING = "im.vector.app.core.services.CallService.ACTION_STOP_RINGING" - private const val EXTRA_IS_VIDEO = "EXTRA_IS_VIDEO" - private const val EXTRA_ROOM_NAME = "EXTRA_ROOM_NAME" - private const val EXTRA_ROOM_ID = "EXTRA_ROOM_ID" - private const val EXTRA_MATRIX_ID = "EXTRA_MATRIX_ID" private const val EXTRA_CALL_ID = "EXTRA_CALL_ID" + private const val EXTRA_IS_IN_BG = "EXTRA_IS_IN_BG" fun onIncomingCallRinging(context: Context, - isVideo: Boolean, - roomName: String, - roomId: String, - matrixId: String, - callId: String) { + callId: String, + isInBackground: Boolean) { val intent = Intent(context, CallService::class.java) .apply { action = ACTION_INCOMING_RINGING_CALL - putExtra(EXTRA_IS_VIDEO, isVideo) - putExtra(EXTRA_ROOM_NAME, roomName) - putExtra(EXTRA_ROOM_ID, roomId) - putExtra(EXTRA_MATRIX_ID, matrixId) putExtra(EXTRA_CALL_ID, callId) + putExtra(EXTRA_IS_IN_BG, isInBackground) } - - ContextCompat.startForegroundService(context, intent) - } - - fun onOnGoingCallBackground(context: Context, - isVideo: Boolean, - roomName: String, - roomId: String, - matrixId: String, - callId: String) { - val intent = Intent(context, CallService::class.java) - .apply { - action = ACTION_ONGOING_CALL_BG - putExtra(EXTRA_IS_VIDEO, isVideo) - putExtra(EXTRA_ROOM_NAME, roomName) - putExtra(EXTRA_ROOM_ID, roomId) - putExtra(EXTRA_MATRIX_ID, matrixId) - putExtra(EXTRA_CALL_ID, callId) - } - ContextCompat.startForegroundService(context, intent) } 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) - putExtra(EXTRA_MATRIX_ID, matrixId) putExtra(EXTRA_CALL_ID, callId) } @@ -339,30 +317,22 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe } fun onPendingCall(context: Context, - isVideo: Boolean, - roomName: String, - roomId: String, - matrixId: String, callId: String) { val intent = Intent(context, CallService::class.java) .apply { action = ACTION_ONGOING_CALL - putExtra(EXTRA_IS_VIDEO, isVideo) - putExtra(EXTRA_ROOM_NAME, roomName) - putExtra(EXTRA_ROOM_ID, roomId) - putExtra(EXTRA_MATRIX_ID, matrixId) putExtra(EXTRA_CALL_ID, callId) } ContextCompat.startForegroundService(context, intent) } - fun onNoActiveCall(context: Context) { + fun onCallTerminated(context: Context, callId: String) { val intent = Intent(context, CallService::class.java) .apply { - action = ACTION_NO_ACTIVE_CALL + action = ACTION_CALL_TERMINATED + putExtra(EXTRA_CALL_ID, callId) } - ContextCompat.startForegroundService(context, intent) } } @@ -372,14 +342,4 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe 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/app/core/ui/list/GenericItem.kt b/vector/src/main/java/im/vector/app/core/ui/list/GenericItem.kt index 492df9eb00..3a1337e78c 100644 --- a/vector/src/main/java/im/vector/app/core/ui/list/GenericItem.kt +++ b/vector/src/main/java/im/vector/app/core/ui/list/GenericItem.kt @@ -48,7 +48,7 @@ abstract class GenericItem : VectorEpoxyModel() { } @EpoxyAttribute - var title: String? = null + var title: CharSequence? = null @EpoxyAttribute var description: CharSequence? = null diff --git a/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt b/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt index f86825750a..59418147d7 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt @@ -41,13 +41,13 @@ class BottomSheetActionButton @JvmOverloads constructor( var title: String? = null set(value) { field = value - views.itemVerificationActionTitle.setTextOrHide(value) + views.bottomSheetActionTitle.setTextOrHide(value) } var subTitle: String? = null set(value) { field = value - views.itemVerificationActionSubTitle.setTextOrHide(value) + views.bottomSheetActionSubTitle.setTextOrHide(value) } var forceStartPadding: Boolean? = null @@ -55,9 +55,9 @@ class BottomSheetActionButton @JvmOverloads constructor( field = value if (leftIcon == null) { if (forceStartPadding == true) { - views.itemVerificationLeftIcon.isInvisible = true + views.bottomSheetActionLeftIcon.isInvisible = true } else { - views.itemVerificationLeftIcon.isGone = true + views.bottomSheetActionLeftIcon.isGone = true } } } @@ -67,33 +67,33 @@ class BottomSheetActionButton @JvmOverloads constructor( field = value if (value == null) { if (forceStartPadding == true) { - views.itemVerificationLeftIcon.isInvisible = true + views.bottomSheetActionLeftIcon.isInvisible = true } else { - views.itemVerificationLeftIcon.isGone = true + views.bottomSheetActionLeftIcon.isGone = true } - views.itemVerificationLeftIcon.setImageDrawable(null) + views.bottomSheetActionLeftIcon.setImageDrawable(null) } else { - views.itemVerificationLeftIcon.isVisible = true - views.itemVerificationLeftIcon.setImageDrawable(value) + views.bottomSheetActionLeftIcon.isVisible = true + views.bottomSheetActionLeftIcon.setImageDrawable(value) } } var rightIcon: Drawable? = null set(value) { field = value - views.itemVerificationActionIcon.setImageDrawable(value) + views.bottomSheetActionIcon.setImageDrawable(value) } var tint: Int? = null set(value) { field = value - views.itemVerificationLeftIcon.imageTintList = value?.let { ColorStateList.valueOf(value) } + views.bottomSheetActionLeftIcon.imageTintList = value?.let { ColorStateList.valueOf(value) } } var titleTextColor: Int? = null set(value) { field = value - value?.let { views.itemVerificationActionTitle.setTextColor(it) } + value?.let { views.bottomSheetActionTitle.setTextColor(it) } } init { diff --git a/vector/src/main/java/im/vector/app/core/ui/views/CurrentCallsView.kt b/vector/src/main/java/im/vector/app/core/ui/views/CurrentCallsView.kt new file mode 100644 index 0000000000..d1332f18dc --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/ui/views/CurrentCallsView.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.app.core.ui.views + +import android.content.Context +import android.util.AttributeSet +import android.widget.RelativeLayout +import im.vector.app.R +import im.vector.app.databinding.ViewCurrentCallsBinding +import im.vector.app.features.call.webrtc.WebRtcCall +import im.vector.app.features.themes.ThemeUtils +import org.matrix.android.sdk.api.session.call.CallState + +class CurrentCallsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RelativeLayout(context, attrs, defStyleAttr) { + + interface Callback { + fun onTapToReturnToCall() + } + + val views: ViewCurrentCallsBinding + var callback: Callback? = null + + init { + inflate(context, R.layout.view_current_calls, this) + views = ViewCurrentCallsBinding.bind(this) + setBackgroundColor(ThemeUtils.getColor(context, R.attr.colorPrimary)) + setOnClickListener { callback?.onTapToReturnToCall() } + } + + fun render(calls: List, formattedDuration: String) { + val connectedCalls = calls.filter { + it.mxCall.state is CallState.Connected + } + val heldCalls = connectedCalls.filter { + it.isLocalOnHold || it.remoteOnHold + } + if (connectedCalls.isEmpty()) return + views.currentCallsInfo.text = if (connectedCalls.size == heldCalls.size) { + resources.getQuantityString(R.plurals.call_only_paused, heldCalls.size, heldCalls.size) + } else { + if (heldCalls.isEmpty()) { + resources.getString(R.string.call_only_active, formattedDuration) + } else { + resources.getQuantityString(R.plurals.call_one_active_and_other_paused, heldCalls.size, formattedDuration, heldCalls.size) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/core/ui/views/ActiveCallViewHolder.kt b/vector/src/main/java/im/vector/app/core/ui/views/KnownCallsViewHolder.kt similarity index 58% rename from vector/src/main/java/im/vector/app/core/ui/views/ActiveCallViewHolder.kt rename to vector/src/main/java/im/vector/app/core/ui/views/KnownCallsViewHolder.kt index 43346e583e..628356fcad 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/ActiveCallViewHolder.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/KnownCallsViewHolder.kt @@ -16,42 +16,56 @@ package im.vector.app.core.ui.views -import android.view.View import androidx.cardview.widget.CardView import androidx.core.view.isVisible import im.vector.app.core.utils.DebouncedClickListener -import im.vector.app.features.call.WebRtcPeerConnectionManager import org.matrix.android.sdk.api.session.call.CallState -import org.matrix.android.sdk.api.session.call.EglUtils -import org.matrix.android.sdk.api.session.call.MxCall +import im.vector.app.features.call.utils.EglUtils +import im.vector.app.features.call.webrtc.WebRtcCall import org.webrtc.RendererCommon import org.webrtc.SurfaceViewRenderer -class ActiveCallViewHolder { +class KnownCallsViewHolder { private var activeCallPiP: SurfaceViewRenderer? = null - private var activeCallView: ActiveCallView? = null + private var currentCallsView: CurrentCallsView? = null private var pipWrapper: CardView? = null + private var currentCall: WebRtcCall? = null + private var calls: List = emptyList() private var activeCallPipInitialized = false - fun updateCall(activeCall: MxCall?, webRtcPeerConnectionManager: WebRtcPeerConnectionManager) { - val hasActiveCall = activeCall?.state is CallState.Connected + private val tickListener = object : WebRtcCall.Listener { + override fun onTick(formattedDuration: String) { + currentCallsView?.render(calls, formattedDuration) + } + } + + fun updateCall(currentCall: WebRtcCall?, calls: List) { + activeCallPiP?.let { + this.currentCall?.detachRenderers(listOf(it)) + } + this.currentCall?.removeListener(tickListener) + this.currentCall = currentCall + this.currentCall?.addListener(tickListener) + this.calls = calls + val hasActiveCall = currentCall?.mxCall?.state is CallState.Connected if (hasActiveCall) { - val isVideoCall = activeCall?.isVideoCall == true + val isVideoCall = currentCall?.mxCall?.isVideoCall == true if (isVideoCall) initIfNeeded() - activeCallView?.isVisible = !isVideoCall + currentCallsView?.isVisible = !isVideoCall + currentCallsView?.render(calls, currentCall?.formattedDuration() ?: "") pipWrapper?.isVisible = isVideoCall activeCallPiP?.isVisible = isVideoCall activeCallPiP?.let { - webRtcPeerConnectionManager.attachViewRenderers(null, it, null) + currentCall?.attachViewRenderers(null, it, null) } } else { - activeCallView?.isVisible = false + currentCallsView?.isVisible = false activeCallPiP?.isVisible = false pipWrapper?.isVisible = false activeCallPiP?.let { - webRtcPeerConnectionManager.detachRenderers(listOf(it)) + currentCall?.detachRenderers(listOf(it)) } } } @@ -69,30 +83,31 @@ class ActiveCallViewHolder { } } - fun bind(activeCallPiP: SurfaceViewRenderer, activeCallView: ActiveCallView, pipWrapper: CardView, interactionListener: ActiveCallView.Callback) { + fun bind(activeCallPiP: SurfaceViewRenderer, activeCallView: CurrentCallsView, pipWrapper: CardView, interactionListener: CurrentCallsView.Callback) { this.activeCallPiP = activeCallPiP - this.activeCallView = activeCallView + this.currentCallsView = activeCallView this.pipWrapper = pipWrapper - - this.activeCallView?.callback = interactionListener + this.currentCallsView?.callback = interactionListener pipWrapper.setOnClickListener( - DebouncedClickListener(View.OnClickListener { _ -> + DebouncedClickListener({ interactionListener.onTapToReturnToCall() }) ) + this.currentCall?.addListener(tickListener) } - fun unBind(webRtcPeerConnectionManager: WebRtcPeerConnectionManager) { + fun unBind() { activeCallPiP?.let { - webRtcPeerConnectionManager.detachRenderers(listOf(it)) + currentCall?.detachRenderers(listOf(it)) } if (activeCallPipInitialized) { activeCallPiP?.release() } - this.activeCallView?.callback = null + this.currentCallsView?.callback = null + this.currentCall?.removeListener(tickListener) pipWrapper?.setOnClickListener(null) activeCallPiP = null - activeCallView = null + currentCallsView = null pipWrapper = null } } diff --git a/vector/src/main/java/im/vector/app/core/ui/views/ReadReceiptsView.kt b/vector/src/main/java/im/vector/app/core/ui/views/ReadReceiptsView.kt index 5fb0e5907d..8f8919fd47 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/ReadReceiptsView.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/ReadReceiptsView.kt @@ -56,6 +56,7 @@ class ReadReceiptsView @JvmOverloads constructor( private fun setupView() { inflate(context, R.layout.view_read_receipts, this) + contentDescription = context.getString(R.string.a11y_view_read_receipts) } fun render(readReceipts: List, avatarRenderer: AvatarRenderer, clickListener: OnClickListener) { diff --git a/vector/src/main/java/im/vector/app/core/ui/views/ActiveCallView.kt b/vector/src/main/java/im/vector/app/core/ui/views/RevealPasswordImageView.kt similarity index 58% rename from vector/src/main/java/im/vector/app/core/ui/views/ActiveCallView.kt rename to vector/src/main/java/im/vector/app/core/ui/views/RevealPasswordImageView.kt index 19d1fbb6f6..b4bcecad16 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/ActiveCallView.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/RevealPasswordImageView.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 New Vector Ltd + * Copyright (c) 2021 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,29 +18,26 @@ package im.vector.app.core.ui.views import android.content.Context import android.util.AttributeSet -import android.widget.RelativeLayout +import androidx.appcompat.widget.AppCompatImageView import im.vector.app.R -import im.vector.app.features.themes.ThemeUtils -class ActiveCallView @JvmOverloads constructor( +class RevealPasswordImageView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : RelativeLayout(context, attrs, defStyleAttr) { - - interface Callback { - fun onTapToReturnToCall() - } - - var callback: Callback? = null +) : AppCompatImageView(context, attrs, defStyleAttr) { init { - setupView() + render(false) } - private fun setupView() { - inflate(context, R.layout.view_active_call_view, this) - setBackgroundColor(ThemeUtils.getColor(context, R.attr.colorPrimary)) - setOnClickListener { callback?.onTapToReturnToCall() } + fun render(isPasswordShown: Boolean) { + if (isPasswordShown) { + contentDescription = context.getString(R.string.a11y_hide_password) + setImageResource(R.drawable.ic_eye_closed) + } else { + contentDescription = context.getString(R.string.a11y_show_password) + setImageResource(R.drawable.ic_eye) + } } } diff --git a/vector/src/main/java/im/vector/app/core/ui/views/ShieldImageView.kt b/vector/src/main/java/im/vector/app/core/ui/views/ShieldImageView.kt new file mode 100644 index 0000000000..8b127307ce --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/ui/views/ShieldImageView.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.ui.views + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.DrawableRes +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.view.isVisible +import im.vector.app.R +import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel + +class ShieldImageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatImageView(context, attrs, defStyleAttr) { + + init { + if (isInEditMode) { + render(RoomEncryptionTrustLevel.Trusted) + } + } + + fun render(roomEncryptionTrustLevel: RoomEncryptionTrustLevel?) { + isVisible = roomEncryptionTrustLevel != null + + when (roomEncryptionTrustLevel) { + RoomEncryptionTrustLevel.Default -> { + contentDescription = context.getString(R.string.a11y_trust_level_default) + setImageResource(R.drawable.ic_shield_black) + } + RoomEncryptionTrustLevel.Warning -> { + contentDescription = context.getString(R.string.a11y_trust_level_warning) + setImageResource(R.drawable.ic_shield_warning) + } + RoomEncryptionTrustLevel.Trusted -> { + contentDescription = context.getString(R.string.a11y_trust_level_trusted) + setImageResource(R.drawable.ic_shield_trusted) + } + } + } +} + +@DrawableRes +fun RoomEncryptionTrustLevel.toDrawableRes(): Int { + return when (this) { + RoomEncryptionTrustLevel.Default -> R.drawable.ic_shield_black + RoomEncryptionTrustLevel.Warning -> R.drawable.ic_shield_warning + RoomEncryptionTrustLevel.Trusted -> R.drawable.ic_shield_trusted + } +} diff --git a/vector/src/main/java/im/vector/app/core/utils/CountUpTimer.kt b/vector/src/main/java/im/vector/app/core/utils/CountUpTimer.kt new file mode 100644 index 0000000000..c812d8d644 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/utils/CountUpTimer.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.utils + +import io.reactivex.Observable +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +class CountUpTimer(private val intervalInMs: Long) { + + private val elapsedTime: AtomicLong = AtomicLong() + private val resumed: AtomicBoolean = AtomicBoolean(false) + + private val disposable = Observable.interval(intervalInMs, TimeUnit.MILLISECONDS) + .filter { resumed.get() } + .doOnNext { elapsedTime.addAndGet(intervalInMs) } + .subscribe { + tickListener?.onTick(elapsedTime.get()) + } + + var tickListener: TickListener? = null + + fun elapsedTime(): Long { + return elapsedTime.get() + } + + fun pause() { + resumed.set(false) + } + + fun resume() { + resumed.set(true) + } + + fun stop() { + disposable.dispose() + } + + interface TickListener { + fun onTick(milliseconds: Long) + } +} diff --git a/vector/src/main/java/im/vector/app/core/utils/DataSource.kt b/vector/src/main/java/im/vector/app/core/utils/DataSource.kt index 06bdeb9277..fc4ee330bb 100644 --- a/vector/src/main/java/im/vector/app/core/utils/DataSource.kt +++ b/vector/src/main/java/im/vector/app/core/utils/DataSource.kt @@ -49,7 +49,7 @@ open class BehaviorDataSource(private val defaultValue: T? = null) : MutableD private fun createRelay(): BehaviorRelay { return if (defaultValue == null) { - BehaviorRelay.create() + BehaviorRelay.create() } else { BehaviorRelay.createDefault(defaultValue) } diff --git a/vector/src/main/java/im/vector/app/core/utils/DefaultSubscriber.kt b/vector/src/main/java/im/vector/app/core/utils/DefaultSubscriber.kt index 99c28becbc..a82e5a4e03 100644 --- a/vector/src/main/java/im/vector/app/core/utils/DefaultSubscriber.kt +++ b/vector/src/main/java/im/vector/app/core/utils/DefaultSubscriber.kt @@ -19,12 +19,11 @@ package im.vector.app.core.utils import io.reactivex.Completable import io.reactivex.Single import io.reactivex.disposables.Disposable -import io.reactivex.functions.Consumer import io.reactivex.internal.functions.Functions import timber.log.Timber fun Single.subscribeLogError(): Disposable { - return subscribe(Functions.emptyConsumer(), Consumer { Timber.e(it) }) + return subscribe(Functions.emptyConsumer(), { Timber.e(it) }) } fun Completable.subscribeLogError(): Disposable { diff --git a/vector/src/main/java/im/vector/app/features/auth/PromptFragment.kt b/vector/src/main/java/im/vector/app/features/auth/PromptFragment.kt index 917f60dacb..4bdd54ae45 100644 --- a/vector/src/main/java/im/vector/app/features/auth/PromptFragment.kt +++ b/vector/src/main/java/im/vector/app/features/auth/PromptFragment.kt @@ -87,14 +87,7 @@ class PromptFragment : VectorBaseFragment() { } views.passwordField.showPassword(it.passwordVisible) - - if (it.passwordVisible) { - views.passwordReveal.setImageResource(R.drawable.ic_eye_closed) - views.passwordReveal.contentDescription = getString(R.string.a11y_hide_password) - } else { - views.passwordReveal.setImageResource(R.drawable.ic_eye) - views.passwordReveal.contentDescription = getString(R.string.a11y_show_password) - } + views.passwordReveal.render(it.passwordVisible) if (it.lastErrorCode != null) { when (it.flowType) { diff --git a/vector/src/main/java/im/vector/app/features/auth/ReAuthActivity.kt b/vector/src/main/java/im/vector/app/features/auth/ReAuthActivity.kt index 0385973386..ce23111a95 100644 --- a/vector/src/main/java/im/vector/app/features/auth/ReAuthActivity.kt +++ b/vector/src/main/java/im/vector/app/features/auth/ReAuthActivity.kt @@ -113,7 +113,7 @@ class ReAuthActivity : SimpleFragmentActivity(), ReAuthViewModel.Factory { override fun onResume() { super.onResume() - // It's the only way we have to know if sso falback flow was successful + // It's the only way we have to know if sso fallback flow was successful withState(sharedViewModel) { if (it.ssoFallbackPageWasShown) { Timber.d("## UIA ssoFallbackPageWasShown tentative success") diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/RecyclerViewPresenter.kt b/vector/src/main/java/im/vector/app/features/autocomplete/RecyclerViewPresenter.kt index c4631300f1..7625eb6216 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/RecyclerViewPresenter.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/RecyclerViewPresenter.kt @@ -102,7 +102,7 @@ abstract class RecyclerViewPresenter(context: Context?) : AutocompletePresent return LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) } - private class Observer internal constructor(private val root: DataSetObserver) : RecyclerView.AdapterDataObserver() { + private class Observer constructor(private val root: DataSetObserver) : RecyclerView.AdapterDataObserver() { override fun onChanged() { root.onChanged() } diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt index b2ad0a7688..ecc607f08d 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt @@ -31,7 +31,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary class AutocompleteMemberPresenter @AssistedInject constructor(context: Context, @Assisted val roomId: String, - private val session: Session, + session: Session, private val controller: AutocompleteMemberController ) : RecyclerViewPresenter(context), AutocompleteClickListener { diff --git a/vector/src/main/java/im/vector/app/features/call/CallAudioManager.kt b/vector/src/main/java/im/vector/app/features/call/CallAudioManager.kt deleted file mode 100644 index 82bbaf1d54..0000000000 --- a/vector/src/main/java/im/vector/app/features/call/CallAudioManager.kt +++ /dev/null @@ -1,318 +0,0 @@ -/* - * Copyright (c) 2020 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.call - -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothManager -import android.bluetooth.BluetoothProfile -import android.content.Context -import android.content.pm.PackageManager -import android.media.AudioManager -import androidx.core.content.getSystemService -import im.vector.app.core.services.WiredHeadsetStateReceiver -import org.matrix.android.sdk.api.session.call.CallState -import org.matrix.android.sdk.api.session.call.MxCall -import timber.log.Timber -import java.util.concurrent.Executors - -class CallAudioManager( - val applicationContext: Context, - val configChange: (() -> Unit)? -) { - - enum class SoundDevice { - PHONE, - SPEAKER, - HEADSET, - WIRELESS_HEADSET - } - - // if all calls to audio manager not in the same thread it's not working well. - private val executor = Executors.newSingleThreadExecutor() - - private var audioManager: AudioManager? = null - - private var savedIsSpeakerPhoneOn = false - private var savedIsMicrophoneMute = false - private var savedAudioMode = AudioManager.MODE_NORMAL - - private var connectedBlueToothHeadset: BluetoothProfile? = null - private var wantsBluetoothConnection = false - - private var bluetoothAdapter: BluetoothAdapter? = null - - init { - executor.execute { - audioManager = applicationContext.getSystemService() - } - val bm = applicationContext.getSystemService() - val adapter = bm?.adapter - Timber.d("## VOIP Bluetooth adapter $adapter") - bluetoothAdapter = adapter - adapter?.getProfileProxy(applicationContext, object : BluetoothProfile.ServiceListener { - override fun onServiceDisconnected(profile: Int) { - Timber.d("## VOIP onServiceDisconnected $profile") - if (profile == BluetoothProfile.HEADSET) { - connectedBlueToothHeadset = null - configChange?.invoke() - } - } - - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) { - Timber.d("## VOIP onServiceConnected $profile , proxy:$proxy") - if (profile == BluetoothProfile.HEADSET) { - connectedBlueToothHeadset = proxy - configChange?.invoke() - } - } - }, BluetoothProfile.HEADSET) - } - - private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> - - // Called on the listener to notify if the audio focus for this listener has been changed. - // The |focusChange| value indicates whether the focus was gained, whether the focus was lost, - // and whether that loss is transient, or whether the new focus holder will hold it for an - // unknown amount of time. - Timber.v("## VOIP: Audio focus change $focusChange") - } - - fun startForCall(mxCall: MxCall) { - Timber.v("## VOIP: AudioManager startForCall ${mxCall.callId}") - } - - private fun setupAudioManager(mxCall: MxCall) { - Timber.v("## VOIP: AudioManager setupAudioManager ${mxCall.callId}") - val audioManager = audioManager ?: return - savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn - savedIsMicrophoneMute = audioManager.isMicrophoneMute - savedAudioMode = audioManager.mode - - // Request audio playout focus (without ducking) and install listener for changes in focus. - - // Remove the deprecation forces us to use 2 different method depending on API level - @Suppress("DEPRECATION") val result = audioManager.requestAudioFocus(audioFocusChangeListener, - AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) - if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - Timber.d("## VOIP Audio focus request granted for VOICE_CALL streams") - } else { - Timber.d("## VOIP Audio focus request failed") - } - - // Start by setting MODE_IN_COMMUNICATION as default audio mode. It is - // required to be in this mode when playout and/or recording starts for - // best possible VoIP performance. - audioManager.mode = AudioManager.MODE_IN_COMMUNICATION - - // Always disable microphone mute during a WebRTC call. - setMicrophoneMute(false) - - adjustCurrentSoundDevice(mxCall) - } - - private fun adjustCurrentSoundDevice(mxCall: MxCall) { - val audioManager = audioManager ?: return - executor.execute { - if (mxCall.state == CallState.LocalRinging && !isHeadsetOn()) { - // Always use speaker if incoming call is in ringing state and a headset is not connected - Timber.v("##VOIP: AudioManager default to SPEAKER (it is ringing)") - setCurrentSoundDevice(SoundDevice.SPEAKER) - } else if (mxCall.isVideoCall && !isHeadsetOn()) { - // If there are no headset, start video output in speaker - // (you can't watch the video and have the phone close to your ear) - Timber.v("##VOIP: AudioManager default to speaker ") - setCurrentSoundDevice(SoundDevice.SPEAKER) - } else { - // if a wired headset is plugged, sound will be directed to it - // (can't really force earpiece when headset is plugged) - if (isBluetoothHeadsetConnected(audioManager)) { - Timber.v("##VOIP: AudioManager default to WIRELESS_HEADSET ") - setCurrentSoundDevice(SoundDevice.WIRELESS_HEADSET) - // try now in case already connected? - audioManager.isBluetoothScoOn = true - } else { - Timber.v("##VOIP: AudioManager default to PHONE/HEADSET ") - setCurrentSoundDevice(if (isWiredHeadsetOn()) SoundDevice.HEADSET else SoundDevice.PHONE) - } - } - } - } - - fun onCallConnected(mxCall: MxCall) { - Timber.v("##VOIP: AudioManager call answered, adjusting current sound device") - setupAudioManager(mxCall) - } - - fun getAvailableSoundDevices(): List { - 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() || (audioManager?.let { isBluetoothHeadsetConnected(it) } ?: false) - } - - private fun isWiredHeadsetOn(): Boolean { - @Suppress("DEPRECATION") - return audioManager?.isWiredHeadsetOn ?: false - } - - private fun isBluetoothHeadsetOn(): Boolean { - Timber.v("## VOIP: AudioManager isBluetoothHeadsetOn") - try { - if (connectedBlueToothHeadset == null) return false.also { - Timber.v("## VOIP: AudioManager no connected bluetooth headset") - } - if (audioManager?.isBluetoothScoAvailableOffCall == false) return false.also { - Timber.v("## VOIP: AudioManager isBluetoothScoAvailableOffCall false") - } - return true - } catch (failure: Throwable) { - Timber.e("## VOIP: AudioManager isBluetoothHeadsetOn failure ${failure.localizedMessage}") - return false - } - } - - /** Sets the speaker phone mode. */ - private fun setSpeakerphoneOn(on: Boolean) { - Timber.v("## VOIP: AudioManager setSpeakerphoneOn $on") - val wasOn = audioManager?.isSpeakerphoneOn ?: false - if (wasOn == on) { - return - } - audioManager?.isSpeakerphoneOn = on - } - - /** Sets the microphone mute state. */ - private fun setMicrophoneMute(on: Boolean) { - Timber.v("## VOIP: AudioManager setMicrophoneMute $on") - val wasMuted = audioManager?.isMicrophoneMute ?: false - if (wasMuted == on) { - return - } - audioManager?.isMicrophoneMute = on - } - - /** true if the device has a telephony radio with data - * communication support. */ - private fun isThisPhone(): Boolean { - return applicationContext.packageManager.hasSystemFeature( - PackageManager.FEATURE_TELEPHONY) - } -} diff --git a/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt b/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt index 75b1033098..84658a830c 100644 --- a/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/call/CallControlsBottomSheet.kt @@ -27,6 +27,7 @@ import com.airbnb.mvrx.activityViewModel import im.vector.app.R import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment import im.vector.app.databinding.BottomSheetCallControlsBinding +import im.vector.app.features.call.audio.CallAudioManager import me.gujun.android.span.span @@ -44,20 +45,34 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment { @@ -69,22 +84,22 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment, current: CallAudioManager.SoundDevice) { + private fun showSoundDeviceChooser(available: Set, current: CallAudioManager.Device) { val soundDevices = available.map { when (it) { - CallAudioManager.SoundDevice.WIRELESS_HEADSET -> span { + CallAudioManager.Device.WIRELESS_HEADSET -> span { text = getString(R.string.sound_device_wireless_headset) textStyle = if (current == it) "bold" else "normal" } - CallAudioManager.SoundDevice.PHONE -> span { + CallAudioManager.Device.PHONE -> span { text = getString(R.string.sound_device_phone) textStyle = if (current == it) "bold" else "normal" } - CallAudioManager.SoundDevice.SPEAKER -> span { + CallAudioManager.Device.SPEAKER -> span { text = getString(R.string.sound_device_speaker) textStyle = if (current == it) "bold" else "normal" } - CallAudioManager.SoundDevice.HEADSET -> span { + CallAudioManager.Device.HEADSET -> span { text = getString(R.string.sound_device_headset) textStyle = if (current == it) "bold" else "normal" } @@ -95,17 +110,17 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment { - callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.PHONE)) + getString(R.string.sound_device_phone) -> { + callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.Device.PHONE)) } - getString(R.string.sound_device_speaker) -> { - callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.SPEAKER)) + getString(R.string.sound_device_speaker) -> { + callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.Device.SPEAKER)) } - getString(R.string.sound_device_headset) -> { - callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.HEADSET)) + getString(R.string.sound_device_headset) -> { + callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.Device.HEADSET)) } getString(R.string.sound_device_wireless_headset) -> { - callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.SoundDevice.WIRELESS_HEADSET)) + callViewModel.handle(VectorCallViewActions.ChangeAudioDevice(CallAudioManager.Device.WIRELESS_HEADSET)) } } } @@ -115,11 +130,11 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment 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) + views.callControlsSoundDevice.subTitle = when (state.device) { + CallAudioManager.Device.PHONE -> getString(R.string.sound_device_phone) + CallAudioManager.Device.SPEAKER -> getString(R.string.sound_device_speaker) + CallAudioManager.Device.HEADSET -> getString(R.string.sound_device_headset) + CallAudioManager.Device.WIRELESS_HEADSET -> getString(R.string.sound_device_wireless_headset) } views.callControlsSwitchCamera.isVisible = state.isVideoCall && state.canSwitchCamera @@ -139,5 +154,15 @@ class CallControlsBottomSheet : VectorBaseBottomSheetDialogFragment { - if (callState.iceConnectionState == PeerConnection.PeerConnectionState.CONNECTED) { + if (callState.iceConnectionState == MxPeerConnectionState.CONNECTED) { views.ringingControls.isVisible = false views.connectedControls.isVisible = true views.videoToggleIcon.isVisible = state.isVideoCall diff --git a/vector/src/main/java/im/vector/app/features/call/DialerChoiceBottomSheet.kt b/vector/src/main/java/im/vector/app/features/call/DialerChoiceBottomSheet.kt new file mode 100644 index 0000000000..401b3e23d7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/DialerChoiceBottomSheet.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment +import im.vector.app.databinding.BottomSheetCallDialerChoiceBinding + +class DialerChoiceBottomSheet : VectorBaseBottomSheetDialogFragment() { + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetCallDialerChoiceBinding { + return BottomSheetCallDialerChoiceBinding.inflate(inflater, container, false) + } + + var onDialPadClicked: (() -> Unit)? = null + var onVoiceCallClicked: (() -> Unit)? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + views.dialerChoiceDialPad.views.bottomSheetActionClickableZone.debouncedClicks { + onDialPadClicked?.invoke() + dismiss() + } + + views.dialerChoiceVoiceCall.views.bottomSheetActionClickableZone.debouncedClicks { + onVoiceCallClicked?.invoke() + dismiss() + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/SharedActiveCallViewModel.kt b/vector/src/main/java/im/vector/app/features/call/SharedActiveCallViewModel.kt deleted file mode 100644 index f2414f0a22..0000000000 --- a/vector/src/main/java/im/vector/app/features/call/SharedActiveCallViewModel.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2020 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.call - -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import org.matrix.android.sdk.api.session.call.MxCall -import javax.inject.Inject - -class SharedActiveCallViewModel @Inject constructor( - private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager -) : ViewModel() { - - val activeCall: MutableLiveData = 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/app/features/call/SharedKnownCallsViewModel.kt b/vector/src/main/java/im/vector/app/features/call/SharedKnownCallsViewModel.kt new file mode 100644 index 0000000000..b33edd09e0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/SharedKnownCallsViewModel.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import im.vector.app.features.call.webrtc.WebRtcCall +import im.vector.app.features.call.webrtc.WebRtcCallManager +import org.matrix.android.sdk.api.session.call.MxCall +import javax.inject.Inject + +class SharedKnownCallsViewModel @Inject constructor( + private val callManager: WebRtcCallManager +) : ViewModel() { + + val liveKnownCalls: MutableLiveData> = MutableLiveData() + + val callListener = object : WebRtcCall.Listener { + + override fun onStateUpdate(call: MxCall) { + // post it-self + liveKnownCalls.postValue(liveKnownCalls.value) + } + + override fun onHoldUnhold() { + super.onHoldUnhold() + // post it-self + liveKnownCalls.postValue(liveKnownCalls.value) + } + } + + private val currentCallListener = object : WebRtcCallManager.CurrentCallListener { + override fun onCurrentCallChange(call: WebRtcCall?) { + val knownCalls = callManager.getCalls() + liveKnownCalls.postValue(knownCalls) + knownCalls.forEach { + it.removeListener(callListener) + it.addListener(callListener) + } + } + } + + init { + val knownCalls = callManager.getCalls() + liveKnownCalls.postValue(knownCalls) + callManager.addCurrentCallListener(currentCallListener) + knownCalls.forEach { + it.addListener(callListener) + } + } + + override fun onCleared() { + callManager.getCalls().forEach { + it.removeListener(callListener) + } + callManager.removeCurrentCallListener(currentCallListener) + super.onCleared() + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt index 6c49d4d3e2..091dbeec24 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt @@ -20,54 +20,52 @@ import android.app.KeyguardManager import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP +import android.graphics.Color import android.os.Build import android.os.Bundle import android.os.Parcelable import android.view.View -import android.view.Window -import android.view.WindowInsets -import android.view.WindowInsetsController import android.view.WindowManager import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.content.getSystemService -import androidx.core.view.ViewCompat import androidx.core.view.isInvisible import androidx.core.view.isVisible -import androidx.core.view.updatePadding import com.airbnb.mvrx.Fail import com.airbnb.mvrx.MvRx import com.airbnb.mvrx.viewModel -import com.jakewharton.rxbinding3.view.clicks +import com.airbnb.mvrx.withState import im.vector.app.R import im.vector.app.core.di.ScreenComponent import im.vector.app.core.platform.VectorBaseActivity -import im.vector.app.core.services.CallService import im.vector.app.core.utils.PERMISSIONS_FOR_AUDIO_IP_CALL import im.vector.app.core.utils.PERMISSIONS_FOR_VIDEO_IP_CALL import im.vector.app.core.utils.allGranted import im.vector.app.core.utils.checkPermissions import im.vector.app.databinding.ActivityCallBinding +import im.vector.app.features.call.dialpad.CallDialPadBottomSheet +import im.vector.app.features.call.dialpad.DialPadFragment +import im.vector.app.features.call.utils.EglUtils +import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.home.room.detail.RoomDetailArgs import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.call.CallState -import org.matrix.android.sdk.api.session.call.EglUtils import org.matrix.android.sdk.api.session.call.MxCallDetail +import org.matrix.android.sdk.api.session.call.MxPeerConnectionState import org.matrix.android.sdk.api.session.call.TurnServerResponse import org.webrtc.EglBase -import org.webrtc.PeerConnection import org.webrtc.RendererCommon import timber.log.Timber -import java.util.concurrent.TimeUnit import javax.inject.Inject @Parcelize data class CallArgs( val roomId: String, - val callId: String?, + val callId: String, val participantUserId: String, val isIncomingCall: Boolean, val isVideoCall: Boolean @@ -87,101 +85,36 @@ class VectorCallActivity : VectorBaseActivity(), CallContro private val callViewModel: VectorCallViewModel by viewModel() private lateinit var callArgs: CallArgs - @Inject lateinit var peerConnectionManager: WebRtcPeerConnectionManager - + @Inject lateinit var callManager: WebRtcCallManager @Inject lateinit var viewModelFactory: VectorCallViewModel.Factory - private var rootEglBase: EglBase? = null + private val dialPadCallback = object : DialPadFragment.Callback { + override fun onDigitAppended(digit: String) { + callViewModel.handle(VectorCallViewActions.SendDtmfDigit(digit)) + } + } - var systemUiVisibility = false + private var rootEglBase: EglBase? = null var surfaceRenderersAreInitialized = false 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) } - @Suppress("DEPRECATION") - private fun hideSystemUI() { - systemUiVisibility = false - // Enables regular immersive mode. - // For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE. - // Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // New API instead of SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN and SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - window.setDecorFitsSystemWindows(false) - // New API instead of SYSTEM_UI_FLAG_HIDE_NAVIGATION - window.decorView.windowInsetsController?.hide(WindowInsets.Type.navigationBars()) - // New API instead of SYSTEM_UI_FLAG_IMMERSIVE - window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE - // New API instead of FLAG_TRANSLUCENT_STATUS - window.statusBarColor = ContextCompat.getColor(this, R.color.half_transparent_status_bar) - // New API instead of FLAG_TRANSLUCENT_NAVIGATION - window.navigationBarColor = ContextCompat.getColor(this, R.color.half_transparent_status_bar) - } else { - window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE - // Set the content to appear under the system bars so that the - // content doesn't resize when the system bars hide and show. - or View.SYSTEM_UI_FLAG_LAYOUT_STABLE - or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - // Hide the nav bar and status bar - or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_FULLSCREEN) - } - } - - // Shows the system bars by removing all the flags -// except for the ones that make the content appear under the system bars. - @Suppress("DEPRECATION") - private fun showSystemUI() { - systemUiVisibility = true - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // New API instead of SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN and SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - window.setDecorFitsSystemWindows(false) - } else { - window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE - or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) - } - } - - private fun toggleUiSystemVisibility() { - if (systemUiVisibility) { - hideSystemUI() - } else { - showSystemUI() - } - } - - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - // Rehide when bottom sheet is dismissed - if (hasFocus) { - hideSystemUI() - } - } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // This will need to be refined - ViewCompat.setOnApplyWindowInsetsListener(views.constraintLayout) { v, insets -> - v.updatePadding(bottom = if (systemUiVisibility) insets.systemWindowInsetBottom else 0) - insets - } - + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window.statusBarColor = Color.TRANSPARENT + window.navigationBarColor = Color.BLACK + super.onCreate(savedInstanceState) if (intent.hasExtra(MvRx.KEY_ARG)) { callArgs = intent.getParcelableExtra(MvRx.KEY_ARG)!! } else { Timber.e("## VOIP missing callArgs for VectorCall Activity") - CallService.onNoActiveCall(this) finish() } @@ -189,13 +122,9 @@ class VectorCallActivity : VectorBaseActivity(), CallContro if (intent.getStringExtra(EXTRA_MODE) == INCOMING_RINGING) { turnScreenOnAndKeyguardOff() } - - views.constraintLayout.clicks() - .throttleFirst(300, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { toggleUiSystemVisibility() } - .disposeOnDestroy() - + if (savedInstanceState != null) { + (supportFragmentManager.findFragmentByTag(FRAGMENT_DIAL_PAD_TAG) as? CallDialPadBottomSheet)?.callback = dialPadCallback + } configureCallViews() callViewModel.subscribe(this) { @@ -222,7 +151,7 @@ class VectorCallActivity : VectorBaseActivity(), CallContro } override fun onDestroy() { - peerConnectionManager.detachRenderers(listOf(views.pipRenderer, views.fullscreenRenderer)) + callManager.getCallById(callArgs.callId)?.detachRenderers(listOf(views.pipRenderer, views.fullscreenRenderer)) if (surfaceRenderersAreInitialized) { views.pipRenderer.release() views.fullscreenRenderer.release() @@ -234,8 +163,6 @@ class VectorCallActivity : VectorBaseActivity(), CallContro 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 } @@ -243,9 +170,13 @@ class VectorCallActivity : VectorBaseActivity(), CallContro views.callControlsView.updateForState(state) val callState = state.callState.invoke() views.callConnectingProgress.isVisible = false + views.callActionText.setOnClickListener(null) + views.callActionText.isVisible = false + views.smallIsHeldIcon.isVisible = false when (callState) { is CallState.Idle, - is CallState.Dialing -> { + is CallState.CreateOffer, + is CallState.Dialing -> { views.callVideoGroup.isInvisible = true views.callInfoGroup.isVisible = true views.callStatusText.setText(R.string.call_ring) @@ -259,24 +190,42 @@ class VectorCallActivity : VectorBaseActivity(), CallContro configureCallInfo(state) } - is CallState.Answering -> { + is CallState.Answering -> { views.callVideoGroup.isInvisible = true views.callInfoGroup.isVisible = true views.callStatusText.setText(R.string.call_connecting) views.callConnectingProgress.isVisible = true configureCallInfo(state) } - is CallState.Connected -> { - if (callState.iceConnectionState == PeerConnection.PeerConnectionState.CONNECTED) { - if (callArgs.isVideoCall) { - views.callVideoGroup.isVisible = true - views.callInfoGroup.isVisible = false - views.pipRenderer.isVisible = !state.isVideoCaptureInError - } else { + is CallState.Connected -> { + if (callState.iceConnectionState == MxPeerConnectionState.CONNECTED) { + if (state.isLocalOnHold || state.isRemoteOnHold) { + views.smallIsHeldIcon.isVisible = true views.callVideoGroup.isInvisible = true views.callInfoGroup.isVisible = true + configureCallInfo(state, blurAvatar = true) + if (state.isRemoteOnHold) { + views.callActionText.setText(R.string.call_resume_action) + views.callActionText.isVisible = true + views.callActionText.setOnClickListener { callViewModel.handle(VectorCallViewActions.ToggleHoldResume) } + views.callStatusText.setText(R.string.call_held_by_you) + } else { + views.callActionText.isInvisible = true + state.callInfo.otherUserItem?.let { + views.callStatusText.text = getString(R.string.call_held_by_user, it.getBestName()) + } + } + } else { + views.callStatusText.text = state.formattedDuration configureCallInfo(state) - views.callStatusText.text = null + if (callArgs.isVideoCall) { + views.callVideoGroup.isVisible = true + views.callInfoGroup.isVisible = false + views.pipRenderer.isVisible = !state.isVideoCaptureInError && state.otherKnownCallInfo == null + } else { + views.callVideoGroup.isInvisible = true + views.callInfoGroup.isVisible = true + } } } else { // This state is not final, if you change network, new candidates will be sent @@ -286,27 +235,52 @@ class VectorCallActivity : VectorBaseActivity(), CallContro views.callStatusText.setText(R.string.call_connecting) views.callConnectingProgress.isVisible = true } - // ensure all attached? - peerConnectionManager.attachViewRenderers(views.pipRenderer, views.fullscreenRenderer, null) } - is CallState.Terminated -> { + is CallState.Terminated -> { finish() } - null -> { + null -> { } } } - private fun configureCallInfo(state: VectorCallViewState) { - state.otherUserMatrixItem.invoke()?.let { - avatarRenderer.render(it, views.otherMemberAvatar) + private fun configureCallInfo(state: VectorCallViewState, blurAvatar: Boolean = false) { + state.callInfo.otherUserItem?.let { + val colorFilter = ContextCompat.getColor(this, R.color.bg_call_screen) + avatarRenderer.renderBlur(it, views.bgCallView, sampling = 20, rounded = false, colorFilter = colorFilter) views.participantNameText.text = it.getBestName() - views.callTypeText.setText(if (state.isVideoCall) R.string.action_video_call else R.string.action_voice_call) + if (blurAvatar) { + avatarRenderer.renderBlur(it, views.otherMemberAvatar, sampling = 2, rounded = true, colorFilter = colorFilter) + } else { + avatarRenderer.render(it, views.otherMemberAvatar) + } + } + if (state.otherKnownCallInfo?.otherUserItem == null) { + views.otherKnownCallLayout.isVisible = false + } else { + val otherCall = callManager.getCallById(state.otherKnownCallInfo.callId) + val colorFilter = ContextCompat.getColor(this, R.color.bg_call_screen) + avatarRenderer.renderBlur( + matrixItem = state.otherKnownCallInfo.otherUserItem, + imageView = views.otherKnownCallAvatarView, + sampling = 20, + rounded = false, + colorFilter = colorFilter + ) + views.otherKnownCallLayout.isVisible = true + views.otherSmallIsHeldIcon.isVisible = otherCall?.let { it.isLocalOnHold || it.remoteOnHold }.orFalse() } } private fun configureCallViews() { views.callControlsView.interactionListener = this + views.otherKnownCallAvatarView.setOnClickListener { + withState(callViewModel) { + val otherCall = callManager.getCallById(it.otherKnownCallInfo?.callId ?: "") ?: return@withState + startActivity(newIntent(this, otherCall.mxCall, null)) + finish() + } + } } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { @@ -331,17 +305,14 @@ class VectorCallActivity : VectorBaseActivity(), CallContro // Init Full Screen renderer views.fullscreenRenderer.init(rootEglBase!!.eglBaseContext, null) - views.fullscreenRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT) + views.fullscreenRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL) views.pipRenderer.setZOrderMediaOverlay(true) views.pipRenderer.setEnableHardwareScaler(true /* enabled */) views.fullscreenRenderer.setEnableHardwareScaler(true /* enabled */) - peerConnectionManager.attachViewRenderers( - views.pipRenderer, - views.fullscreenRenderer, - intent.getStringExtra(EXTRA_MODE)?.takeIf { isFirstCreation() } - ) + callManager.getCallById(callArgs.callId)?.attachViewRenderers(views.pipRenderer, views.fullscreenRenderer, + intent.getStringExtra(EXTRA_MODE)?.takeIf { isFirstCreation() }) views.pipRenderer.setOnClickListener { callViewModel.handle(VectorCallViewActions.ToggleCamera) @@ -352,14 +323,21 @@ class VectorCallActivity : VectorBaseActivity(), CallContro private fun handleViewEvents(event: VectorCallViewEvents?) { Timber.v("## VOIP handleViewEvents $event") when (event) { - VectorCallViewEvents.DismissNoCall -> { - CallService.onNoActiveCall(this) + VectorCallViewEvents.DismissNoCall -> { finish() } is VectorCallViewEvents.ConnectionTimeout -> { onErrorTimoutConnect(event.turn) } - null -> { + is VectorCallViewEvents.ShowDialPad -> { + CallDialPadBottomSheet.newInstance(false).apply { + callback = dialPadCallback + }.show(supportFragmentManager, FRAGMENT_DIAL_PAD_TAG) + } + is VectorCallViewEvents.ShowCallTransferScreen -> { + navigator.openCallTransfer(this, callArgs.callId) + } + null -> { } } } @@ -381,22 +359,23 @@ class VectorCallActivity : VectorBaseActivity(), CallContro private const val CAPTURE_PERMISSION_REQUEST_CODE = 1 private const val EXTRA_MODE = "EXTRA_MODE" + private const val FRAGMENT_DIAL_PAD_TAG = "FRAGMENT_DIAL_PAD_TAG" const val OUTGOING_CREATED = "OUTGOING_CREATED" const val INCOMING_RINGING = "INCOMING_RINGING" const val INCOMING_ACCEPT = "INCOMING_ACCEPT" - fun newIntent(context: Context, mxCall: MxCallDetail): Intent { + fun newIntent(context: Context, mxCall: MxCallDetail, mode: String?): Intent { return Intent(context, VectorCallActivity::class.java).apply { // 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) + putExtra(MvRx.KEY_ARG, CallArgs(mxCall.roomId, mxCall.callId, mxCall.opponentUserId, !mxCall.isOutgoing, mxCall.isVideoCall)) + putExtra(EXTRA_MODE, mode) } } fun newIntent(context: Context, - callId: String?, + callId: String, roomId: String, otherUserId: String, isIncomingCall: Boolean, @@ -404,7 +383,7 @@ class VectorCallActivity : VectorBaseActivity(), CallContro mode: String?): Intent { return Intent(context, VectorCallActivity::class.java).apply { // what could be the best flags? - flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + flags = FLAG_ACTIVITY_CLEAR_TOP putExtra(MvRx.KEY_ARG, CallArgs(roomId, callId, otherUserId, isIncomingCall, isVideoCall)) putExtra(EXTRA_MODE, mode) } diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt index 4ca21a0f1d..7addabf724 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt @@ -17,6 +17,7 @@ package im.vector.app.features.call import im.vector.app.core.platform.VectorViewModelAction +import im.vector.app.features.call.audio.CallAudioManager sealed class VectorCallViewActions : VectorViewModelAction { object EndCall : VectorCallViewActions() @@ -24,9 +25,13 @@ sealed class VectorCallViewActions : VectorViewModelAction { object DeclineCall : VectorCallViewActions() object ToggleMute : VectorCallViewActions() object ToggleVideo : VectorCallViewActions() - data class ChangeAudioDevice(val device: CallAudioManager.SoundDevice) : VectorCallViewActions() + object ToggleHoldResume: VectorCallViewActions() + data class ChangeAudioDevice(val device: CallAudioManager.Device) : VectorCallViewActions() + object OpenDialPad: VectorCallViewActions() + data class SendDtmfDigit(val digit: String) : VectorCallViewActions() object SwitchSoundDevice : VectorCallViewActions() object HeadSetButtonPressed : VectorCallViewActions() object ToggleCamera : VectorCallViewActions() object ToggleHDSD : VectorCallViewActions() + object InitiateCallTransfer : VectorCallViewActions() } diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt index b79cd5d772..91c3154d0a 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt @@ -17,6 +17,7 @@ package im.vector.app.features.call import im.vector.app.core.platform.VectorViewEvents +import im.vector.app.features.call.audio.CallAudioManager import org.matrix.android.sdk.api.session.call.TurnServerResponse sealed class VectorCallViewEvents : VectorViewEvents { @@ -24,9 +25,11 @@ sealed class VectorCallViewEvents : VectorViewEvents { object DismissNoCall : VectorCallViewEvents() data class ConnectionTimeout(val turn: TurnServerResponse?) : VectorCallViewEvents() data class ShowSoundDeviceChooser( - val available: List, - val current: CallAudioManager.SoundDevice + val available: Set, + val current: CallAudioManager.Device ) : VectorCallViewEvents() + object ShowDialPad: VectorCallViewEvents() + object ShowCallTransferScreen: VectorCallViewEvents() // data class CallAnswered(val content: CallAnswerContent) : VectorCallViewEvents() // data class CallHangup(val content: CallHangupContent) : VectorCallViewEvents() // object CallAccepted : VectorCallViewEvents() diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt index fd735de085..8a2d56a5a2 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt @@ -16,254 +16,277 @@ package im.vector.app.features.call +import androidx.lifecycle.viewModelScope import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Loading import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.Success -import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.ViewModelContext import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.assisted.AssistedFactory import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorViewModel -import org.matrix.android.sdk.api.MatrixCallback +import im.vector.app.features.call.audio.CallAudioManager +import im.vector.app.features.call.webrtc.WebRtcCall +import im.vector.app.features.call.webrtc.WebRtcCallManager +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.MxCall -import org.matrix.android.sdk.api.session.call.TurnServerResponse +import org.matrix.android.sdk.api.session.call.MxPeerConnectionState +import org.matrix.android.sdk.api.session.room.model.call.supportCallTransfer import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.toMatrixItem -import org.webrtc.PeerConnection -import java.util.Timer -import java.util.TimerTask class VectorCallViewModel @AssistedInject constructor( @Assisted initialState: VectorCallViewState, - @Assisted val args: CallArgs, val session: Session, - val webRtcPeerConnectionManager: WebRtcPeerConnectionManager, + val callManager: WebRtcCallManager, val proximityManager: CallProximityManager ) : VectorViewModel(initialState) { - private var call: MxCall? = null + private var call: WebRtcCall? = null - private var connectionTimeoutTimer: Timer? = null + private var connectionTimeoutJob: Job? = null private 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 - connectionTimeoutTimer?.cancel() - connectionTimeoutTimer = null - } else { - // do we reset as long as it's moving? - connectionTimeoutTimer?.cancel() - if (hasBeenConnectedOnce) { - connectionTimeoutTimer = Timer().apply { - schedule(object : TimerTask() { - override fun run() { - session.callSignalingService().getTurnServer(object : MatrixCallback { - override fun onFailure(failure: Throwable) { - _viewEvents.post(VectorCallViewEvents.ConnectionTimeout(null)) - } + private val callListener = object : WebRtcCall.Listener { - override fun onSuccess(data: TurnServerResponse) { - _viewEvents.post(VectorCallViewEvents.ConnectionTimeout(data)) - } - }) - } - }, 30_000) - } - } - } + override fun onHoldUnhold() { setState { copy( - callState = Success(callState) + isLocalOnHold = call?.isLocalOnHold ?: false, + isRemoteOnHold = call?.remoteOnHold ?: false ) } } - } - - private val currentCallListener = object : WebRtcPeerConnectionManager.CurrentCallListener { - override fun onCurrentCallChange(call: MxCall?) { - // we need to check the state - if (call == null) { - // we should dismiss, e.g handled by other session? - _viewEvents.post(VectorCallViewEvents.DismissNoCall) - } - } override fun onCaptureStateChanged() { setState { copy( - isVideoCaptureInError = webRtcPeerConnectionManager.capturerIsInError, - isHD = webRtcPeerConnectionManager.currentCaptureFormat() is CaptureFormat.HD + isVideoCaptureInError = call?.videoCapturerIsInError ?: false, + isHD = call?.currentCaptureFormat() is CaptureFormat.HD ) } } + override fun onCameraChanged() { + setState { + copy( + canSwitchCamera = call?.canSwitchCamera() ?: false, + isFrontCamera = call?.currentCameraType() == CameraType.FRONT + ) + } + } + + override fun onTick(formattedDuration: String) { + setState { + copy(formattedDuration = formattedDuration) + } + } + + override fun onStateUpdate(call: MxCall) { + val callState = call.state + if (callState is CallState.Connected && callState.iceConnectionState == MxPeerConnectionState.CONNECTED) { + hasBeenConnectedOnce = true + connectionTimeoutJob?.cancel() + connectionTimeoutJob = null + } else { + // do we reset as long as it's moving? + connectionTimeoutJob?.cancel() + if (hasBeenConnectedOnce) { + connectionTimeoutJob = viewModelScope.launch { + delay(30_000) + try { + val turn = session.callSignalingService().getTurnServer() + _viewEvents.post(VectorCallViewEvents.ConnectionTimeout(turn)) + } catch (failure: Throwable) { + _viewEvents.post(VectorCallViewEvents.ConnectionTimeout(null)) + } + } + } + } + setState { + copy( + callState = Success(callState), + canOpponentBeTransferred = call.capabilities.supportCallTransfer() + ) + } + } + } + + private val currentCallListener = object : WebRtcCallManager.CurrentCallListener { + + override fun onCurrentCallChange(call: WebRtcCall?) { + if (call == null) { + _viewEvents.post(VectorCallViewEvents.DismissNoCall) + } else { + updateOtherKnownCall(call) + } + } + override fun onAudioDevicesChange() { - val currentSoundDevice = webRtcPeerConnectionManager.callAudioManager.getCurrentSoundDevice() - if (currentSoundDevice == CallAudioManager.SoundDevice.PHONE) { + val currentSoundDevice = callManager.audioManager.selectedDevice ?: return + if (currentSoundDevice == CallAudioManager.Device.PHONE) { proximityManager.start() } else { proximityManager.stop() } - setState { copy( - availableSoundDevices = webRtcPeerConnectionManager.callAudioManager.getAvailableSoundDevices(), - soundDevice = currentSoundDevice + availableDevices = callManager.audioManager.availableDevices, + device = currentSoundDevice ) } } + } - override fun onCameraChange() { - setState { - copy( - canSwitchCamera = webRtcPeerConnectionManager.canSwitchCamera(), - isFrontCamera = webRtcPeerConnectionManager.currentCameraType() == CameraType.FRONT - ) + private fun updateOtherKnownCall(currentCall: WebRtcCall) { + val otherCall = callManager.getCalls().firstOrNull { + it.callId != currentCall.callId && it.mxCall.state is CallState.Connected + } + setState { + if (otherCall == null) { + copy(otherKnownCallInfo = null) + } else { + val otherUserItem: MatrixItem? = session.getUser(otherCall.mxCall.opponentUserId)?.toMatrixItem() + copy(otherKnownCallInfo = VectorCallViewState.CallInfo(otherCall.callId, otherUserItem)) } } } init { - initialState.callId?.let { - webRtcPeerConnectionManager.addCurrentCallListener(currentCallListener) - - session.callSignalingService().getCallWithId(it)?.let { mxCall -> - this.call = mxCall - mxCall.otherUserId - val item: MatrixItem? = session.getRoomMember(mxCall.otherUserId, mxCall.roomId)?.toMatrixItem() - - mxCall.addListener(callStateListener) - - val currentSoundDevice = webRtcPeerConnectionManager.callAudioManager.getCurrentSoundDevice() - if (currentSoundDevice == CallAudioManager.SoundDevice.PHONE) { - proximityManager.start() - } - - setState { - copy( - isVideoCall = mxCall.isVideoCall, - callState = Success(mxCall.state), - otherUserMatrixItem = item?.let { Success(it) } ?: Uninitialized, - soundDevice = currentSoundDevice, - availableSoundDevices = webRtcPeerConnectionManager.callAudioManager.getAvailableSoundDevices(), - isFrontCamera = webRtcPeerConnectionManager.currentCameraType() == CameraType.FRONT, - canSwitchCamera = webRtcPeerConnectionManager.canSwitchCamera(), - isHD = mxCall.isVideoCall && webRtcPeerConnectionManager.currentCaptureFormat() is CaptureFormat.HD - ) - } - } ?: run { - setState { - copy( - callState = Fail(IllegalArgumentException("No call")) - ) - } + val webRtcCall = callManager.getCallById(initialState.callId) + if (webRtcCall == null) { + setState { + copy(callState = Fail(IllegalArgumentException("No call"))) } + } else { + call = webRtcCall + callManager.addCurrentCallListener(currentCallListener) + val item: MatrixItem? = session.getUser(webRtcCall.mxCall.opponentUserId)?.toMatrixItem() + webRtcCall.addListener(callListener) + val currentSoundDevice = callManager.audioManager.selectedDevice + if (currentSoundDevice == CallAudioManager.Device.PHONE) { + proximityManager.start() + } + setState { + copy( + isVideoCall = webRtcCall.mxCall.isVideoCall, + callState = Success(webRtcCall.mxCall.state), + callInfo = VectorCallViewState.CallInfo(callId, item), + device = currentSoundDevice ?: CallAudioManager.Device.PHONE, + isLocalOnHold = webRtcCall.isLocalOnHold, + isRemoteOnHold = webRtcCall.remoteOnHold, + availableDevices = callManager.audioManager.availableDevices, + isFrontCamera = webRtcCall.currentCameraType() == CameraType.FRONT, + canSwitchCamera = webRtcCall.canSwitchCamera(), + formattedDuration = webRtcCall.formattedDuration(), + isHD = webRtcCall.mxCall.isVideoCall && webRtcCall.currentCaptureFormat() is CaptureFormat.HD, + canOpponentBeTransferred = webRtcCall.mxCall.capabilities.supportCallTransfer() + ) + } + updateOtherKnownCall(webRtcCall) } } override fun onCleared() { - // session.callService().removeCallListener(callServiceListener) - webRtcPeerConnectionManager.removeCurrentCallListener(currentCallListener) - this.call?.removeListener(callStateListener) + callManager.removeCurrentCallListener(currentCallListener) + call?.removeListener(callListener) proximityManager.stop() super.onCleared() } override fun handle(action: VectorCallViewActions) = withState { state -> when (action) { - VectorCallViewActions.EndCall -> webRtcPeerConnectionManager.endCall() - VectorCallViewActions.AcceptCall -> { + VectorCallViewActions.EndCall -> call?.endCall() + VectorCallViewActions.AcceptCall -> { setState { copy(callState = Loading()) } - webRtcPeerConnectionManager.acceptIncomingCall() + call?.acceptIncomingCall() } - VectorCallViewActions.DeclineCall -> { + VectorCallViewActions.DeclineCall -> { setState { copy(callState = Loading()) } - webRtcPeerConnectionManager.endCall() + call?.endCall() } - VectorCallViewActions.ToggleMute -> { + VectorCallViewActions.ToggleMute -> { val muted = state.isAudioMuted - webRtcPeerConnectionManager.muteCall(!muted) + call?.muteCall(!muted) setState { copy(isAudioMuted = !muted) } } - VectorCallViewActions.ToggleVideo -> { + VectorCallViewActions.ToggleVideo -> { if (state.isVideoCall) { val videoEnabled = state.isVideoEnabled - webRtcPeerConnectionManager.enableVideo(!videoEnabled) + call?.enableVideo(!videoEnabled) setState { copy(isVideoEnabled = !videoEnabled) } } Unit } - is VectorCallViewActions.ChangeAudioDevice -> { - webRtcPeerConnectionManager.callAudioManager.setCurrentSoundDevice(action.device) - setState { - copy( - soundDevice = webRtcPeerConnectionManager.callAudioManager.getCurrentSoundDevice() - ) - } + VectorCallViewActions.ToggleHoldResume -> { + val isRemoteOnHold = state.isRemoteOnHold + call?.updateRemoteOnHold(!isRemoteOnHold) } - VectorCallViewActions.SwitchSoundDevice -> { + is VectorCallViewActions.ChangeAudioDevice -> { + callManager.audioManager.setAudioDevice(action.device) + } + VectorCallViewActions.SwitchSoundDevice -> { _viewEvents.post( - VectorCallViewEvents.ShowSoundDeviceChooser(state.availableSoundDevices, state.soundDevice) + VectorCallViewEvents.ShowSoundDeviceChooser(state.availableDevices, state.device) ) } VectorCallViewActions.HeadSetButtonPressed -> { if (state.callState.invoke() is CallState.LocalRinging) { // accept call - webRtcPeerConnectionManager.acceptIncomingCall() + call?.acceptIncomingCall() } if (state.callState.invoke() is CallState.Connected) { // end call? - webRtcPeerConnectionManager.endCall() + call?.endCall() } Unit } - VectorCallViewActions.ToggleCamera -> { - webRtcPeerConnectionManager.switchCamera() + VectorCallViewActions.ToggleCamera -> { + call?.switchCamera() } - VectorCallViewActions.ToggleHDSD -> { + VectorCallViewActions.ToggleHDSD -> { if (!state.isVideoCall) return@withState - webRtcPeerConnectionManager.setCaptureFormat(if (state.isHD) CaptureFormat.SD else CaptureFormat.HD) + call?.setCaptureFormat(if (state.isHD) CaptureFormat.SD else CaptureFormat.HD) + } + VectorCallViewActions.OpenDialPad -> { + _viewEvents.post(VectorCallViewEvents.ShowDialPad) + } + is VectorCallViewActions.SendDtmfDigit -> { + call?.sendDtmfDigit(action.digit) + } + VectorCallViewActions.InitiateCallTransfer -> { + _viewEvents.post( + VectorCallViewEvents.ShowCallTransferScreen + ) } }.exhaustive } @AssistedFactory interface Factory { - fun create(initialState: VectorCallViewState, args: CallArgs): VectorCallViewModel + fun create(initialState: VectorCallViewState): VectorCallViewModel } companion object : MvRxViewModelFactory { @JvmStatic - override fun create(viewModelContext: ViewModelContext, state: VectorCallViewState): VectorCallViewModel? { + 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 - ) + return callActivity.viewModelFactory.create(state) } } } diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewState.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewState.kt index f24e810400..cdd002114a 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewState.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewState.kt @@ -19,21 +19,39 @@ package im.vector.app.features.call import com.airbnb.mvrx.Async import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.Uninitialized +import im.vector.app.features.call.audio.CallAudioManager import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.util.MatrixItem data class VectorCallViewState( - val callId: String? = null, - val roomId: String = "", + val callId: String, + val roomId: String, val isVideoCall: Boolean, + val isRemoteOnHold: Boolean = false, + val isLocalOnHold: Boolean = false, 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 + val device: CallAudioManager.Device = CallAudioManager.Device.PHONE, + val availableDevices: Set = emptySet(), + val callState: Async = Uninitialized, + val otherKnownCallInfo: CallInfo? = null, + val callInfo: CallInfo = CallInfo(callId), + val formattedDuration: String = "", + val canOpponentBeTransferred: Boolean = false +) : MvRxState { + + data class CallInfo( + val callId: String, + val otherUserItem: MatrixItem? = null + ) + + constructor(callArgs: CallArgs): this( + callId = callArgs.callId, + roomId = callArgs.roomId, + isVideoCall = callArgs.isVideoCall + ) +} diff --git a/vector/src/main/java/im/vector/app/features/call/WebRtcPeerConnectionManager.kt b/vector/src/main/java/im/vector/app/features/call/WebRtcPeerConnectionManager.kt deleted file mode 100644 index 5bc87ed1d1..0000000000 --- a/vector/src/main/java/im/vector/app/features/call/WebRtcPeerConnectionManager.kt +++ /dev/null @@ -1,1088 +0,0 @@ -/* - * Copyright (c) 2020 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.call - -import android.content.Context -import android.hardware.camera2.CameraManager -import androidx.core.content.getSystemService -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.OnLifecycleEvent -import im.vector.app.ActiveSessionDataSource -import im.vector.app.core.services.BluetoothHeadsetReceiver -import im.vector.app.core.services.CallService -import im.vector.app.core.services.WiredHeadsetStateReceiver -import im.vector.app.push.fcm.FcmHelper -import io.reactivex.disposables.Disposable -import io.reactivex.subjects.PublishSubject -import io.reactivex.subjects.ReplaySubject -import org.matrix.android.sdk.api.MatrixCallback -import org.matrix.android.sdk.api.extensions.tryOrNull -import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.call.CallState -import org.matrix.android.sdk.api.session.call.CallsListener -import org.matrix.android.sdk.api.session.call.EglUtils -import org.matrix.android.sdk.api.session.call.MxCall -import org.matrix.android.sdk.api.session.call.TurnServerResponse -import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent -import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent -import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent -import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent -import org.matrix.android.sdk.api.util.toMatrixItem -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 activeSessionDataSource: ActiveSessionDataSource -) : CallsListener, LifecycleObserver { - - private val currentSession: Session? - get() = activeSessionDataSource.currentValue?.orNull() - - interface CurrentCallListener { - fun onCurrentCallChange(call: MxCall?) - fun onCaptureStateChanged() {} - fun onAudioDevicesChange() {} - fun onCameraChange() {} - } - - private val currentCallsListeners = emptyList().toMutableList() - fun addCurrentCallListener(listener: CurrentCallListener) { - currentCallsListeners.add(listener) - } - - fun removeCurrentCallListener(listener: CurrentCallListener) { - currentCallsListeners.remove(listener) - } - - val callAudioManager = CallAudioManager(context.applicationContext) { - currentCallsListeners.forEach { - tryOrNull { it.onAudioDevicesChange() } - } - } - - 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 - - private var isInBackground: Boolean = true - - var capturerIsInError = false - set(value) { - field = value - currentCallsListeners.forEach { - tryOrNull { it.onCaptureStateChanged() } - } - } - - 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)) - } - } - - @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) - fun entersForeground() { - isInBackground = false - } - - @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) - fun entersBackground() { - isInBackground = true - } - - var currentCall: CallContext? = null - set(value) { - field = value - currentCallsListeners.forEach { - tryOrNull { 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)) { - currentSession?.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 = currentSession?.getRoomMember(mxCall.otherUserId, mxCall.roomId)?.toMatrixItem()?.getBestName() - ?: mxCall.otherUserId - // Start background service with notification - CallService.onPendingCall( - context = context, - isVideo = mxCall.isVideoCall, - roomName = name, - roomId = mxCall.roomId, - matrixId = currentSession?.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 -> { - // 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 = currentSession?.getRoomMember(mxCall.otherUserId, mxCall.roomId)?.toMatrixItem()?.getBestName() - ?: mxCall.roomId - CallService.onPendingCall( - context = context, - isVideo = mxCall.isVideoCall, - roomName = name, - roomId = mxCall.roomId, - matrixId = currentSession?.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 - val restarter = CameraRestarter(cameraInUse?.name ?: "", callContext.mxCall.callId) - callContext.cameraAvailabilityCallback = restarter - val cameraManager = context.getSystemService()!! - 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(this.cameraInUse?.type == CameraType.FRONT) - // 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 = currentSession?.getRoomMember(mxCall.otherUserId, mxCall.roomId)?.toMatrixItem()?.getBestName() - ?: mxCall.otherUserId - CallService.onOnGoingCallBackground( - context = context, - isVideo = mxCall.isVideoCall, - roomName = name, - roomId = mxCall.roomId, - matrixId = currentSession?.myUserId ?: "", - callId = mxCall.callId - ) - } - } - } - - fun close() { - Timber.v("## VOIP WebRtcPeerConnectionManager close() >") - CallService.onNoActiveCall(context) - callAudioManager.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 = currentSession?.callSignalingService()?.createOutgoingCall(signalingRoomId, otherUserId, isVideoCall) ?: return - val callContext = CallContext(createdCall) - - callAudioManager.startForCall(createdCall) - currentCall = callContext - - val name = currentSession?.getRoomMember(createdCall.otherUserId, createdCall.roomId)?.toMatrixItem()?.getBestName() - ?: createdCall.otherUserId - CallService.onOutgoingCallRinging( - context = context.applicationContext, - isVideo = createdCall.isVideoCall, - roomName = name, - roomId = createdCall.roomId, - matrixId = currentSession?.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 - callAudioManager.startForCall(mxCall) - executor.execute { - callContext.remoteCandidateSource = ReplaySubject.create() - } - - // Start background service with notification - val name = currentSession?.getRoomMember(mxCall.otherUserId, mxCall.roomId)?.toMatrixItem()?.getBestName() - ?: mxCall.otherUserId - CallService.onIncomingCallRinging( - context = context, - isVideo = mxCall.isVideoCall, - roomName = name, - roomId = mxCall.roomId, - matrixId = currentSession?.myUserId ?: "", - callId = mxCall.callId - ) - - callContext.offerSdp = callInviteContent.offer - - // If this is received while in background, the app will not sync, - // and thus won't be able to received events. For example if the call is - // accepted on an other session this device will continue ringing - if (isInBackground) { - if (FcmHelper.isPushSupported()) { - // only for push version as fdroid version is already doing it? - currentSession?.startAutomaticBackgroundSync(30, 0) - } else { - // Maybe increase sync freq? but how to set back to default values? - } - } - } - - 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 } - localSurfaceRenderer.forEach { - it.get()?.setMirror(isFrontCamera) - } - - currentCallsListeners.forEach { - tryOrNull { it.onCameraChange() } - } - } - - 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 { tryOrNull { it.onCaptureStateChanged() } } - } - } - - 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 -> - val cameraManager = context.getSystemService()!! - 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... - callAudioManager.wiredStateChange(event) - } - - fun onWirelessDeviceEvent(event: BluetoothHeadsetReceiver.BTHeadsetPlugEvent) { - Timber.v("## VOIP onWirelessDeviceEvent $event") - callAudioManager.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 = currentSession?.getRoomMember(mxCall.otherUserId, mxCall.roomId)?.toMatrixItem()?.getBestName() - ?: mxCall.otherUserId - CallService.onPendingCall( - context = context, - isVideo = mxCall.isVideoCall, - roomName = name, - roomId = mxCall.roomId, - matrixId = currentSession?.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) - - // did we start background sync? so we should stop it - if (isInBackground) { - if (FcmHelper.isPushSupported()) { - currentSession?.stopAnyBackgroundSync() - } else { - // for fdroid we should not stop, it should continue syncing - // maybe we should restore default timeout/delay though? - } - } - } - - 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) - callAudioManager.onCallConnected(callContext.mxCall) - } - /** - * 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") - } - } - - 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()?.unregisterAvailabilityCallback(this) - } - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/call/audio/API21AudioDeviceDetector.kt b/vector/src/main/java/im/vector/app/features/call/audio/API21AudioDeviceDetector.kt new file mode 100644 index 0000000000..32b243aa2b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/audio/API21AudioDeviceDetector.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("DEPRECATION") + +package im.vector.app.features.call.audio + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.content.Context +import android.media.AudioManager +import androidx.core.content.getSystemService +import im.vector.app.core.services.BluetoothHeadsetReceiver +import im.vector.app.core.services.WiredHeadsetStateReceiver +import timber.log.Timber +import java.util.HashSet + +internal class API21AudioDeviceDetector(private val context: Context, + private val audioManager: AudioManager, + private val callAudioManager: CallAudioManager +) : CallAudioManager.AudioDeviceDetector, WiredHeadsetStateReceiver.HeadsetEventListener, BluetoothHeadsetReceiver.EventListener { + + private var bluetoothAdapter: BluetoothAdapter? = null + private var connectedBlueToothHeadset: BluetoothProfile? = null + private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null + private var bluetoothHeadsetStateReceiver: BluetoothHeadsetReceiver? = null + + private val onAudioDeviceChangeRunner = Runnable { + val devices = getAvailableSoundDevices() + callAudioManager.replaceDevices(devices) + Timber.i(" Available audio devices: $devices") + callAudioManager.updateAudioRoute() + } + + private fun getAvailableSoundDevices(): Set { + return HashSet().apply { + if (isBluetoothHeadsetOn()) add(CallAudioManager.Device.WIRELESS_HEADSET) + if (isWiredHeadsetOn()) { + add(CallAudioManager.Device.HEADSET) + } else { + add(CallAudioManager.Device.PHONE) + } + add(CallAudioManager.Device.SPEAKER) + } + } + + private fun isWiredHeadsetOn(): Boolean { + return audioManager.isWiredHeadsetOn + } + + private fun isBluetoothHeadsetOn(): Boolean { + Timber.v("## VOIP: AudioManager isBluetoothHeadsetOn") + try { + if (connectedBlueToothHeadset == null) return false.also { + Timber.v("## VOIP: AudioManager no connected bluetooth headset") + } + if (!audioManager.isBluetoothScoAvailableOffCall) return false.also { + Timber.v("## VOIP: AudioManager isBluetoothScoAvailableOffCall false") + } + return true + } catch (failure: Throwable) { + Timber.e("## VOIP: AudioManager isBluetoothHeadsetOn failure ${failure.localizedMessage}") + return false + } + } + + /** + * Helper method to trigger an audio route update when devices change. It + * makes sure the operation is performed on the audio thread. + */ + private fun onAudioDeviceChange() { + callAudioManager.runInAudioThread(onAudioDeviceChangeRunner) + } + + override fun start() { + Timber.i("Start using $this as the audio device handler") + wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(context, this) + bluetoothHeadsetStateReceiver = BluetoothHeadsetReceiver.createAndRegister(context, this) + val bm: BluetoothManager? = context.getSystemService() + val adapter = bm?.adapter + Timber.d("## VOIP Bluetooth adapter $adapter") + bluetoothAdapter = adapter + adapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceDisconnected(profile: Int) { + Timber.d("## VOIP onServiceDisconnected $profile") + if (profile == BluetoothProfile.HEADSET) { + connectedBlueToothHeadset = null + onAudioDeviceChange() + } + } + + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile?) { + Timber.d("## VOIP onServiceConnected $profile , proxy:$proxy") + if (profile == BluetoothProfile.HEADSET) { + connectedBlueToothHeadset = proxy + onAudioDeviceChange() + } + } + }, BluetoothProfile.HEADSET) + onAudioDeviceChange() + } + + override fun stop() { + Timber.i("Stop using $this as the audio device handler") + wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(context, it) } + wiredHeadsetStateReceiver = null + bluetoothHeadsetStateReceiver?.let { BluetoothHeadsetReceiver.unRegister(context, it) } + bluetoothHeadsetStateReceiver = null + } + + override fun onHeadsetEvent(event: WiredHeadsetStateReceiver.HeadsetPlugEvent) { + Timber.v("onHeadsetEvent $event") + onAudioDeviceChange() + } + + override fun onBTHeadsetEvent(event: BluetoothHeadsetReceiver.BTHeadsetPlugEvent) { + Timber.v("onBTHeadsetEvent $event") + onAudioDeviceChange() + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/audio/API23AudioDeviceDetector.kt b/vector/src/main/java/im/vector/app/features/call/audio/API23AudioDeviceDetector.kt new file mode 100644 index 0000000000..7174554d5f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/audio/API23AudioDeviceDetector.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.app.features.call.audio + +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import androidx.annotation.RequiresApi +import timber.log.Timber +import java.util.HashSet + +@RequiresApi(Build.VERSION_CODES.M) +internal class API23AudioDeviceDetector(private val audioManager: AudioManager, + private val callAudioManager: CallAudioManager +) : CallAudioManager.AudioDeviceDetector { + + private val onAudioDeviceChangeRunner = Runnable { + val devices: MutableSet = HashSet() + val deviceInfos = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + for (info in deviceInfos) { + when (info.type) { + AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> devices.add(CallAudioManager.Device.WIRELESS_HEADSET) + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> devices.add(CallAudioManager.Device.PHONE) + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> devices.add(CallAudioManager.Device.SPEAKER) + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET, TYPE_USB_HEADSET -> devices.add(CallAudioManager.Device.HEADSET) + } + } + callAudioManager.replaceDevices(devices) + Timber.i(" Available audio devices: $devices") + callAudioManager.updateAudioRoute() + } + private val audioDeviceCallback: AudioDeviceCallback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded( + addedDevices: Array) { + Timber.d(" Audio devices added") + onAudioDeviceChange() + } + + override fun onAudioDevicesRemoved( + removedDevices: Array) { + Timber.d(" Audio devices removed") + onAudioDeviceChange() + } + } + + /** + * Helper method to trigger an audio route update when devices change. It + * makes sure the operation is performed on the audio thread. + */ + private fun onAudioDeviceChange() { + callAudioManager.runInAudioThread(onAudioDeviceChangeRunner) + } + + override fun start() { + Timber.i("Using $this as the audio device handler") + audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) + onAudioDeviceChange() + } + + override fun stop() { + audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) + } + + companion object { + /** + * Constant defining a USB headset. Only available on API level >= 26. + * The value of: AudioDeviceInfo.TYPE_USB_HEADSET + */ + private const val TYPE_USB_HEADSET = 22 + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/audio/CallAudioManager.kt b/vector/src/main/java/im/vector/app/features/call/audio/CallAudioManager.kt new file mode 100644 index 0000000000..36a11b5923 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/audio/CallAudioManager.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.audio + +import android.content.Context +import android.media.AudioManager +import android.os.Build +import androidx.core.content.getSystemService +import org.matrix.android.sdk.api.extensions.orFalse +import timber.log.Timber +import java.util.HashSet +import java.util.concurrent.Executors + +class CallAudioManager(private val context: Context, val configChange: (() -> Unit)?) { + + private val audioManager: AudioManager? = context.getSystemService() + private var audioDeviceDetector: AudioDeviceDetector? = null + private var audioDeviceRouter: AudioDeviceRouter? = null + + enum class Device { + PHONE, + SPEAKER, + HEADSET, + WIRELESS_HEADSET + } + + enum class Mode { + DEFAULT, + AUDIO_CALL, + VIDEO_CALL + } + + private var mode = Mode.DEFAULT + private var _availableDevices: MutableSet = HashSet() + val availableDevices: Set + get() = _availableDevices + + var selectedDevice: Device? = null + private set + private var userSelectedDevice: Device? = null + + init { + runInAudioThread { setup() } + } + + private fun setup() { + if (audioManager == null) { + return + } + audioDeviceDetector?.stop() + audioDeviceDetector = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + API23AudioDeviceDetector(audioManager, this) + } else { + API21AudioDeviceDetector(context, audioManager, this) + } + audioDeviceDetector?.start() + audioDeviceRouter = DefaultAudioDeviceRouter(audioManager, this) + } + + fun runInAudioThread(runnable: Runnable) { + executor.execute(runnable) + } + + /** + * Sets the user selected audio device as the active audio device. + * + * @param device the desired device which will become active. + */ + fun setAudioDevice(device: Device) { + runInAudioThread(Runnable { + if (!_availableDevices.contains(device)) { + Timber.w("Audio device not available: $device") + userSelectedDevice = null + return@Runnable + } + if (mode != Mode.DEFAULT) { + Timber.i("User selected device set to: $device") + userSelectedDevice = device + updateAudioRoute(mode, false) + } + }) + } + + /** + * Public method to set the current audio mode. + * + * @param mode the desired audio mode. + * could be updated successfully, and it will be rejected otherwise. + */ + fun setMode(mode: Mode) { + runInAudioThread { + var success: Boolean + try { + success = updateAudioRoute(mode, false) + } catch (e: Throwable) { + success = false + Timber.e(e, "Failed to update audio route for mode: $mode") + } + if (success) { + this@CallAudioManager.mode = mode + } + } + } + + /** + * Updates the audio route for the given mode. + * + * @param mode the audio mode to be used when computing the audio route. + * @return `true` if the audio route was updated successfully; + * `false`, otherwise. + */ + private fun updateAudioRoute(mode: Mode, force: Boolean): Boolean { + Timber.i("Update audio route for mode: $mode") + if (!audioDeviceRouter?.setMode(mode).orFalse()) { + return false + } + if (mode == Mode.DEFAULT) { + selectedDevice = null + userSelectedDevice = null + return true + } + val bluetoothAvailable = _availableDevices.contains(Device.WIRELESS_HEADSET) + val headsetAvailable = _availableDevices.contains(Device.HEADSET) + + // Pick the desired device based on what's available and the mode. + var audioDevice: Device + audioDevice = if (bluetoothAvailable) { + Device.WIRELESS_HEADSET + } else if (headsetAvailable) { + Device.HEADSET + } else if (mode == Mode.VIDEO_CALL) { + Device.SPEAKER + } else { + Device.PHONE + } + // Consider the user's selection + if (userSelectedDevice != null && _availableDevices.contains(userSelectedDevice)) { + audioDevice = userSelectedDevice!! + } + + // If the previously selected device and the current default one + // match, do nothing. + if (!force && selectedDevice != null && selectedDevice == audioDevice) { + return true + } + selectedDevice = audioDevice + Timber.i("Selected audio device: $audioDevice") + audioDeviceRouter?.setAudioRoute(audioDevice) + configChange?.invoke() + return true + } + + /** + * Resets the current device selection. + */ + fun resetSelectedDevice() { + selectedDevice = null + userSelectedDevice = null + } + + /** + * Adds a new device to the list of available devices. + * + * @param device The new device. + */ + fun addDevice(device: Device) { + _availableDevices.add(device) + resetSelectedDevice() + } + + /** + * Removes a device from the list of available devices. + * + * @param device The old device to the removed. + */ + fun removeDevice(device: Device) { + _availableDevices.remove(device) + resetSelectedDevice() + } + + /** + * Replaces the current list of available devices with a new one. + * + * @param devices The new devices list. + */ + fun replaceDevices(devices: Set) { + _availableDevices.clear() + _availableDevices.addAll(devices) + resetSelectedDevice() + } + + /** + * Re-sets the current audio route. Needed when devices changes have happened. + */ + fun updateAudioRoute() { + if (mode != Mode.DEFAULT) { + updateAudioRoute(mode, false) + } + } + + /** + * Re-sets the current audio route. Needed when focus is lost and regained. + */ + fun resetAudioRoute() { + if (mode != Mode.DEFAULT) { + updateAudioRoute(mode, true) + } + } + + /** + * Interface for the modules implementing the actual audio device management. + */ + interface AudioDeviceDetector { + /** + * Start detecting audio device changes. + */ + fun start() + + /** + * Stop audio device detection. + */ + fun stop() + } + + interface AudioDeviceRouter { + /** + * Set the appropriate route for the given audio device. + * + * @param device Audio device for which the route must be set. + */ + fun setAudioRoute(device: Device) + + /** + * Set the given audio mode. + * + * @param mode The new audio mode to be used. + * @return Whether the operation was successful or not. + */ + fun setMode(mode: Mode): Boolean + } + + companion object { + // Every audio operations should be launched on single thread + private val executor = Executors.newSingleThreadExecutor() + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/audio/DefaultAudioDeviceRouter.kt b/vector/src/main/java/im/vector/app/features/call/audio/DefaultAudioDeviceRouter.kt new file mode 100644 index 0000000000..c252cc9f89 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/audio/DefaultAudioDeviceRouter.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.audio + +import android.media.AudioManager +import androidx.media.AudioAttributesCompat +import androidx.media.AudioFocusRequestCompat +import androidx.media.AudioManagerCompat +import timber.log.Timber + +class DefaultAudioDeviceRouter(private val audioManager: AudioManager, + private val callAudioManager: CallAudioManager +) : CallAudioManager.AudioDeviceRouter, AudioManager.OnAudioFocusChangeListener { + + private var audioFocusLost = false + + private var focusRequestCompat: AudioFocusRequestCompat? = null + + override fun setAudioRoute(device: CallAudioManager.Device) { + audioManager.isSpeakerphoneOn = device === CallAudioManager.Device.SPEAKER + setBluetoothAudioRoute(device === CallAudioManager.Device.WIRELESS_HEADSET) + } + + override fun setMode(mode: CallAudioManager.Mode): Boolean { + if (mode === CallAudioManager.Mode.DEFAULT) { + audioFocusLost = false + audioManager.mode = AudioManager.MODE_NORMAL + focusRequestCompat?.also { + AudioManagerCompat.abandonAudioFocusRequest(audioManager, it) + } + focusRequestCompat = null + audioManager.isSpeakerphoneOn = false + setBluetoothAudioRoute(false) + return true + } + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + audioManager.isMicrophoneMute = false + + val audioFocusRequest = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributesCompat.Builder() + .setUsage(AudioAttributesCompat.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributesCompat.CONTENT_TYPE_SPEECH) + .build() + ) + .setOnAudioFocusChangeListener(this) + .build() + .also { + focusRequestCompat = it + } + + val gotFocus = AudioManagerCompat.requestAudioFocus(audioManager, audioFocusRequest) + if (gotFocus == AudioManager.AUDIOFOCUS_REQUEST_FAILED) { + Timber.w(" Audio focus request failed") + return false + } + return true + } + + /** + * Helper method to set the output route to a Bluetooth device. + * + * @param enabled true if Bluetooth should use used, false otherwise. + */ + private fun setBluetoothAudioRoute(enabled: Boolean) { + if (enabled) { + audioManager.startBluetoothSco() + audioManager.isBluetoothScoOn = true + } else { + audioManager.isBluetoothScoOn = false + audioManager.stopBluetoothSco() + } + } + + /** + * [AudioManager.OnAudioFocusChangeListener] interface method. Called + * when the audio focus of the system is updated. + * + * @param focusChange - The type of focus change. + */ + override fun onAudioFocusChange(focusChange: Int) { + callAudioManager.runInAudioThread { + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> { + Timber.d(" Audio focus gained") + if (audioFocusLost) { + callAudioManager.resetAudioRoute() + } + audioFocusLost = false + } + AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + Timber.d(" Audio focus lost") + audioFocusLost = true + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/conference/JitsiCallViewModel.kt b/vector/src/main/java/im/vector/app/features/call/conference/JitsiCallViewModel.kt index 5a323aeb85..1525ca6dfe 100644 --- a/vector/src/main/java/im/vector/app/features/call/conference/JitsiCallViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/call/conference/JitsiCallViewModel.kt @@ -24,7 +24,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.assisted.AssistedFactory import im.vector.app.core.platform.VectorViewModel -import im.vector.app.core.resources.StringProvider +import im.vector.app.features.themes.ThemeProvider import org.jitsi.meet.sdk.JitsiMeetUserInfo import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session @@ -37,7 +37,8 @@ class JitsiCallViewModel @AssistedInject constructor( @Assisted initialState: JitsiCallViewState, @Assisted val args: VectorJitsiActivity.Args, private val session: Session, - private val stringProvider: StringProvider + private val jitsiMeetPropertiesFactory: JitsiWidgetPropertiesFactory, + private val themeProvider: ThemeProvider ) : VectorViewModel(initialState) { @AssistedFactory @@ -45,6 +46,8 @@ class JitsiCallViewModel @AssistedInject constructor( fun create(initialState: JitsiCallViewState, args: VectorJitsiActivity.Args): JitsiCallViewModel } + private val widgetService = session.widgetService() + init { val me = session.getRoomMember(session.myUserId, args.roomId)?.toMatrixItem() val userInfo = JitsiMeetUserInfo().apply { @@ -57,13 +60,14 @@ class JitsiCallViewModel @AssistedInject constructor( copy(userInfo = userInfo) } - session.widgetService().getRoomWidgetsLive(args.roomId, QueryStringValue.Equals(args.widgetId), WidgetType.Jitsi.values()) + widgetService.getRoomWidgetsLive(args.roomId, QueryStringValue.Equals(args.widgetId), WidgetType.Jitsi.values()) .asObservable() .distinctUntilChanged() .subscribe { val jitsiWidget = it.firstOrNull() if (jitsiWidget != null) { - val ppt = jitsiWidget.computedUrl?.let { url -> JitsiWidgetProperties(url, stringProvider) } + val ppt = widgetService.getWidgetComputedUrl(jitsiWidget, themeProvider.isLightTheme()) + ?.let { url -> jitsiMeetPropertiesFactory.create(url) } setState { copy( widget = Success(jitsiWidget), diff --git a/vector/src/main/java/im/vector/app/features/call/conference/JitsiWidgetProperties.kt b/vector/src/main/java/im/vector/app/features/call/conference/JitsiWidgetProperties.kt index 46e2e68dd6..ed63f723c8 100644 --- a/vector/src/main/java/im/vector/app/features/call/conference/JitsiWidgetProperties.kt +++ b/vector/src/main/java/im/vector/app/features/call/conference/JitsiWidgetProperties.kt @@ -16,25 +16,9 @@ package im.vector.app.features.call.conference -import android.net.Uri -import im.vector.app.R -import im.vector.app.core.resources.StringProvider -import java.net.URLDecoder - -class JitsiWidgetProperties(private val uriString: String, val stringProvider: StringProvider) { - val domain: String by lazy { configs["conferenceDomain"] ?: stringProvider.getString(R.string.preferred_jitsi_domain) } - val confId: String? by lazy { configs["conferenceId"] } - val displayName: String? by lazy { configs["displayName"] } - val avatarUrl: String? by lazy { configs["avatarUrl"] } - - private val configString: String? by lazy { Uri.parse(uriString).fragment } - - private val configs: Map by lazy { - configString?.split("&") - ?.map { it.split("=") } - ?.filter { it.size == 2 } - ?.map { (key, value) -> key to URLDecoder.decode(value, "UTF-8") } - ?.toMap() - .orEmpty() - } -} +data class JitsiWidgetProperties( + val domain: String, + val confId: String?, + val displayName: String?, + val avatarUrl: String? +) diff --git a/vector/src/main/java/im/vector/app/features/call/conference/JitsiWidgetPropertiesFactory.kt b/vector/src/main/java/im/vector/app/features/call/conference/JitsiWidgetPropertiesFactory.kt new file mode 100644 index 0000000000..8014e01fb2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/conference/JitsiWidgetPropertiesFactory.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.conference + +import android.net.Uri +import im.vector.app.R +import im.vector.app.core.resources.StringProvider +import org.matrix.android.sdk.api.extensions.tryOrNull +import java.net.URLDecoder +import javax.inject.Inject + +class JitsiWidgetPropertiesFactory @Inject constructor( + private val stringProvider: StringProvider +) { + fun create(url: String): JitsiWidgetProperties { + val configString = tryOrNull { Uri.parse(url) }?.fragment + + val configs = configString?.split("&") + ?.map { it.split("=") } + ?.filter { it.size == 2 } + ?.map { (key, value) -> key to URLDecoder.decode(value, "UTF-8") } + ?.toMap() + .orEmpty() + + return JitsiWidgetProperties( + domain = configs["conferenceDomain"] ?: stringProvider.getString(R.string.preferred_jitsi_domain), + confId = configs["conferenceId"], + displayName = configs["displayName"], + avatarUrl = configs["avatarUrl"] + ) + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/conference/VectorJitsiActivity.kt b/vector/src/main/java/im/vector/app/features/call/conference/VectorJitsiActivity.kt index 4851afaed0..caece858aa 100644 --- a/vector/src/main/java/im/vector/app/features/call/conference/VectorJitsiActivity.kt +++ b/vector/src/main/java/im/vector/app/features/call/conference/VectorJitsiActivity.kt @@ -16,12 +16,15 @@ package im.vector.app.features.call.conference +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.os.Bundle import android.os.Parcelable import android.widget.FrameLayout import androidx.core.view.isVisible +import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.airbnb.mvrx.Fail import com.airbnb.mvrx.MvRx import com.airbnb.mvrx.Success @@ -31,17 +34,17 @@ import im.vector.app.core.di.ScreenComponent import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivityJitsiBinding import kotlinx.parcelize.Parcelize +import org.jitsi.meet.sdk.BroadcastEvent import org.jitsi.meet.sdk.JitsiMeetActivityDelegate import org.jitsi.meet.sdk.JitsiMeetActivityInterface import org.jitsi.meet.sdk.JitsiMeetConferenceOptions import org.jitsi.meet.sdk.JitsiMeetView -import org.jitsi.meet.sdk.JitsiMeetViewListener import org.matrix.android.sdk.api.extensions.tryOrNull import timber.log.Timber import java.net.URL import javax.inject.Inject -class VectorJitsiActivity : VectorBaseActivity(), JitsiMeetActivityInterface, JitsiMeetViewListener { +class VectorJitsiActivity : VectorBaseActivity(), JitsiMeetActivityInterface { @Parcelize data class Args( @@ -63,12 +66,21 @@ class VectorJitsiActivity : VectorBaseActivity(), JitsiMee injector.inject(this) } + // See https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-android-sdk#listening-for-broadcasted-events + private val broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + intent?.let { onBroadcastReceived(it) } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) jitsiViewModel.subscribe(this) { renderState(it) } + + registerForBroadcastMessages() } override fun initUiAndData() { @@ -76,7 +88,6 @@ class VectorJitsiActivity : VectorBaseActivity(), JitsiMee jitsiMeetView = JitsiMeetView(this) val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) views.jitsiLayout.addView(jitsiMeetView, params) - jitsiMeetView?.listener = this } private fun renderState(viewState: JitsiCallViewState) { @@ -132,6 +143,7 @@ class VectorJitsiActivity : VectorBaseActivity(), JitsiMee override fun onDestroy() { JitsiMeetActivityDelegate.onHostDestroy(this) + unregisterForBroadcastMessages() super.onDestroy() } @@ -154,20 +166,37 @@ class VectorJitsiActivity : VectorBaseActivity(), JitsiMee JitsiMeetActivityDelegate.onRequestPermissionsResult(requestCode, permissions, grantResults) } - override fun onConferenceTerminated(p0: MutableMap?) { - Timber.v("JitsiMeetViewListener.onConferenceTerminated()") - // Do not finish if there is an error - if (p0?.get("error") == null) { - finish() + private fun registerForBroadcastMessages() { + val intentFilter = IntentFilter() + for (type in BroadcastEvent.Type.values()) { + intentFilter.addAction(type.action) + } + tryOrNull("Unable to register receiver") { + LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, intentFilter) } } - override fun onConferenceJoined(p0: MutableMap?) { - Timber.v("JitsiMeetViewListener.onConferenceJoined()") + private fun unregisterForBroadcastMessages() { + tryOrNull("Unable to unregister receiver") { + LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver) + } } - override fun onConferenceWillJoin(p0: MutableMap?) { - Timber.v("JitsiMeetViewListener.onConferenceWillJoin()") + private fun onBroadcastReceived(intent: Intent) { + val event = BroadcastEvent(intent) + Timber.v("Broadcast received: ${event.type}") + when (event.type) { + BroadcastEvent.Type.CONFERENCE_TERMINATED -> onConferenceTerminated(event.data) + else -> Unit + } + } + + private fun onConferenceTerminated(data: Map) { + Timber.v("JitsiMeetViewListener.onConferenceTerminated()") + // Do not finish if there is an error + if (data["error"] == null) { + finish() + } } companion object { diff --git a/vector/src/main/java/im/vector/app/features/call/dialpad/CallDialPadBottomSheet.kt b/vector/src/main/java/im/vector/app/features/call/dialpad/CallDialPadBottomSheet.kt new file mode 100644 index 0000000000..06b4dbfe7b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/dialpad/CallDialPadBottomSheet.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.dialpad + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import im.vector.app.R +import im.vector.app.core.extensions.addChildFragment +import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment +import im.vector.app.databinding.BottomSheetCallDialPadBinding +import im.vector.app.features.settings.VectorLocale + +class CallDialPadBottomSheet : VectorBaseBottomSheetDialogFragment() { + + companion object { + + private const val EXTRA_SHOW_ACTIONS = "EXTRA_SHOW_ACTIONS" + + fun newInstance(showActions: Boolean): CallDialPadBottomSheet { + return CallDialPadBottomSheet().apply { + arguments = Bundle().apply { + putBoolean(EXTRA_SHOW_ACTIONS, showActions) + } + } + } + } + + override val showExpanded = true + + var callback: DialPadFragment.Callback? = null + set(value) { + field = value + setCallbackToFragment(callback) + } + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetCallDialPadBinding { + return BottomSheetCallDialPadBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (savedInstanceState == null) { + val showActions = arguments?.getBoolean(EXTRA_SHOW_ACTIONS, false) ?: false + DialPadFragment().apply { + arguments = Bundle().apply { + putBoolean(DialPadFragment.EXTRA_ENABLE_DELETE, showActions) + putBoolean(DialPadFragment.EXTRA_ENABLE_OK, showActions) + putString(DialPadFragment.EXTRA_REGION_CODE, VectorLocale.applicationLocale.country) + } + callback = DialPadFragmentCallbackWrapper(this@CallDialPadBottomSheet.callback) + }.also { + addChildFragment(R.id.callDialPadFragmentContainer, it) + } + } else { + setCallbackToFragment(callback) + } + views.callDialPadClose.setOnClickListener { + dismiss() + } + } + + override fun onDestroyView() { + setCallbackToFragment(null) + super.onDestroyView() + } + + private fun setCallbackToFragment(callback: DialPadFragment.Callback?) { + if (!isAdded) return + val dialPadFragment = childFragmentManager.findFragmentById(R.id.callDialPadFragmentContainer) as? DialPadFragment + dialPadFragment?.callback = DialPadFragmentCallbackWrapper(callback) + } + + private inner class DialPadFragmentCallbackWrapper(val callback: DialPadFragment.Callback?): DialPadFragment.Callback { + + override fun onDigitAppended(digit: String) { + callback?.onDigitAppended(digit) + } + + override fun onOkClicked(formatted: String?, raw: String?) { + callback?.onOkClicked(formatted, raw) + dismiss() + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/dialpad/DialPadFragment.kt b/vector/src/main/java/im/vector/app/features/call/dialpad/DialPadFragment.kt new file mode 100644 index 0000000000..b488a1af0e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/dialpad/DialPadFragment.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.dialpad + +import android.content.res.ColorStateList +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.core.widget.ImageViewCompat +import androidx.fragment.app.Fragment +import com.android.dialer.dialpadview.DialpadView +import com.android.dialer.dialpadview.DigitsEditText +import com.android.dialer.dialpadview.R +import com.google.i18n.phonenumbers.AsYouTypeFormatter +import com.google.i18n.phonenumbers.PhoneNumberUtil +import im.vector.app.features.themes.ThemeUtils + +class DialPadFragment : Fragment() { + + var callback: Callback? = null + + private var digits: DigitsEditText? = null + private var formatter: AsYouTypeFormatter? = null + private var input = "" + private var regionCode: String = DEFAULT_REGION_CODE + private var formatAsYouType = true + private var enableStar = true + private var enablePound = true + private var enablePlus = true + private var cursorVisible = false + private var enableDelete = true + private var enableFabOk = true + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View { + initArgs(savedInstanceState) + val view = inflater.inflate(R.layout.dialpad_fragment, container, false) + val dialpadView = view.findViewById(R.id.dialpad_view) as DialpadView + dialpadView.findViewById(R.id.dialpad_key_voicemail).isVisible = false + digits = dialpadView.digits as? DigitsEditText + digits?.isCursorVisible = cursorVisible + digits?.setTextColor(ThemeUtils.getColor(requireContext(), im.vector.app.R.attr.riotx_text_primary)) + dialpadView.findViewById(R.id.zero).setOnClickListener { append('0') } + if (enablePlus) { + dialpadView.findViewById(R.id.zero).setOnLongClickListener { + append('+') + true + } + } + dialpadView.findViewById(R.id.one).setOnClickListener { append('1') } + dialpadView.findViewById(R.id.two).setOnClickListener { append('2') } + dialpadView.findViewById(R.id.three).setOnClickListener { append('3') } + dialpadView.findViewById(R.id.four).setOnClickListener { append('4') } + dialpadView.findViewById(R.id.four).setOnClickListener { append('4') } + dialpadView.findViewById(R.id.five).setOnClickListener { append('5') } + dialpadView.findViewById(R.id.six).setOnClickListener { append('6') } + dialpadView.findViewById(R.id.seven).setOnClickListener { append('7') } + dialpadView.findViewById(R.id.eight).setOnClickListener { append('8') } + dialpadView.findViewById(R.id.nine).setOnClickListener { append('9') } + if (enableStar) { + dialpadView.findViewById(R.id.star).setOnClickListener { append('*') } + } else { + dialpadView.findViewById(R.id.star).isVisible = false + } + if (enablePound) { + dialpadView.findViewById(R.id.pound).setOnClickListener { append('#') } + } else { + dialpadView.findViewById(R.id.pound).isVisible = false + } + if (enableDelete) { + dialpadView.deleteButton.setOnClickListener { poll() } + dialpadView.deleteButton.setOnLongClickListener { + clear() + true + } + val tintColor = ThemeUtils.getColor(requireContext(), im.vector.app.R.attr.riotx_text_secondary) + ImageViewCompat.setImageTintList(dialpadView.deleteButton, ColorStateList.valueOf(tintColor)) + } else { + dialpadView.deleteButton.isVisible = false + } + + // if region code is null, no formatting is performed + formatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(if (formatAsYouType) regionCode else "") + + val fabOk = view.findViewById(R.id.fab_ok) + if (enableFabOk) { + fabOk.setOnClickListener { + callback?.onOkClicked(digits?.text.toString(), input) + } + } else { + fabOk.isVisible = false + } + + digits?.setOnTextContextMenuClickListener { + val string = digits?.text.toString() + clear() + for (element in string) { + append(element) + } + } + return view + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString(EXTRA_REGION_CODE, regionCode) + outState.putBoolean(EXTRA_FORMAT_AS_YOU_TYPE, formatAsYouType) + outState.putBoolean(EXTRA_ENABLE_STAR, enableStar) + outState.putBoolean(EXTRA_ENABLE_POUND, enablePound) + outState.putBoolean(EXTRA_ENABLE_PLUS, enablePlus) + outState.putBoolean(EXTRA_ENABLE_OK, enableFabOk) + outState.putBoolean(EXTRA_ENABLE_DELETE, enableDelete) + outState.putBoolean(EXTRA_CURSOR_VISIBLE, cursorVisible) + } + + private fun initArgs(savedInstanceState: Bundle?) { + val args = savedInstanceState ?: arguments + if (args != null) { + regionCode = args.getString(EXTRA_REGION_CODE, DEFAULT_REGION_CODE) + formatAsYouType = args.getBoolean(EXTRA_FORMAT_AS_YOU_TYPE, formatAsYouType) + enableStar = args.getBoolean(EXTRA_ENABLE_STAR, enableStar) + enablePound = args.getBoolean(EXTRA_ENABLE_POUND, enablePound) + enablePlus = args.getBoolean(EXTRA_ENABLE_PLUS, enablePlus) + enableDelete = args.getBoolean(EXTRA_ENABLE_DELETE, enableDelete) + enableFabOk = args.getBoolean(EXTRA_ENABLE_OK, enableFabOk) + cursorVisible = args.getBoolean(EXTRA_CURSOR_VISIBLE, cursorVisible) + } + } + + private fun poll() { + if (!input.isEmpty()) { + input = input.substring(0, input.length - 1) + formatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(regionCode) + if (formatAsYouType) { + digits?.setText("") + for (c in input.toCharArray()) { + digits?.setText(formatter?.inputDigit(c)) + } + } else { + digits?.setText(input) + } + } + } + + private fun clear() { + formatter?.clear() + digits?.setText("") + input = "" + } + + private fun append(c: Char) { + callback?.onDigitAppended(c.toString()) + input += c + if (formatAsYouType) { + digits?.setText(formatter?.inputDigit(c)) + } else { + digits?.setText(input) + } + } + + interface Callback { + fun onOkClicked(formatted: String?, raw: String?) = Unit + fun onDigitAppended(digit: String) = Unit + } + + companion object { + const val EXTRA_REGION_CODE = "EXTRA_REGION_CODE" + const val EXTRA_FORMAT_AS_YOU_TYPE = "EXTRA_FORMAT_AS_YOU_TYPE" + const val EXTRA_ENABLE_STAR = "EXTRA_ENABLE_STAR" + const val EXTRA_ENABLE_POUND = "EXTRA_ENABLE_POUND" + const val EXTRA_ENABLE_PLUS = "EXTRA_ENABLE_PLUS" + const val EXTRA_ENABLE_DELETE = "EXTRA_ENABLE_DELETE" + const val EXTRA_ENABLE_OK = "EXTRA_ENABLE_OK" + const val EXTRA_CURSOR_VISIBLE = "EXTRA_CURSOR_VISIBLE" + + private const val DEFAULT_REGION_CODE = "US" + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/dialpad/DialPadLookup.kt b/vector/src/main/java/im/vector/app/features/call/dialpad/DialPadLookup.kt new file mode 100644 index 0000000000..6fccea6c8c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/dialpad/DialPadLookup.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.dialpad + +import im.vector.app.features.call.webrtc.WebRtcCallManager +import im.vector.app.features.createdirect.DirectRoomHelper +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.Session +import javax.inject.Inject + +class DialPadLookup @Inject constructor( + private val session: Session, + private val directRoomHelper: DirectRoomHelper, + private val callManager: WebRtcCallManager +) { + class Failure : Throwable() + + data class Result(val userId: String, val roomId: String) + + suspend fun lookupPhoneNumber(phoneNumber: String): Result { + val supportedProtocolKey = callManager.supportedPSTNProtocol ?: throw Failure() + val thirdPartyUser = tryOrNull { + session.thirdPartyService().getThirdPartyUser( + protocol = supportedProtocolKey, + fields = mapOf("m.id.phone" to phoneNumber) + ).firstOrNull() + } ?: throw Failure() + + val roomId = directRoomHelper.ensureDMExists(thirdPartyUser.userId) + return Result(userId = thirdPartyUser.userId, roomId = roomId) + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/service/CallHeadsUpActionReceiver.kt b/vector/src/main/java/im/vector/app/features/call/service/CallHeadsUpActionReceiver.kt index 04e7401e6c..5a1d8cd396 100644 --- a/vector/src/main/java/im/vector/app/features/call/service/CallHeadsUpActionReceiver.kt +++ b/vector/src/main/java/im/vector/app/features/call/service/CallHeadsUpActionReceiver.kt @@ -20,24 +20,28 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import im.vector.app.core.di.HasVectorInjector -import im.vector.app.features.call.WebRtcPeerConnectionManager +import im.vector.app.features.call.webrtc.WebRtcCallManager import timber.log.Timber class CallHeadsUpActionReceiver : BroadcastReceiver() { companion object { const val EXTRA_CALL_ACTION_KEY = "EXTRA_CALL_ACTION_KEY" + const val EXTRA_CALL_ID = "EXTRA_CALL_ID" const val CALL_ACTION_REJECT = 0 } override fun onReceive(context: Context, intent: Intent?) { - val peerConnectionManager = (context.applicationContext as? HasVectorInjector) + val webRtcCallManager = (context.applicationContext as? HasVectorInjector) ?.injector() - ?.webRtcPeerConnectionManager() + ?.webRtcCallManager() ?: return when (intent?.getIntExtra(EXTRA_CALL_ACTION_KEY, 0)) { - CALL_ACTION_REJECT -> onCallRejectClicked(peerConnectionManager) + CALL_ACTION_REJECT -> { + val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return + onCallRejectClicked(webRtcCallManager, callId) + } } // Not sure why this should be needed @@ -48,9 +52,9 @@ class CallHeadsUpActionReceiver : BroadcastReceiver() { // context.stopService(Intent(context, CallHeadsUpService::class.java)) } - private fun onCallRejectClicked(peerConnectionManager: WebRtcPeerConnectionManager) { + private fun onCallRejectClicked(callManager: WebRtcCallManager, callId: String) { Timber.d("onCallRejectClicked") - peerConnectionManager.endCall() + callManager.getCallById(callId)?.endCall() } // private fun onCallAnswerClicked(context: Context) { diff --git a/vector/src/main/java/im/vector/app/features/call/telecom/CallConnection.kt b/vector/src/main/java/im/vector/app/features/call/telecom/CallConnection.kt index 0a9a164993..b298eebb23 100644 --- a/vector/src/main/java/im/vector/app/features/call/telecom/CallConnection.kt +++ b/vector/src/main/java/im/vector/app/features/call/telecom/CallConnection.kt @@ -21,7 +21,7 @@ import android.os.Build import android.telecom.Connection import android.telecom.DisconnectCause import androidx.annotation.RequiresApi -import im.vector.app.features.call.WebRtcPeerConnectionManager +import im.vector.app.features.call.webrtc.WebRtcCallManager import timber.log.Timber import javax.inject.Inject @@ -31,7 +31,7 @@ import javax.inject.Inject val callId: String ) : Connection() { - @Inject lateinit var peerConnectionManager: WebRtcPeerConnectionManager + @Inject lateinit var callManager: WebRtcCallManager init { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/vector/src/main/java/im/vector/app/features/call/telecom/CallConnectionService.java b/vector/src/main/java/im/vector/app/features/call/telecom/CallConnectionService.java new file mode 100644 index 0000000000..f3fbfc9ac4 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/telecom/CallConnectionService.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.telecom; + +import android.os.Build; + +import androidx.annotation.RequiresApi; + +import org.jitsi.meet.sdk.ConnectionService; + +@RequiresApi(api = Build.VERSION_CODES.O) +public class CallConnectionService extends ConnectionService { +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionService.kt b/vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionService.kt index 410a4621e8..e289537177 100644 --- a/vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionService.kt +++ b/vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionService.kt @@ -71,7 +71,7 @@ import im.vector.app.core.services.CallService bindService(Intent(applicationContext, CallService::class.java), CallServiceConnection(connection), 0) connection.setInitializing() - return CallConnection(applicationContext, roomId, callId) + return connection } inner class CallServiceConnection(private val callConnection: CallConnection) : ServiceConnection { diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferAction.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferAction.kt new file mode 100644 index 0000000000..bd694ad14e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferAction.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.transfer + +import im.vector.app.core.platform.VectorViewModelAction + +sealed class CallTransferAction : VectorViewModelAction { + data class ConnectWithUserId(val consultFirst: Boolean, val selectedUserId: String) : CallTransferAction() + data class ConnectWithPhoneNumber(val consultFirst: Boolean, val phoneNumber: String) : CallTransferAction() +} diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt new file mode 100644 index 0000000000..c5b4dda135 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.transfer + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +import com.airbnb.mvrx.MvRx +import com.airbnb.mvrx.viewModel +import com.google.android.material.tabs.TabLayoutMediator +import im.vector.app.R +import im.vector.app.core.di.ScreenComponent +import im.vector.app.core.error.ErrorFormatter +import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.databinding.ActivityCallTransferBinding +import im.vector.app.features.contactsbook.ContactsBookViewModel +import im.vector.app.features.contactsbook.ContactsBookViewState +import im.vector.app.features.userdirectory.UserListViewModel +import im.vector.app.features.userdirectory.UserListViewState +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +@Parcelize +data class CallTransferArgs(val callId: String) : Parcelable + +private const val USER_LIST_FRAGMENT_TAG = "USER_LIST_FRAGMENT_TAG" + +class CallTransferActivity : VectorBaseActivity(), + CallTransferViewModel.Factory, + UserListViewModel.Factory, + ContactsBookViewModel.Factory { + + @Inject lateinit var userListViewModelFactory: UserListViewModel.Factory + @Inject lateinit var callTransferViewModelFactory: CallTransferViewModel.Factory + @Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory + @Inject lateinit var errorFormatter: ErrorFormatter + + private lateinit var sectionsPagerAdapter: CallTransferPagerAdapter + + private val callTransferViewModel: CallTransferViewModel by viewModel() + + override fun getBinding() = ActivityCallTransferBinding.inflate(layoutInflater) + + override fun getCoordinatorLayout() = views.vectorCoordinatorLayout + + override fun injectWith(injector: ScreenComponent) { + super.injectWith(injector) + injector.inject(this) + } + + override fun create(initialState: UserListViewState): UserListViewModel { + return userListViewModelFactory.create(initialState) + } + + override fun create(initialState: CallTransferViewState): CallTransferViewModel { + return callTransferViewModelFactory.create(initialState) + } + + override fun create(initialState: ContactsBookViewState): ContactsBookViewModel { + return contactsBookViewModelFactory.create(initialState) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + waitingView = views.waitingView.waitingView + + callTransferViewModel.observeViewEvents { + when (it) { + is CallTransferViewEvents.Dismiss -> finish() + CallTransferViewEvents.Loading -> showWaitingView() + is CallTransferViewEvents.FailToTransfer -> showSnackbar(getString(R.string.call_transfer_failure)) + } + } + + sectionsPagerAdapter = CallTransferPagerAdapter(this).register() + views.callTransferViewPager.adapter = sectionsPagerAdapter + sectionsPagerAdapter.onDialPadOkClicked = { phoneNumber -> + val action = CallTransferAction.ConnectWithPhoneNumber(views.callTransferConsultCheckBox.isChecked, phoneNumber) + callTransferViewModel.handle(action) + } + + TabLayoutMediator(views.callTransferTabLayout, views.callTransferViewPager) { tab, position -> + when (position) { + 0 -> tab.text = getString(R.string.call_transfer_users_tab_title) + 1 -> tab.text = getString(R.string.call_dial_pad_title) + } + }.attach() + configureToolbar(views.callTransferToolbar) + views.callTransferToolbar.title = getString(R.string.call_transfer_title) + setupConnectAction() + } + + private fun setupConnectAction() { + views.callTransferConnectAction.debouncedClicks { + val selectedUser = sectionsPagerAdapter.userListFragment?.getCurrentState()?.getSelectedMatrixId()?.firstOrNull() + if (selectedUser != null) { + val action = CallTransferAction.ConnectWithUserId(views.callTransferConsultCheckBox.isChecked, selectedUser) + callTransferViewModel.handle(action) + } + } + } + + companion object { + + fun newIntent(context: Context, callId: String): Intent { + return Intent(context, CallTransferActivity::class.java).also { + it.putExtra(MvRx.KEY_ARG, CallTransferArgs(callId)) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferPagerAdapter.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferPagerAdapter.kt new file mode 100644 index 0000000000..1f2d3070dd --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferPagerAdapter.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.transfer + +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import im.vector.app.core.extensions.toMvRxBundle +import im.vector.app.core.platform.Restorable +import im.vector.app.features.call.dialpad.DialPadFragment +import im.vector.app.features.settings.VectorLocale +import im.vector.app.features.userdirectory.UserListFragment +import im.vector.app.features.userdirectory.UserListFragmentArgs + +class CallTransferPagerAdapter( + private val fragmentActivity: FragmentActivity +) : FragmentStateAdapter(fragmentActivity), Restorable { + + val userListFragment: UserListFragment? + get() = findFragmentAtPosition(0) as? UserListFragment + val dialPadFragment: DialPadFragment? + get() = findFragmentAtPosition(1) as? DialPadFragment + + var onDialPadOkClicked: ((String) -> Unit)? = null + + override fun getItemCount() = 2 + + override fun createFragment(position: Int): Fragment { + val fragment: Fragment + if (position == 0) { + fragment = fragmentActivity.supportFragmentManager.fragmentFactory.instantiate(fragmentActivity.classLoader, UserListFragment::class.java.name) + fragment.arguments = UserListFragmentArgs( + title = "", + menuResId = -1, + singleSelection = true, + showInviteActions = false, + showToolbar = false, + showContactBookAction = false + ).toMvRxBundle() + } else { + fragment = fragmentActivity.supportFragmentManager.fragmentFactory.instantiate(fragmentActivity.classLoader, DialPadFragment::class.java.name) + (fragment as DialPadFragment).apply { + arguments = Bundle().apply { + putBoolean(DialPadFragment.EXTRA_ENABLE_DELETE, true) + putBoolean(DialPadFragment.EXTRA_ENABLE_OK, true) + putString(DialPadFragment.EXTRA_REGION_CODE, VectorLocale.applicationLocale.country) + } + applyCallback() + } + } + return fragment + } + + private fun findFragmentAtPosition(position: Int): Fragment? { + return fragmentActivity.supportFragmentManager.findFragmentByTag("f$position") + } + + override fun onSaveInstanceState(outState: Bundle) = Unit + + override fun onRestoreInstanceState(savedInstanceState: Bundle?) { + dialPadFragment?.applyCallback() + } + + private fun DialPadFragment.applyCallback(): DialPadFragment { + callback = object : DialPadFragment.Callback { + override fun onOkClicked(formatted: String?, raw: String?) { + if (raw.isNullOrEmpty()) return + onDialPadOkClicked?.invoke(raw) + } + } + return this + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt new file mode 100644 index 0000000000..b110164d1e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.transfer + +import im.vector.app.core.platform.VectorViewEvents + +sealed class CallTransferViewEvents : VectorViewEvents { + object Dismiss : CallTransferViewEvents() + object Loading: CallTransferViewEvents() + object FailToTransfer : CallTransferViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt new file mode 100644 index 0000000000..5f661faf80 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.transfer + +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.ViewModelContext +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.call.dialpad.DialPadLookup +import im.vector.app.features.call.webrtc.WebRtcCall +import im.vector.app.features.call.webrtc.WebRtcCallManager +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.session.call.CallState +import org.matrix.android.sdk.api.session.call.MxCall + +class CallTransferViewModel @AssistedInject constructor(@Assisted initialState: CallTransferViewState, + private val dialPadLookup: DialPadLookup, + callManager: WebRtcCallManager) + : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory { + fun create(initialState: CallTransferViewState): CallTransferViewModel + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: CallTransferViewState): CallTransferViewModel? { + val activity: CallTransferActivity = (viewModelContext as ActivityViewModelContext).activity() + return activity.callTransferViewModelFactory.create(state) + } + } + + private val call = callManager.getCallById(initialState.callId) + private val callListener = object : WebRtcCall.Listener { + override fun onStateUpdate(call: MxCall) { + if (call.state == CallState.Terminated) { + _viewEvents.post(CallTransferViewEvents.Dismiss) + } + } + } + + init { + if (call == null) { + _viewEvents.post(CallTransferViewEvents.Dismiss) + } else { + call.addListener(callListener) + } + } + + override fun onCleared() { + super.onCleared() + call?.removeListener(callListener) + } + + override fun handle(action: CallTransferAction) { + when (action) { + is CallTransferAction.ConnectWithUserId -> connectWithUserId(action) + is CallTransferAction.ConnectWithPhoneNumber -> connectWithPhoneNumber(action) + }.exhaustive + } + + private fun connectWithUserId(action: CallTransferAction.ConnectWithUserId) { + viewModelScope.launch { + try { + _viewEvents.post(CallTransferViewEvents.Loading) + call?.mxCall?.transfer(action.selectedUserId, null) + _viewEvents.post(CallTransferViewEvents.Dismiss) + } catch (failure: Throwable) { + _viewEvents.post(CallTransferViewEvents.FailToTransfer) + } + } + } + + private fun connectWithPhoneNumber(action: CallTransferAction.ConnectWithPhoneNumber) { + viewModelScope.launch { + try { + _viewEvents.post(CallTransferViewEvents.Loading) + val result = dialPadLookup.lookupPhoneNumber(action.phoneNumber) + call?.mxCall?.transfer(result.userId, result.roomId) + _viewEvents.post(CallTransferViewEvents.Dismiss) + } catch (failure: Throwable) { + _viewEvents.post(CallTransferViewEvents.FailToTransfer) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/core/epoxy/EmptyItem.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewState.kt similarity index 66% rename from vector/src/main/java/im/vector/app/core/epoxy/EmptyItem.kt rename to vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewState.kt index aaf870667b..2b29d9f6f2 100644 --- a/vector/src/main/java/im/vector/app/core/epoxy/EmptyItem.kt +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewState.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * 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. @@ -14,12 +14,13 @@ * limitations under the License. */ -package im.vector.app.core.epoxy +package im.vector.app.features.call.transfer -import com.airbnb.epoxy.EpoxyModelClass -import im.vector.app.R +import com.airbnb.mvrx.MvRxState -@EpoxyModelClass(layout = R.layout.item_empty) -abstract class EmptyItem : VectorEpoxyModel() { - class Holder : VectorEpoxyHolder() +data class CallTransferViewState( + val callId: String +) : MvRxState { + + constructor(args: CallTransferArgs) : this(callId = args.callId) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/EglUtils.kt b/vector/src/main/java/im/vector/app/features/call/utils/EglUtils.kt similarity index 94% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/EglUtils.kt rename to vector/src/main/java/im/vector/app/features/call/utils/EglUtils.kt index 131779a4dc..045124a900 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/call/EglUtils.kt +++ b/vector/src/main/java/im/vector/app/features/call/utils/EglUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright (c) 2020 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.matrix.android.sdk.api.session.call +package im.vector.app.features.call.utils import org.webrtc.EglBase import timber.log.Timber diff --git a/vector/src/main/java/im/vector/app/features/call/utils/PeerConnectionExt.kt b/vector/src/main/java/im/vector/app/features/call/utils/PeerConnectionExt.kt new file mode 100644 index 0000000000..978b984dce --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/utils/PeerConnectionExt.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.utils + +import im.vector.app.features.call.webrtc.SdpObserverAdapter +import org.webrtc.MediaConstraints +import org.webrtc.PeerConnection +import org.webrtc.SessionDescription +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +suspend fun PeerConnection.awaitCreateOffer(mediaConstraints: MediaConstraints): SessionDescription? = suspendCoroutine { cont -> + createOffer(object : SdpObserverAdapter() { + override fun onCreateSuccess(p0: SessionDescription?) { + super.onCreateSuccess(p0) + cont.resume(p0) + } + + override fun onCreateFailure(p0: String?) { + super.onCreateFailure(p0) + cont.resumeWithException(IllegalStateException(p0)) + } + }, mediaConstraints) +} + +suspend fun PeerConnection.awaitCreateAnswer(mediaConstraints: MediaConstraints): SessionDescription? = suspendCoroutine { cont -> + createAnswer(object : SdpObserverAdapter() { + override fun onCreateSuccess(p0: SessionDescription?) { + super.onCreateSuccess(p0) + cont.resume(p0) + } + + override fun onCreateFailure(p0: String?) { + super.onCreateFailure(p0) + cont.resumeWithException(IllegalStateException(p0)) + } + }, mediaConstraints) +} + +suspend fun PeerConnection.awaitSetLocalDescription(sessionDescription: SessionDescription): Unit = suspendCoroutine { cont -> + setLocalDescription(object : SdpObserverAdapter() { + override fun onSetFailure(p0: String?) { + super.onSetFailure(p0) + cont.resumeWithException(IllegalStateException(p0)) + } + + override fun onSetSuccess() { + super.onSetSuccess() + cont.resume(Unit) + } + }, sessionDescription) +} + +suspend fun PeerConnection.awaitSetRemoteDescription(sessionDescription: SessionDescription): Unit = suspendCoroutine { cont -> + setRemoteDescription(object : SdpObserverAdapter() { + override fun onSetFailure(p0: String?) { + super.onSetFailure(p0) + cont.resumeWithException(IllegalStateException(p0)) + } + + override fun onSetSuccess() { + super.onSetSuccess() + cont.resume(Unit) + } + }, sessionDescription) +} diff --git a/vector/src/main/java/im/vector/app/features/call/utils/WebRtcMapping.kt b/vector/src/main/java/im/vector/app/features/call/utils/WebRtcMapping.kt new file mode 100644 index 0000000000..2b0d5281d2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/utils/WebRtcMapping.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.app.features.call.utils + +import org.matrix.android.sdk.api.session.room.model.call.CallCandidate +import org.matrix.android.sdk.api.session.room.model.call.SdpType +import org.webrtc.IceCandidate +import org.webrtc.SessionDescription + +fun List.mapToCallCandidate() = map { + CallCandidate( + sdpMid = it.sdpMid, + sdpMLineIndex = it.sdpMLineIndex, + candidate = it.sdp + ) +} + +fun SdpType.asWebRTC(): SessionDescription.Type { + return if (this == SdpType.OFFER) { + SessionDescription.Type.OFFER + } else { + SessionDescription.Type.ANSWER + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/PeerConnectionObserver.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/PeerConnectionObserver.kt new file mode 100644 index 0000000000..f14bb2f849 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/PeerConnectionObserver.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.webrtc + +import org.matrix.android.sdk.api.session.call.CallState +import org.matrix.android.sdk.api.session.call.MxPeerConnectionState +import org.webrtc.DataChannel +import org.webrtc.IceCandidate +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.RtpReceiver +import timber.log.Timber + +class PeerConnectionObserver(private val webRtcCall: WebRtcCall) : 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 -> { + webRtcCall.mxCall.state = CallState.Connected(MxPeerConnectionState.CONNECTED) + } + /** + * 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... + // webRtcCall.mxCall.state = CallState.ERROR + webRtcCall.mxCall.state = CallState.Connected(MxPeerConnectionState.FAILED) + } + /** + * 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 -> { + webRtcCall.mxCall.state = CallState.Connected(MxPeerConnectionState.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 -> { + webRtcCall.mxCall.state = CallState.Connected(MxPeerConnectionState.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 -> { + webRtcCall.mxCall.state = CallState.Connected(MxPeerConnectionState.DISCONNECTED) + } + null -> { + } + } + } + + override fun onIceCandidate(iceCandidate: IceCandidate) { + Timber.v("## VOIP StreamObserver onIceCandidate: $iceCandidate") + webRtcCall.onIceCandidate(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 -> { + webRtcCall.onRenegotiationNeeded(restartIce = true) + } + /** + * 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") + webRtcCall.onAddStream(stream) + } + + override fun onRemoveStream(stream: MediaStream) { + Timber.v("## VOIP StreamObserver onRemoveStream") + webRtcCall.onRemoveStream() + } + + 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") + webRtcCall.onRenegotiationNeeded(restartIce = false) + } + + /** + * 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") + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/PeerConnectionObserverAdapter.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/PeerConnectionObserverAdapter.kt similarity index 98% rename from vector/src/main/java/im/vector/app/features/call/PeerConnectionObserverAdapter.kt rename to vector/src/main/java/im/vector/app/features/call/webrtc/PeerConnectionObserverAdapter.kt index 32e30c5345..3d31f0e705 100644 --- a/vector/src/main/java/im/vector/app/features/call/PeerConnectionObserverAdapter.kt +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/PeerConnectionObserverAdapter.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package im.vector.app.features.call +package im.vector.app.features.call.webrtc import org.webrtc.DataChannel import org.webrtc.IceCandidate diff --git a/vector/src/main/java/im/vector/app/features/call/SdpObserverAdapter.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/SdpObserverAdapter.kt similarity index 87% rename from vector/src/main/java/im/vector/app/features/call/SdpObserverAdapter.kt rename to vector/src/main/java/im/vector/app/features/call/webrtc/SdpObserverAdapter.kt index 0685928d1c..24d0e7b1f8 100644 --- a/vector/src/main/java/im/vector/app/features/call/SdpObserverAdapter.kt +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/SdpObserverAdapter.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package im.vector.app.features.call +package im.vector.app.features.call.webrtc import org.webrtc.SdpObserver import org.webrtc.SessionDescription @@ -30,10 +30,10 @@ open class SdpObserverAdapter : SdpObserver { } override fun onCreateSuccess(p0: SessionDescription?) { - Timber.e("## SdpObserver: onSetFailure $p0") + Timber.v("## SdpObserver: onCreateSuccess $p0") } override fun onCreateFailure(p0: String?) { - Timber.e("## SdpObserver: onSetFailure $p0") + Timber.e("## SdpObserver: onCreateFailure $p0") } } diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt new file mode 100644 index 0000000000..469fba4d5e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt @@ -0,0 +1,873 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.webrtc + +import android.content.Context +import android.hardware.camera2.CameraManager +import androidx.core.content.getSystemService +import im.vector.app.core.services.CallService +import im.vector.app.core.utils.CountUpTimer +import im.vector.app.features.call.CameraEventsHandlerAdapter +import im.vector.app.features.call.CameraProxy +import im.vector.app.features.call.CameraType +import im.vector.app.features.call.CaptureFormat +import im.vector.app.features.call.VectorCallActivity +import im.vector.app.features.call.utils.asWebRTC +import im.vector.app.features.call.utils.awaitCreateAnswer +import im.vector.app.features.call.utils.awaitCreateOffer +import im.vector.app.features.call.utils.awaitSetLocalDescription +import im.vector.app.features.call.utils.awaitSetRemoteDescription +import im.vector.app.features.call.utils.mapToCallCandidate +import io.reactivex.disposables.Disposable +import io.reactivex.subjects.PublishSubject +import io.reactivex.subjects.ReplaySubject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.call.CallState +import org.matrix.android.sdk.api.session.call.MxCall +import org.matrix.android.sdk.api.session.call.MxPeerConnectionState +import org.matrix.android.sdk.api.session.call.TurnServerResponse +import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent +import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent +import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent +import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent +import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent +import org.matrix.android.sdk.api.session.room.model.call.SdpType +import org.threeten.bp.Duration +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.Camera1Enumerator +import org.webrtc.Camera2Enumerator +import org.webrtc.CameraVideoCapturer +import org.webrtc.EglBase +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpTransceiver +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.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import javax.inject.Provider +import kotlin.coroutines.CoroutineContext + +private const val STREAM_ID = "ARDAMS" +private const val AUDIO_TRACK_ID = "ARDAMSa0" +private const val VIDEO_TRACK_ID = "ARDAMSv0" +private val DEFAULT_AUDIO_CONSTRAINTS = MediaConstraints() + +class WebRtcCall(val mxCall: MxCall, + private val rootEglBase: EglBase?, + private val context: Context, + private val dispatcher: CoroutineContext, + private val sessionProvider: Provider, + private val peerConnectionFactoryProvider: Provider, + private val onCallBecomeActive: (WebRtcCall) -> Unit, + private val onCallEnded: (String) -> Unit) : MxCall.StateListener { + + interface Listener : MxCall.StateListener { + fun onCaptureStateChanged() {} + fun onCameraChanged() {} + fun onHoldUnhold() {} + fun onTick(formattedDuration: String) {} + override fun onStateUpdate(call: MxCall) {} + } + + private val listeners = CopyOnWriteArrayList() + + fun addListener(listener: Listener) { + listeners.add(listener) + } + + fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + val callId = mxCall.callId + val roomId = mxCall.roomId + + private var peerConnection: PeerConnection? = null + private var localAudioSource: AudioSource? = null + private var localAudioTrack: AudioTrack? = null + private var localVideoSource: VideoSource? = null + private var localVideoTrack: VideoTrack? = null + private var remoteAudioTrack: AudioTrack? = null + private var remoteVideoTrack: VideoTrack? = null + + // Perfect negotiation state: https://www.w3.org/TR/webrtc/#perfect-negotiation-example + private var makingOffer: Boolean = false + private var ignoreOffer: Boolean = false + + private var videoCapturer: CameraVideoCapturer? = null + + private val availableCamera = ArrayList() + private var cameraInUse: CameraProxy? = null + private var currentCaptureFormat: CaptureFormat = CaptureFormat.HD + private var cameraAvailabilityCallback: CameraManager.AvailabilityCallback? = null + + private val timer = CountUpTimer(Duration.ofSeconds(1).toMillis()).apply { + tickListener = object : CountUpTimer.TickListener { + override fun onTick(milliseconds: Long) { + val formattedDuration = formatDuration(Duration.ofMillis(milliseconds)) + listeners.forEach { + tryOrNull { it.onTick(formattedDuration) } + } + } + } + } + + // Mute status + var micMuted = false + private set + var videoMuted = false + private set + var remoteOnHold = false + private set + var isLocalOnHold = false + private set + + // This value is used to track localOnHold when changing remoteOnHold value + private var wasLocalOnHold = false + + var offerSdp: CallInviteContent.Offer? = null + + var videoCapturerIsInError = false + set(value) { + field = value + listeners.forEach { + tryOrNull { it.onCaptureStateChanged() } + } + } + private var localSurfaceRenderers: MutableList> = ArrayList() + private var remoteSurfaceRenderers: MutableList> = ArrayList() + + private 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.sendLocalCallCandidates(it.mapToCallCandidate()) + } + } + + private val remoteCandidateSource: ReplaySubject = ReplaySubject.create() + private var remoteIceCandidateDisposable: Disposable? = null + + init { + mxCall.addListener(this) + } + + fun onIceCandidate(iceCandidate: IceCandidate) = iceCandidateSource.onNext(iceCandidate) + + fun onRenegotiationNeeded(restartIce: Boolean) { + GlobalScope.launch(dispatcher) { + if (mxCall.state != CallState.CreateOffer && mxCall.opponentVersion == 0) { + Timber.v("Opponent does not support renegotiation: ignoring onRenegotiationNeeded event") + return@launch + } + val constraints = MediaConstraints() + if (restartIce) { + constraints.mandatory.add(MediaConstraints.KeyValuePair("IceRestart", "true")) + } + val peerConnection = peerConnection ?: return@launch + Timber.v("## VOIP creating offer...") + makingOffer = true + try { + val sessionDescription = peerConnection.awaitCreateOffer(constraints) ?: return@launch + peerConnection.awaitSetLocalDescription(sessionDescription) + if (peerConnection.iceGatheringState() == PeerConnection.IceGatheringState.GATHERING) { + // Allow a short time for initial candidates to be gathered + delay(200) + } + if (mxCall.state == CallState.Terminated) { + return@launch + } + if (mxCall.state == CallState.CreateOffer) { + // send offer to peer + mxCall.offerSdp(sessionDescription.description) + } else { + mxCall.negotiate(sessionDescription.description, SdpType.OFFER) + } + } catch (failure: Throwable) { + // Need to handle error properly. + Timber.v("Failure while creating offer") + } finally { + makingOffer = false + } + } + } + + fun formattedDuration(): String { + return formatDuration( + Duration.ofMillis(timer.elapsedTime()) + ) + } + + private fun createPeerConnection(turnServerResponse: TurnServerResponse?) { + val peerConnectionFactory = peerConnectionFactoryProvider.get() ?: return + 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 ") + val rtcConfig = PeerConnection.RTCConfiguration(iceServers).apply { + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + } + peerConnection = peerConnectionFactory.createPeerConnection(rtcConfig, PeerConnectionObserver(this)) + } + + fun attachViewRenderers(localViewRenderer: SurfaceViewRenderer?, remoteViewRenderer: SurfaceViewRenderer, mode: String?) { + Timber.v("## VOIP attachViewRenderers localRendeder $localViewRenderer / $remoteViewRenderer") + localSurfaceRenderers.addIfNeeded(localViewRenderer) + remoteSurfaceRenderers.addIfNeeded(remoteViewRenderer) + + GlobalScope.launch(dispatcher) { + when (mode) { + VectorCallActivity.INCOMING_ACCEPT -> { + internalAcceptIncomingCall() + } + VectorCallActivity.INCOMING_RINGING -> { + // wait until accepted to create peer connection + // TODO eventually we could already display local stream in PIP? + } + VectorCallActivity.OUTGOING_CREATED -> { + setupOutgoingCall() + } + else -> { + // sink existing tracks (configuration change, e.g screen rotation) + attachViewRenderersInternal() + } + } + } + } + + fun acceptIncomingCall() { + GlobalScope.launch { + Timber.v("## VOIP acceptIncomingCall from state ${mxCall.state}") + if (mxCall.state == CallState.LocalRinging) { + internalAcceptIncomingCall() + } + } + } + + /** + * Sends a DTMF digit to the other party + * @param digit The digit (nb. string - '#' and '*' are dtmf too) + */ + fun sendDtmfDigit(digit: String) { + for (sender in peerConnection?.senders.orEmpty()) { + if (sender.track()?.kind() == "audio" && sender.dtmf()?.canInsertDtmf() == true) { + try { + sender.dtmf()?.insertDtmf(digit, 100, 70) + return + } catch (failure: Throwable) { + Timber.v("Fail to send Dtmf digit") + } + } + } + } + + fun detachRenderers(renderers: List?) { + Timber.v("## VOIP detachRenderers") + if (renderers.isNullOrEmpty()) { + // remove all sinks + localSurfaceRenderers.forEach { + if (it.get() != null) localVideoTrack?.removeSink(it.get()) + } + remoteSurfaceRenderers.forEach { + if (it.get() != null) remoteVideoTrack?.removeSink(it.get()) + } + localSurfaceRenderers.clear() + remoteSurfaceRenderers.clear() + } else { + renderers.forEach { + localSurfaceRenderers.removeIfNeeded(it) + remoteSurfaceRenderers.removeIfNeeded(it) + // no need to check if it's in the track, removeSink is doing it + localVideoTrack?.removeSink(it) + remoteVideoTrack?.removeSink(it) + } + } + } + + private suspend fun setupOutgoingCall() = withContext(dispatcher) { + tryOrNull { + onCallBecomeActive(this@WebRtcCall) + } + val turnServer = getTurnServer() + mxCall.state = CallState.CreateOffer + // 1. Create RTCPeerConnection + createPeerConnection(turnServer) + // 2. Access camera (if video call) + microphone, create local stream + createLocalStream() + attachViewRenderersInternal() + Timber.v("## VOIP remoteCandidateSource $remoteCandidateSource") + remoteIceCandidateDisposable = remoteCandidateSource.subscribe({ + Timber.v("## VOIP adding remote ice candidate $it") + peerConnection?.addIceCandidate(it) + }, { + Timber.v("## VOIP failed to add remote ice candidate $it") + }) + // Now we wait for negotiation callback + } + + private suspend fun internalAcceptIncomingCall() = withContext(dispatcher) { + tryOrNull { + onCallBecomeActive(this@WebRtcCall) + } + val turnServerResponse = getTurnServer() + // Update service state + withContext(Dispatchers.Main) { + CallService.onPendingCall( + context = context, + callId = mxCall.callId + ) + } + // 1) create peer connection + createPeerConnection(turnServerResponse) + + // create sdp using offer, and set remote description + // the offer has beed stored when invite was received + val offerSdp = offerSdp?.sdp?.let { + SessionDescription(SessionDescription.Type.OFFER, it) + } + if (offerSdp == null) { + Timber.v("We don't have any offer to process") + return@withContext + } + Timber.v("Offer sdp for invite: ${offerSdp.description}") + try { + peerConnection?.awaitSetRemoteDescription(offerSdp) + } catch (failure: Throwable) { + Timber.v("Failure putting remote description") + return@withContext + } + // 2) Access camera + microphone, create local stream + createLocalStream() + attachViewRenderersInternal() + + // create a answer, set local description and send via signaling + createAnswer()?.also { + mxCall.accept(it.description) + } + Timber.v("## VOIP remoteCandidateSource $remoteCandidateSource") + remoteIceCandidateDisposable = remoteCandidateSource.subscribe({ + Timber.v("## VOIP adding remote ice candidate $it") + peerConnection?.addIceCandidate(it) + }, { + Timber.v("## VOIP failed to add remote ice candidate $it") + }) + } + + private fun attachViewRenderersInternal() { + // render local video in pip view + localSurfaceRenderers.forEach { renderer -> + renderer.get()?.let { pipSurface -> + pipSurface.setMirror(this.cameraInUse?.type == CameraType.FRONT) + // no need to check if already added, addSink is checking that + localVideoTrack?.addSink(pipSurface) + } + } + + // If remote track exists, then sink it to surface + remoteSurfaceRenderers.forEach { renderer -> + renderer.get()?.let { participantSurface -> + remoteVideoTrack?.addSink(participantSurface) + } + } + } + + private suspend fun getTurnServer(): TurnServerResponse? { + return tryOrNull { + sessionProvider.get()?.callSignalingService()?.getTurnServer() + } + } + + private fun createLocalStream() { + val peerConnectionFactory = peerConnectionFactoryProvider.get() ?: return + Timber.v("Create local stream for call ${mxCall.callId}") + configureAudioTrack(peerConnectionFactory) + // add video track if needed + if (mxCall.isVideoCall) { + configureVideoTrack(peerConnectionFactory) + } + updateMuteStatus() + } + + private fun configureAudioTrack(peerConnectionFactory: PeerConnectionFactory) { + val audioSource = peerConnectionFactory.createAudioSource(DEFAULT_AUDIO_CONSTRAINTS) + val audioTrack = peerConnectionFactory.createAudioTrack(AUDIO_TRACK_ID, audioSource) + audioTrack.setEnabled(true) + Timber.v("Add audio track $AUDIO_TRACK_ID to call ${mxCall.callId}") + peerConnection?.addTrack(audioTrack, listOf(STREAM_ID)) + localAudioSource = audioSource + localAudioTrack = audioTrack + } + + private fun configureVideoTrack(peerConnectionFactory: PeerConnectionFactory) { + 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 } + + listeners.forEach { + tryOrNull { it.onCameraChanged() } + } + + if (camera != null) { + val videoCapturer = cameraIterator.createCapturer(camera.name, object : CameraEventsHandlerAdapter() { + override fun onFirstFrameAvailable() { + super.onFirstFrameAvailable() + videoCapturerIsInError = false + } + + override fun onCameraClosed() { + super.onCameraClosed() + Timber.v("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 + videoCapturerIsInError = true + val cameraManager = context.getSystemService() + cameraAvailabilityCallback = object : CameraManager.AvailabilityCallback() { + override fun onCameraUnavailable(cameraId: String) { + super.onCameraUnavailable(cameraId) + Timber.v("On camera unavailable: $cameraId") + } + + override fun onCameraAccessPrioritiesChanged() { + super.onCameraAccessPrioritiesChanged() + Timber.v("onCameraAccessPrioritiesChanged") + } + + override fun onCameraAvailable(cameraId: String) { + Timber.v("On camera available: $cameraId") + if (cameraId == camera.name) { + videoCapturer?.startCapture(currentCaptureFormat.width, currentCaptureFormat.height, currentCaptureFormat.fps) + cameraManager?.unregisterAvailabilityCallback(this) + } + } + } + cameraManager?.registerAvailabilityCallback(cameraAvailabilityCallback!!, 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, videoSource.capturerObserver) + // HD + videoCapturer.startCapture(currentCaptureFormat.width, currentCaptureFormat.height, currentCaptureFormat.fps) + this.videoCapturer = videoCapturer + + val videoTrack = peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, videoSource) + Timber.v("Add video track $VIDEO_TRACK_ID to call ${mxCall.callId}") + videoTrack.setEnabled(true) + peerConnection?.addTrack(videoTrack, listOf(STREAM_ID)) + localVideoSource = videoSource + localVideoTrack = videoTrack + } + } + + fun setCaptureFormat(format: CaptureFormat) { + Timber.v("## VOIP setCaptureFormat $format") + videoCapturer?.changeCaptureFormat(format.width, format.height, format.fps) + currentCaptureFormat = format + } + + private fun updateMuteStatus() { + val micShouldBeMuted = micMuted || remoteOnHold + localAudioTrack?.setEnabled(!micShouldBeMuted) + remoteAudioTrack?.setEnabled(!remoteOnHold) + val vidShouldBeMuted = videoMuted || remoteOnHold + localVideoTrack?.setEnabled(!vidShouldBeMuted) + remoteVideoTrack?.setEnabled(!remoteOnHold) + } + + /** + * Indicates whether we are 'on hold' to the remote party (ie. if true, + * they cannot hear us). Note that this will return true when we put the + * remote on hold too due to the way hold is implemented (since we don't + * wish to play hold music when we put a call on hold, we use 'inactive' + * rather than 'sendonly') + * @returns true if the other party has put us on hold + */ + private fun computeIsLocalOnHold(): Boolean { + if (mxCall.state !is CallState.Connected) return false + var callOnHold = true + // We consider a call to be on hold only if *all* the tracks are on hold + // (is this the right thing to do?) + for (transceiver in peerConnection?.transceivers ?: emptyList()) { + val trackOnHold = transceiver.currentDirection == RtpTransceiver.RtpTransceiverDirection.INACTIVE + || transceiver.currentDirection == RtpTransceiver.RtpTransceiverDirection.RECV_ONLY + if (!trackOnHold) callOnHold = false + } + return callOnHold + } + + fun updateRemoteOnHold(onHold: Boolean) { + GlobalScope.launch(dispatcher) { + if (remoteOnHold == onHold) return@launch + val direction: RtpTransceiver.RtpTransceiverDirection + if (onHold) { + wasLocalOnHold = isLocalOnHold + remoteOnHold = true + isLocalOnHold = true + direction = RtpTransceiver.RtpTransceiverDirection.INACTIVE + timer.pause() + } else { + remoteOnHold = false + isLocalOnHold = wasLocalOnHold + onCallBecomeActive(this@WebRtcCall) + direction = RtpTransceiver.RtpTransceiverDirection.SEND_RECV + if (!isLocalOnHold) { + timer.resume() + } + } + for (transceiver in peerConnection?.transceivers ?: emptyList()) { + transceiver.direction = direction + } + updateMuteStatus() + listeners.forEach { + tryOrNull { it.onHoldUnhold() } + } + } + } + + fun muteCall(muted: Boolean) { + micMuted = muted + updateMuteStatus() + } + + fun enableVideo(enabled: Boolean) { + videoMuted = !enabled + updateMuteStatus() + } + + fun canSwitchCamera(): Boolean { + return availableCamera.size > 1 + } + + private fun getOppositeCameraIfAny(): CameraProxy? { + val currentCamera = cameraInUse ?: return null + return if (currentCamera.type == CameraType.FRONT) { + availableCamera.firstOrNull { it.type == CameraType.BACK } + } else { + availableCamera.firstOrNull { it.type == CameraType.FRONT } + } + } + + fun switchCamera() { + Timber.v("## VOIP switchCamera") + if (mxCall.state is CallState.Connected && mxCall.isVideoCall) { + val oppositeCamera = getOppositeCameraIfAny() ?: return + 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 = oppositeCamera + localSurfaceRenderers.forEach { + it.get()?.setMirror(isFrontCamera) + } + listeners.forEach { + tryOrNull { it.onCameraChanged() } + } + } + + override fun onCameraSwitchError(errorDescription: String?) { + Timber.v("## VOIP onCameraSwitchError isFront $errorDescription") + } + }, oppositeCamera.name + ) + } + } + + private suspend fun createAnswer(): SessionDescription? { + Timber.w("## VOIP createAnswer") + val peerConnection = peerConnection ?: return null + val constraints = MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", if (mxCall.isVideoCall) "true" else "false")) + } + return try { + val localDescription = peerConnection.awaitCreateAnswer(constraints) ?: return null + peerConnection.awaitSetLocalDescription(localDescription) + localDescription + } catch (failure: Throwable) { + Timber.v("Fail to create answer") + null + } + } + + fun currentCameraType(): CameraType? { + return cameraInUse?.type + } + + fun currentCaptureFormat(): CaptureFormat { + return currentCaptureFormat + } + + private fun release() { + listeners.clear() + mxCall.removeListener(this) + timer.stop() + timer.tickListener = null + videoCapturer?.stopCapture() + videoCapturer?.dispose() + videoCapturer = null + remoteIceCandidateDisposable?.dispose() + iceCandidateDisposable?.dispose() + peerConnection?.close() + peerConnection?.dispose() + localAudioSource?.dispose() + localVideoSource?.dispose() + localAudioSource = null + localAudioTrack = null + localVideoSource = null + localVideoTrack = null + cameraAvailabilityCallback = null + } + + fun onAddStream(stream: MediaStream) { + GlobalScope.launch(dispatcher) { + // 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?? + mxCall.hangUp() + return@launch + } + if (stream.audioTracks.size == 1) { + val remoteAudioTrack = stream.audioTracks.first() + remoteAudioTrack.setEnabled(true) + this@WebRtcCall.remoteAudioTrack = remoteAudioTrack + } + if (stream.videoTracks.size == 1) { + val remoteVideoTrack = stream.videoTracks.first() + remoteVideoTrack.setEnabled(true) + this@WebRtcCall.remoteVideoTrack = remoteVideoTrack + // sink to renderer if attached + remoteSurfaceRenderers.forEach { it.get()?.let { remoteVideoTrack.addSink(it) } } + } + } + } + + fun onRemoveStream() { + GlobalScope.launch(dispatcher) { + remoteSurfaceRenderers + .mapNotNull { it.get() } + .forEach { remoteVideoTrack?.removeSink(it) } + remoteVideoTrack = null + remoteAudioTrack = null + } + } + + fun endCall(originatedByMe: Boolean = true, reason: CallHangupContent.Reason? = null) { + if (mxCall.state == CallState.Terminated) { + return + } + // Close tracks ASAP + localVideoTrack?.setEnabled(false) + localVideoTrack?.setEnabled(false) + cameraAvailabilityCallback?.let { cameraAvailabilityCallback -> + val cameraManager = context.getSystemService()!! + cameraManager.unregisterAvailabilityCallback(cameraAvailabilityCallback) + } + val wasRinging = mxCall.state is CallState.LocalRinging + mxCall.state = CallState.Terminated + GlobalScope.launch(dispatcher) { + release() + } + onCallEnded(callId) + if (originatedByMe) { + if (wasRinging) { + mxCall.reject() + } else { + mxCall.hangUp(reason) + } + } + } + + // Call listener + + fun onCallIceCandidateReceived(iceCandidatesContent: CallCandidatesContent) { + GlobalScope.launch(dispatcher) { + iceCandidatesContent.candidates.forEach { + if (it.sdpMid.isNullOrEmpty() || it.candidate.isNullOrEmpty()) { + return@forEach + } + Timber.v("## VOIP onCallIceCandidateReceived for call ${mxCall.callId} sdp: ${it.candidate}") + val iceCandidate = IceCandidate(it.sdpMid, it.sdpMLineIndex, it.candidate) + remoteCandidateSource.onNext(iceCandidate) + } + } + } + + fun onCallAnswerReceived(callAnswerContent: CallAnswerContent) { + GlobalScope.launch(dispatcher) { + Timber.v("## VOIP onCallAnswerReceived ${callAnswerContent.callId}") + val sdp = SessionDescription(SessionDescription.Type.ANSWER, callAnswerContent.answer.sdp) + try { + peerConnection?.awaitSetRemoteDescription(sdp) + } catch (failure: Throwable) { + endCall(true, CallHangupContent.Reason.UNKWOWN_ERROR) + return@launch + } + if (mxCall.opponentPartyId?.hasValue().orFalse()) { + mxCall.selectAnswer() + } + } + } + + fun onCallNegotiateReceived(callNegotiateContent: CallNegotiateContent) { + GlobalScope.launch(dispatcher) { + val description = callNegotiateContent.description + val type = description?.type + val sdpText = description?.sdp + if (type == null || sdpText == null) { + Timber.i("Ignoring invalid m.call.negotiate event") + return@launch + } + val peerConnection = peerConnection ?: return@launch + // Politeness always follows the direction of the call: in a glare situation, + // we pick either the inbound or outbound call, so one side will always be + // inbound and one outbound + val polite = !mxCall.isOutgoing + // Here we follow the perfect negotiation logic from + // https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation + val offerCollision = description.type == SdpType.OFFER + && (makingOffer || peerConnection.signalingState() != PeerConnection.SignalingState.STABLE) + + ignoreOffer = !polite && offerCollision + if (ignoreOffer) { + Timber.i("Ignoring colliding negotiate event because we're impolite") + return@launch + } + val prevOnHold = computeIsLocalOnHold() + try { + val sdp = SessionDescription(type.asWebRTC(), sdpText) + peerConnection.awaitSetRemoteDescription(sdp) + if (type == SdpType.OFFER) { + createAnswer()?.also { + mxCall.negotiate(it.description, SdpType.ANSWER) + } + } + } catch (failure: Throwable) { + Timber.e(failure, "Failed to complete negotiation") + } + val nowOnHold = computeIsLocalOnHold() + wasLocalOnHold = nowOnHold + if (prevOnHold != nowOnHold) { + isLocalOnHold = nowOnHold + if (nowOnHold) { + timer.pause() + } else { + timer.resume() + } + listeners.forEach { + tryOrNull { it.onHoldUnhold() } + } + } + } + } + + private fun formatDuration(duration: Duration): String { + val hours = duration.seconds / 3600 + val minutes = (duration.seconds % 3600) / 60 + val seconds = duration.seconds % 60 + return if (hours > 0) { + String.format("%d:%02d:%02d", hours, minutes, seconds) + } else { + String.format("%02d:%02d", minutes, seconds) + } + } + + // MxCall.StateListener + + override fun onStateUpdate(call: MxCall) { + val state = call.state + if (state is CallState.Connected && state.iceConnectionState == MxPeerConnectionState.CONNECTED) { + timer.resume() + } else { + timer.pause() + } + listeners.forEach { + tryOrNull { it.onStateUpdate(call) } + } + } +} + +private fun MutableList>.addIfNeeded(renderer: SurfaceViewRenderer?) { + if (renderer == null) return + val exists = any { + it.get() == renderer + } + if (!exists) { + add(WeakReference(renderer)) + } +} + +private fun MutableList>.removeIfNeeded(renderer: SurfaceViewRenderer?) { + if (renderer == null) return + removeAll { + it.get() == renderer + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt new file mode 100644 index 0000000000..2f8f84051e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt @@ -0,0 +1,400 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.webrtc + +import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import im.vector.app.ActiveSessionDataSource +import im.vector.app.core.services.CallService +import im.vector.app.features.call.VectorCallActivity +import im.vector.app.features.call.audio.CallAudioManager +import im.vector.app.features.call.utils.EglUtils +import im.vector.app.push.fcm.FcmHelper +import kotlinx.coroutines.asCoroutineDispatcher +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.call.CallListener +import org.matrix.android.sdk.api.session.call.CallState +import org.matrix.android.sdk.api.session.call.MxCall +import org.matrix.android.sdk.api.session.call.PSTNProtocolChecker +import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent +import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent +import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent +import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent +import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent +import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent +import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.PeerConnectionFactory +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference +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 WebRtcCallManager @Inject constructor( + private val context: Context, + private val activeSessionDataSource: ActiveSessionDataSource +) : CallListener, LifecycleObserver { + + private val currentSession: Session? + get() = activeSessionDataSource.currentValue?.orNull() + + private val pstnProtocolChecker: PSTNProtocolChecker? + get() = currentSession?.callSignalingService()?.getPSTNProtocolChecker() + + interface CurrentCallListener { + fun onCurrentCallChange(call: WebRtcCall?) {} + fun onAudioDevicesChange() {} + } + + val supportedPSTNProtocol: String? + get() = pstnProtocolChecker?.supportedPSTNProtocol + + val supportsPSTNProtocol: Boolean + get() = supportedPSTNProtocol != null + + fun addPstnSupportListener(listener: PSTNProtocolChecker.Listener) { + pstnProtocolChecker?.addListener(listener) + } + + fun removePstnSupportListener(listener: PSTNProtocolChecker.Listener) { + pstnProtocolChecker?.removeListener(listener) + } + + private val currentCallsListeners = CopyOnWriteArrayList() + + fun addCurrentCallListener(listener: CurrentCallListener) { + currentCallsListeners.add(listener) + } + + fun removeCurrentCallListener(listener: CurrentCallListener) { + currentCallsListeners.remove(listener) + } + + val audioManager = CallAudioManager(context) { + currentCallsListeners.forEach { + tryOrNull { it.onAudioDevicesChange() } + } + }.apply { + setMode(CallAudioManager.Mode.DEFAULT) + } + + private var peerConnectionFactory: PeerConnectionFactory? = null + private val executor = Executors.newSingleThreadExecutor() + private val dispatcher = executor.asCoroutineDispatcher() + + private val rootEglBase by lazy { EglUtils.rootEglBase } + + private var isInBackground: Boolean = true + + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) + fun entersForeground() { + isInBackground = false + } + + @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) + fun entersBackground() { + isInBackground = true + } + + /** + * The current call is the call we interacted with whatever his state (connected,resumed, held...) + * As soon as we interact with an other call, it replaces this one and put it on held if not already. + */ + var currentCall: AtomicReference = AtomicReference(null) + private fun AtomicReference.setAndNotify(newValue: WebRtcCall?) { + set(newValue) + currentCallsListeners.forEach { + tryOrNull { it.onCurrentCallChange(newValue) } + } + } + + private val advertisedCalls = HashSet() + private val callsByCallId = ConcurrentHashMap() + private val callsByRoomId = ConcurrentHashMap>() + + fun getCallById(callId: String): WebRtcCall? { + return callsByCallId[callId] + } + + fun getCallsByRoomId(roomId: String): List { + return callsByRoomId[roomId] ?: emptyList() + } + + fun getCurrentCall(): WebRtcCall? { + return currentCall.get() + } + + fun getCalls(): List { + return callsByCallId.values.toList() + } + + fun checkForPSTNSupportIfNeeded() { + pstnProtocolChecker?.checkForPSTNSupportIfNeeded() + } + + /** + * @return a set of all advertised call during the lifetime of the app. + */ + fun getAdvertisedCalls() = advertisedCalls + + fun headSetButtonTapped() { + Timber.v("## VOIP headSetButtonTapped") + val call = getCurrentCall() ?: return + if (call.mxCall.state is CallState.LocalRinging) { + call.acceptIncomingCall() + } + if (call.mxCall.state is CallState.Connected) { + // end call? + call.endCall() + } + } + + private fun createPeerConnectionFactoryIfNeeded() { + 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() + } + + private fun onCallActive(call: WebRtcCall) { + Timber.v("## VOIP WebRtcPeerConnectionManager onCall active: ${call.mxCall.callId}") + val currentCall = getCurrentCall().takeIf { it != call } + currentCall?.updateRemoteOnHold(onHold = true) + audioManager.setMode(if (call.mxCall.isVideoCall) CallAudioManager.Mode.VIDEO_CALL else CallAudioManager.Mode.AUDIO_CALL) + this.currentCall.setAndNotify(call) + } + + private fun onCallEnded(callId: String) { + Timber.v("## VOIP WebRtcPeerConnectionManager onCall ended: $callId") + val webRtcCall = callsByCallId.remove(callId) ?: return Unit.also { + Timber.v("On call ended for unknown call $callId") + } + CallService.onCallTerminated(context, callId) + callsByRoomId[webRtcCall.roomId]?.remove(webRtcCall) + if (getCurrentCall()?.callId == callId) { + val otherCall = getCalls().lastOrNull() + currentCall.setAndNotify(otherCall) + } + // This must be done in this thread + executor.execute { + // There is no active calls + if (getCurrentCall() == null) { + Timber.v("## VOIP Dispose peerConnectionFactory as there is no need to keep one") + peerConnectionFactory?.dispose() + peerConnectionFactory = null + audioManager.setMode(CallAudioManager.Mode.DEFAULT) + // did we start background sync? so we should stop it + if (isInBackground) { + if (FcmHelper.isPushSupported()) { + currentSession?.stopAnyBackgroundSync() + } else { + // for fdroid we should not stop, it should continue syncing + // maybe we should restore default timeout/delay though? + } + } + } + Timber.v("## VOIP WebRtcPeerConnectionManager close() executor done") + } + } + + fun startOutgoingCall(signalingRoomId: String, otherUserId: String, isVideoCall: Boolean) { + Timber.v("## VOIP startOutgoingCall in room $signalingRoomId to $otherUserId isVideo $isVideoCall") + if (getCallsByRoomId(signalingRoomId).isNotEmpty()) { + Timber.w("## VOIP you already have a call in this room") + return + } + if (getCurrentCall() != null && getCurrentCall()?.mxCall?.state !is CallState.Connected || getCalls().size >= 2) { + Timber.w("## VOIP cannot start outgoing call") + // Just ignore, maybe we could answer from other session? + return + } + executor.execute { + createPeerConnectionFactoryIfNeeded() + } + getCurrentCall()?.updateRemoteOnHold(onHold = true) + val mxCall = currentSession?.callSignalingService()?.createOutgoingCall(signalingRoomId, otherUserId, isVideoCall) ?: return + val webRtcCall = createWebRtcCall(mxCall) + currentCall.setAndNotify(webRtcCall) + + CallService.onOutgoingCallRinging( + context = context.applicationContext, + callId = mxCall.callId) + + // start the activity now + context.startActivity(VectorCallActivity.newIntent(context, mxCall, VectorCallActivity.OUTGOING_CREATED)) + } + + override fun onCallIceCandidateReceived(mxCall: MxCall, iceCandidatesContent: CallCandidatesContent) { + Timber.v("## VOIP onCallIceCandidateReceived for call ${mxCall.callId}") + val call = callsByCallId[iceCandidatesContent.callId] + ?: return Unit.also { + Timber.w("onCallIceCandidateReceived for non active call? ${iceCandidatesContent.callId}") + } + call.onCallIceCandidateReceived(iceCandidatesContent) + } + + private fun createWebRtcCall(mxCall: MxCall): WebRtcCall { + val webRtcCall = WebRtcCall( + mxCall = mxCall, + rootEglBase = rootEglBase, + context = context, + dispatcher = dispatcher, + peerConnectionFactoryProvider = { + createPeerConnectionFactoryIfNeeded() + peerConnectionFactory + }, + sessionProvider = { currentSession }, + onCallBecomeActive = this::onCallActive, + onCallEnded = this::onCallEnded + ) + advertisedCalls.add(mxCall.callId) + callsByCallId[mxCall.callId] = webRtcCall + callsByRoomId.getOrPut(mxCall.roomId) { ArrayList(1) } + .add(webRtcCall) + if (getCurrentCall() == null) { + currentCall.setAndNotify(webRtcCall) + } + return webRtcCall + } + + fun endCallForRoom(roomId: String, originatedByMe: Boolean = true) { + callsByRoomId[roomId]?.forEach { it.endCall(originatedByMe) } + } + + override fun onCallInviteReceived(mxCall: MxCall, callInviteContent: CallInviteContent) { + Timber.v("## VOIP onCallInviteReceived callId ${mxCall.callId}") + if (getCallsByRoomId(mxCall.roomId).isNotEmpty()) { + Timber.w("## VOIP you already have a call in this room") + return + } + if ((getCurrentCall() != null && getCurrentCall()?.mxCall?.state !is CallState.Connected) || getCalls().size >= 2) { + Timber.w("## VOIP receiving incoming call but cannot handle it") + // Just ignore, maybe we could answer from other session? + return + } + createWebRtcCall(mxCall).apply { + offerSdp = callInviteContent.offer + } + // Start background service with notification + CallService.onIncomingCallRinging( + context = context, + callId = mxCall.callId, + isInBackground = isInBackground + ) + // If this is received while in background, the app will not sync, + // and thus won't be able to received events. For example if the call is + // accepted on an other session this device will continue ringing + if (isInBackground) { + if (FcmHelper.isPushSupported()) { + // only for push version as fdroid version is already doing it? + currentSession?.startAutomaticBackgroundSync(30, 0) + } else { + // Maybe increase sync freq? but how to set back to default values? + } + } + } + + override fun onCallAnswerReceived(callAnswerContent: CallAnswerContent) { + val call = callsByCallId[callAnswerContent.callId] + ?: return Unit.also { + Timber.w("onCallAnswerReceived for non active call? ${callAnswerContent.callId}") + } + val mxCall = call.mxCall + // Update service state + CallService.onPendingCall( + context = context, + callId = mxCall.callId + ) + call.onCallAnswerReceived(callAnswerContent) + } + + override fun onCallHangupReceived(callHangupContent: CallHangupContent) { + val call = callsByCallId[callHangupContent.callId] + ?: return Unit.also { + Timber.w("onCallHangupReceived for non active call? ${callHangupContent.callId}") + } + call.endCall(false) + } + + override fun onCallRejectReceived(callRejectContent: CallRejectContent) { + val call = callsByCallId[callRejectContent.callId] + ?: return Unit.also { + Timber.w("onCallRejectReceived for non active call? ${callRejectContent.callId}") + } + call.endCall(false) + } + + override fun onCallSelectAnswerReceived(callSelectAnswerContent: CallSelectAnswerContent) { + val call = callsByCallId[callSelectAnswerContent.callId] + ?: return Unit.also { + Timber.w("onCallSelectAnswerReceived for non active call? ${callSelectAnswerContent.callId}") + } + val selectedPartyId = callSelectAnswerContent.selectedPartyId + if (selectedPartyId != call.mxCall.ourPartyId) { + Timber.i("Got select_answer for party ID $selectedPartyId: we are party ID ${call.mxCall.ourPartyId}.") + // The other party has picked somebody else's answer + call.endCall(false) + } + } + + override fun onCallNegotiateReceived(callNegotiateContent: CallNegotiateContent) { + val call = callsByCallId[callNegotiateContent.callId] + ?: return Unit.also { + Timber.w("onCallNegotiateReceived for non active call? ${callNegotiateContent.callId}") + } + call.onCallNegotiateReceived(callNegotiateContent) + } + + override fun onCallManagedByOtherSession(callId: String) { + Timber.v("## VOIP onCallManagedByOtherSession: $callId") + onCallEnded(callId) + } +} diff --git a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt index 6aaa69fbc0..68e169b8c5 100644 --- a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt +++ b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt @@ -32,7 +32,7 @@ import im.vector.app.core.extensions.configureWith import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentContactsBookBinding -import im.vector.app.features.userdirectory.PendingInvitee +import im.vector.app.features.userdirectory.PendingSelection import im.vector.app.features.userdirectory.UserListAction import im.vector.app.features.userdirectory.UserListSharedAction import im.vector.app.features.userdirectory.UserListSharedActionViewModel @@ -44,9 +44,9 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject class ContactsBookFragment @Inject constructor( - val contactsBookViewModelFactory: ContactsBookViewModel.Factory, + private val contactsBookViewModelFactory: ContactsBookViewModel.Factory, private val contactsBookController: ContactsBookController -) : VectorBaseFragment(), ContactsBookController.Callback { +) : VectorBaseFragment(), ContactsBookController.Callback, ContactsBookViewModel.Factory { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentContactsBookBinding { return FragmentContactsBookBinding.inflate(inflater, container, false) @@ -59,6 +59,10 @@ class ContactsBookFragment @Inject constructor( private lateinit var sharedActionViewModel: UserListSharedActionViewModel + override fun create(initialState: ContactsBookViewState): ContactsBookViewModel { + return contactsBookViewModelFactory.create(initialState) + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) sharedActionViewModel = activityViewModelProvider.get(UserListSharedActionViewModel::class.java) @@ -128,13 +132,13 @@ class ContactsBookFragment @Inject constructor( override fun onMatrixIdClick(matrixId: String) { view?.hideKeyboard() - viewModel.handle(UserListAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(User(matrixId)))) + viewModel.handle(UserListAction.AddPendingSelection(PendingSelection.UserPendingSelection(User(matrixId)))) sharedActionViewModel.post(UserListSharedAction.GoBack) } override fun onThreePidClick(threePid: ThreePid) { view?.hideKeyboard() - viewModel.handle(UserListAction.SelectPendingInvitee(PendingInvitee.ThreePidPendingInvitee(threePid))) + viewModel.handle(UserListAction.AddPendingSelection(PendingSelection.ThreePidPendingSelection(threePid))) sharedActionViewModel.post(UserListSharedAction.GoBack) } } diff --git a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewModel.kt b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewModel.kt index d73794f8d8..05af63d7ba 100644 --- a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewModel.kt @@ -16,7 +16,6 @@ package im.vector.app.features.contactsbook -import androidx.fragment.app.FragmentActivity import androidx.lifecycle.viewModelScope import com.airbnb.mvrx.ActivityViewModelContext import com.airbnb.mvrx.FragmentViewModelContext @@ -32,8 +31,6 @@ import im.vector.app.core.contacts.MappedContact import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.VectorViewModel -import im.vector.app.features.createdirect.CreateDirectRoomActivity -import im.vector.app.features.invite.InviteUsersToRoomActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.matrix.android.sdk.api.MatrixCallback @@ -57,17 +54,11 @@ class ContactsBookViewModel @AssistedInject constructor(@Assisted companion object : MvRxViewModelFactory { override fun create(viewModelContext: ViewModelContext, state: ContactsBookViewState): ContactsBookViewModel? { - return when (viewModelContext) { - is FragmentViewModelContext -> (viewModelContext.fragment() as ContactsBookFragment).contactsBookViewModelFactory.create(state) - is ActivityViewModelContext -> { - when (viewModelContext.activity()) { - is CreateDirectRoomActivity -> viewModelContext.activity().contactsBookViewModelFactory.create(state) - is InviteUsersToRoomActivity -> viewModelContext.activity().contactsBookViewModelFactory.create(state) - else -> error("Wrong activity or fragment") - } - } - else -> error("Wrong activity or fragment") + val factory = when (viewModelContext) { + is FragmentViewModelContext -> viewModelContext.fragment as? Factory + is ActivityViewModelContext -> viewModelContext.activity as? Factory } + return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface") } } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt index ce91761fdd..ffc25210e9 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt @@ -17,11 +17,11 @@ package im.vector.app.features.createdirect import im.vector.app.core.platform.VectorViewModelAction -import im.vector.app.features.userdirectory.PendingInvitee +import im.vector.app.features.userdirectory.PendingSelection sealed class CreateDirectRoomAction : VectorViewModelAction { data class CreateRoomAndInviteSelectedUsers( - val invitees: Set, + val selections: Set, val existingDmRoomId: String? ) : CreateDirectRoomAction() } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt index beb7931fd4..4f81841b73 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt @@ -45,6 +45,7 @@ import im.vector.app.core.utils.checkPermissions import im.vector.app.core.utils.onPermissionDeniedSnackbar import im.vector.app.features.contactsbook.ContactsBookFragment import im.vector.app.features.contactsbook.ContactsBookViewModel +import im.vector.app.features.contactsbook.ContactsBookViewState import im.vector.app.features.userdirectory.UserListFragment import im.vector.app.features.userdirectory.UserListFragmentArgs import im.vector.app.features.userdirectory.UserListSharedAction @@ -57,7 +58,7 @@ import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure import java.net.HttpURLConnection import javax.inject.Inject -class CreateDirectRoomActivity : SimpleFragmentActivity(), UserListViewModel.Factory { +class CreateDirectRoomActivity : SimpleFragmentActivity(), UserListViewModel.Factory, CreateDirectRoomViewModel.Factory, ContactsBookViewModel.Factory { private val viewModel: CreateDirectRoomViewModel by viewModel() private lateinit var sharedActionViewModel: UserListSharedActionViewModel @@ -71,9 +72,11 @@ class CreateDirectRoomActivity : SimpleFragmentActivity(), UserListViewModel.Fac injector.inject(this) } - override fun create(initialState: UserListViewState): UserListViewModel { - return userListViewModelFactory.create(initialState) - } + override fun create(initialState: UserListViewState) = userListViewModelFactory.create(initialState) + + override fun create(initialState: CreateDirectRoomViewState) = createDirectRoomViewModelFactory.create(initialState) + + override fun create(initialState: ContactsBookViewState) = contactsBookViewModelFactory.create(initialState) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -143,7 +146,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity(), UserListViewModel.Fac private fun onMenuItemSelected(action: UserListSharedAction.OnMenuItemSelected) { if (action.itemId == R.id.action_create_direct_room) { viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers( - action.invitees, + action.selections, null )) } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt index 94578ed5c7..92a03c5483 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt @@ -29,8 +29,7 @@ import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO import im.vector.app.core.utils.checkPermissions import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.databinding.FragmentQrCodeScannerBinding -import im.vector.app.features.userdirectory.PendingInvitee - +import im.vector.app.features.userdirectory.PendingSelection import me.dm7.barcodescanner.zxing.ZXingScannerView import org.matrix.android.sdk.api.session.permalinks.PermalinkData import org.matrix.android.sdk.api.session.permalinks.PermalinkParser @@ -107,7 +106,7 @@ class CreateDirectRoomByQrCodeFragment @Inject constructor() : VectorBaseFragmen val qrInvitee = if (viewModel.session.getUser(mxid) != null) viewModel.session.getUser(mxid)!! else User(mxid, null, null) viewModel.handle( - CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(setOf(PendingInvitee.UserPendingInvitee(qrInvitee)), existingDm) + CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(setOf(PendingSelection.UserPendingSelection(qrInvitee)), existingDm) ) } } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt index 30bbedf7ec..cbe363aa0e 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt @@ -18,6 +18,7 @@ package im.vector.app.features.createdirect import androidx.lifecycle.viewModelScope import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.Success import com.airbnb.mvrx.ViewModelContext @@ -28,7 +29,7 @@ import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.raw.wellknown.getElementWellknown import im.vector.app.features.raw.wellknown.isE2EByDefault -import im.vector.app.features.userdirectory.PendingInvitee +import im.vector.app.features.userdirectory.PendingSelection import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.matrix.android.sdk.api.raw.RawService @@ -51,8 +52,11 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted @JvmStatic override fun create(viewModelContext: ViewModelContext, state: CreateDirectRoomViewState): CreateDirectRoomViewModel? { - val activity: CreateDirectRoomActivity = (viewModelContext as ActivityViewModelContext).activity() - return activity.createDirectRoomViewModelFactory.create(state) + val factory = when (viewModelContext) { + is FragmentViewModelContext -> viewModelContext.fragment as? Factory + is ActivityViewModelContext -> viewModelContext.activity as? Factory + } + return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface") } } @@ -73,11 +77,11 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted } } else { // Create the DM - createRoomAndInviteSelectedUsers(action.invitees) + createRoomAndInviteSelectedUsers(action.selections) } } - private fun createRoomAndInviteSelectedUsers(invitees: Set) { + private fun createRoomAndInviteSelectedUsers(selections: Set) { viewModelScope.launch(Dispatchers.IO) { val adminE2EByDefault = rawService.getElementWellknown(session.myUserId) ?.isE2EByDefault() @@ -85,10 +89,10 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted val roomParams = CreateRoomParams() .apply { - invitees.forEach { + selections.forEach { when (it) { - is PendingInvitee.UserPendingInvitee -> invitedUserIds.add(it.user.userId) - is PendingInvitee.ThreePidPendingInvitee -> invite3pids.add(it.threePid) + is PendingSelection.UserPendingSelection -> invitedUserIds.add(it.user.userId) + is PendingSelection.ThreePidPendingSelection -> invite3pids.add(it.threePid) }.exhaustive } setDirectMessage() diff --git a/vector/src/main/java/im/vector/app/features/createdirect/DirectRoomHelper.kt b/vector/src/main/java/im/vector/app/features/createdirect/DirectRoomHelper.kt new file mode 100644 index 0000000000..171970ec1e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/createdirect/DirectRoomHelper.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.createdirect + +import im.vector.app.features.raw.wellknown.getElementWellknown +import im.vector.app.features.raw.wellknown.isE2EByDefault +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.raw.RawService +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.internal.util.awaitCallback +import javax.inject.Inject + +class DirectRoomHelper @Inject constructor( + private val rawService: RawService, + private val session: Session +) { + + suspend fun ensureDMExists(userId: String): String { + val existingRoomId = tryOrNull { session.getExistingDirectRoomWithUser(userId) } + val roomId: String + if (existingRoomId != null) { + roomId = existingRoomId + } else { + val adminE2EByDefault = rawService.getElementWellknown(session.myUserId) + ?.isE2EByDefault() + ?: true + + val roomParams = CreateRoomParams().apply { + invitedUserIds.add(userId) + setDirectMessage() + enableEncryptionIfInvitedUsersSupportIt = adminE2EByDefault + } + roomId = awaitCallback { + session.createRoom(roomParams, it) + } + } + return roomId + } +} diff --git a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/restore/KeysBackupRestoreActivity.kt b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/restore/KeysBackupRestoreActivity.kt index 80ae46262d..20ecbb4e5a 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/restore/KeysBackupRestoreActivity.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/restore/KeysBackupRestoreActivity.kt @@ -19,7 +19,6 @@ import android.app.Activity import android.content.Context import android.content.Intent import androidx.appcompat.app.AlertDialog -import androidx.lifecycle.Observer import im.vector.app.R import im.vector.app.core.extensions.addFragmentToBackstack import im.vector.app.core.extensions.observeEvent @@ -54,7 +53,7 @@ class KeysBackupRestoreActivity : SimpleFragmentActivity() { viewModel = viewModelProvider.get(KeysBackupRestoreSharedViewModel::class.java) viewModel.initSession(session) - viewModel.keySourceModel.observe(this, Observer { keySource -> + viewModel.keySourceModel.observe(this) { keySource -> if (keySource != null && !keySource.isInQuadS && supportFragmentManager.fragments.isEmpty()) { val isBackupCreatedFromPassphrase = viewModel.keyVersionResult.value?.getAuthDataAsMegolmBackupAuthData()?.privateKeySalt != null @@ -64,7 +63,7 @@ class KeysBackupRestoreActivity : SimpleFragmentActivity() { replaceFragment(R.id.container, KeysBackupRestoreFromKeyFragment::class.java) } } - }) + } viewModel.keyVersionResultError.observeEvent(this) { message -> AlertDialog.Builder(this) @@ -111,9 +110,9 @@ class KeysBackupRestoreActivity : SimpleFragmentActivity() { } } - viewModel.loadingEvent.observe(this, Observer { + viewModel.loadingEvent.observe(this) { updateWaitingView(it) - }) + } viewModel.importRoomKeysFinishWithResult.observeEvent(this) { // set data? diff --git a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/restore/KeysBackupRestoreFromKeyFragment.kt b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/restore/KeysBackupRestoreFromKeyFragment.kt index 2887a29776..1f8cf4d3a0 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/restore/KeysBackupRestoreFromKeyFragment.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/restore/KeysBackupRestoreFromKeyFragment.kt @@ -22,7 +22,6 @@ import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import androidx.core.widget.doOnTextChanged -import androidx.lifecycle.Observer import im.vector.app.R import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.platform.VectorBaseFragment @@ -56,9 +55,9 @@ class KeysBackupRestoreFromKeyFragment @Inject constructor() } views.keyInputLayout.error = viewModel.recoveryCodeErrorText.value - viewModel.recoveryCodeErrorText.observe(viewLifecycleOwner, Observer { newValue -> + viewModel.recoveryCodeErrorText.observe(viewLifecycleOwner) { newValue -> views.keyInputLayout.error = newValue - }) + } views.keysRestoreButton.setOnClickListener { onRestoreFromKey() } views.keysBackupImport.setOnClickListener { onImport() } diff --git a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/restore/KeysBackupRestoreFromPassphraseFragment.kt b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/restore/KeysBackupRestoreFromPassphraseFragment.kt index 6c139aaf45..0f0c9a23c7 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/restore/KeysBackupRestoreFromPassphraseFragment.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/restore/KeysBackupRestoreFromPassphraseFragment.kt @@ -24,7 +24,6 @@ import android.view.ViewGroup import android.view.inputmethod.EditorInfo import androidx.core.text.set import androidx.core.widget.doOnTextChanged -import androidx.lifecycle.Observer import im.vector.app.R import im.vector.app.core.extensions.showPassword import im.vector.app.core.platform.VectorBaseFragment @@ -51,17 +50,17 @@ class KeysBackupRestoreFromPassphraseFragment @Inject constructor() : VectorBase viewModel = fragmentViewModelProvider.get(KeysBackupRestoreFromPassphraseViewModel::class.java) sharedViewModel = activityViewModelProvider.get(KeysBackupRestoreSharedViewModel::class.java) - viewModel.passphraseErrorText.observe(viewLifecycleOwner, Observer { newValue -> + viewModel.passphraseErrorText.observe(viewLifecycleOwner) { newValue -> views.keysBackupPassphraseEnterTil.error = newValue - }) + } views.helperTextWithLink.text = spannableStringForHelperText() - viewModel.showPasswordMode.observe(viewLifecycleOwner, Observer { + viewModel.showPasswordMode.observe(viewLifecycleOwner) { val shouldBeVisible = it ?: false views.keysBackupPassphraseEnterEdittext.showPassword(shouldBeVisible) - views.keysBackupViewShowPassword.setImageResource(if (shouldBeVisible) R.drawable.ic_eye_closed else R.drawable.ic_eye) - }) + views.keysBackupViewShowPassword.render(shouldBeVisible) + } views.keysBackupPassphraseEnterEdittext.setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_DONE) { diff --git a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt index ab8e725959..e042459437 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt @@ -21,7 +21,6 @@ import android.content.Intent import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager -import androidx.lifecycle.Observer import im.vector.app.R import im.vector.app.core.dialogs.ExportKeysDialog import im.vector.app.core.extensions.observeEvent @@ -49,20 +48,20 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() { viewModel.showManualExport.value = intent.getBooleanExtra(EXTRA_SHOW_MANUAL_EXPORT, false) viewModel.initSession(session) - viewModel.isCreatingBackupVersion.observe(this, Observer { + viewModel.isCreatingBackupVersion.observe(this) { val isCreating = it ?: false if (isCreating) { showWaitingView() } else { hideWaitingView() } - }) + } - viewModel.loadingStatus.observe(this, Observer { + viewModel.loadingStatus.observe(this) { it?.let { updateWaitingView(it) } - }) + } viewModel.navigateEvent.observeEvent(this) { uxStateEvent -> when (uxStateEvent) { @@ -99,7 +98,7 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() { } } - viewModel.prepareRecoverFailError.observe(this, Observer { error -> + viewModel.prepareRecoverFailError.observe(this) { error -> if (error != null) { AlertDialog.Builder(this) .setTitle(R.string.unknown_error) @@ -110,9 +109,9 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() { } .show() } - }) + } - viewModel.creatingBackupError.observe(this, Observer { error -> + viewModel.creatingBackupError.observe(this) { error -> if (error != null) { AlertDialog.Builder(this) .setTitle(R.string.unexpected_error) @@ -123,7 +122,7 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() { } .show() } - }) + } } private val saveStartForActivityResult = registerStartForActivityResult { activityResult -> diff --git a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupStep1Fragment.kt b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupStep1Fragment.kt index 44996a159c..cf65a9942c 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupStep1Fragment.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupStep1Fragment.kt @@ -20,7 +20,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.lifecycle.Observer import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.utils.LiveEvent import im.vector.app.databinding.FragmentKeysBackupSetupStep1Binding @@ -40,12 +39,12 @@ class KeysBackupSetupStep1Fragment @Inject constructor() : VectorBaseFragment + viewModel.passwordStrength.observe(viewLifecycleOwner) { strength -> if (strength == null) { views.keysBackupSetupStep2PassphraseStrengthLevel.strength = 0 views.keysBackupSetupStep2PassphraseEnterTil.error = null @@ -91,9 +89,9 @@ class KeysBackupSetupStep2Fragment @Inject constructor() : VectorBaseFragment { newValue -> + viewModel.passphrase.observe(viewLifecycleOwner) { newValue -> if (newValue.isEmpty()) { viewModel.passwordStrength.value = null } else { @@ -104,28 +102,28 @@ class KeysBackupSetupStep2Fragment @Inject constructor() : VectorBaseFragment if (actionId == EditorInfo.IME_ACTION_DONE) { @@ -141,8 +139,8 @@ class KeysBackupSetupStep2Fragment @Inject constructor() : VectorBaseFragment onPassphraseChanged() } - views.keysBackupSetupStep2PassphraseConfirmEditText.doOnTextChanged { _, _, _, _ -> onConfirmPassphraseChanged() } + views.keysBackupSetupStep2PassphraseEnterEdittext.doOnTextChanged { _, _, _, _ -> onPassphraseChanged() } + views.keysBackupSetupStep2PassphraseConfirmEditText.doOnTextChanged { _, _, _, _ -> onConfirmPassphraseChanged() } } private fun toggleVisibilityMode() { diff --git a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupStep3Fragment.kt b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupStep3Fragment.kt index a41de109b0..9a3b5fa874 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupStep3Fragment.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupStep3Fragment.kt @@ -25,7 +25,6 @@ import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible -import androidx.lifecycle.Observer import arrow.core.Try import com.google.android.material.bottomsheet.BottomSheetDialog import im.vector.app.R @@ -61,7 +60,7 @@ class KeysBackupSetupStep3Fragment @Inject constructor() : VectorBaseFragment val shouldBeVisible = state.passphraseVisible views.ssssPassphraseEnterEdittext.showPassword(shouldBeVisible) - views.ssssViewShowPassword.setImageResource(if (shouldBeVisible) R.drawable.ic_eye_closed else R.drawable.ic_eye) + views.ssssViewShowPassword.render(shouldBeVisible) } } diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BackupToQuadSMigrationTask.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BackupToQuadSMigrationTask.kt index bbdd0fd5c5..8fbef016cf 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/recover/BackupToQuadSMigrationTask.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BackupToQuadSMigrationTask.kt @@ -97,7 +97,7 @@ class BackupToQuadSMigrationTask @Inject constructor( when { params.passphrase?.isNotEmpty() == true -> { reportProgress(params, R.string.bootstrap_progress_generating_ssss) - awaitCallback { + awaitCallback { quadS.generateKeyWithPassphrase( UUID.randomUUID().toString(), "ssss_key", diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapConclusionFragment.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapConclusionFragment.kt index f0a7811666..6c5c259755 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapConclusionFragment.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapConclusionFragment.kt @@ -44,7 +44,7 @@ class BootstrapConclusionFragment @Inject constructor( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - views.bootstrapConclusionContinue.views.itemVerificationClickableZone.debouncedClicks { sharedViewModel.handle(BootstrapActions.Completed) } + views.bootstrapConclusionContinue.views.bottomSheetActionClickableZone.debouncedClicks { sharedViewModel.handle(BootstrapActions.Completed) } } override fun invalidate() = withState(sharedViewModel) { state -> diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapConfirmPassphraseFragment.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapConfirmPassphraseFragment.kt index 9862a97db2..2d26436556 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapConfirmPassphraseFragment.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapConfirmPassphraseFragment.kt @@ -109,7 +109,7 @@ class BootstrapConfirmPassphraseFragment @Inject constructor() if (state.step is BootstrapStep.ConfirmPassphrase) { val isPasswordVisible = state.step.isPasswordVisible views.ssssPassphraseEnterEdittext.showPassword(isPasswordVisible, updateCursor = false) - views.ssssViewShowPassword.setImageResource(if (isPasswordVisible) R.drawable.ic_eye_closed else R.drawable.ic_eye) + views.ssssViewShowPassword.render(isPasswordVisible) } } } diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapEnterPassphraseFragment.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapEnterPassphraseFragment.kt index 9d3f26cba8..7a5d6e5fd7 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapEnterPassphraseFragment.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapEnterPassphraseFragment.kt @@ -103,7 +103,7 @@ class BootstrapEnterPassphraseFragment @Inject constructor() if (state.step is BootstrapStep.SetupPassphrase) { val isPasswordVisible = state.step.isPasswordVisible views.ssssPassphraseEnterEdittext.showPassword(isPasswordVisible, updateCursor = false) - views.ssssViewShowPassword.setImageResource(if (isPasswordVisible) R.drawable.ic_eye_closed else R.drawable.ic_eye) + views.ssssViewShowPassword.render(isPasswordVisible) state.passphraseStrength.invoke()?.let { strength -> val score = strength.score diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapMigrateBackupFragment.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapMigrateBackupFragment.kt index 99507fd1c7..ca0942f59a 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapMigrateBackupFragment.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapMigrateBackupFragment.kt @@ -133,7 +133,7 @@ class BootstrapMigrateBackupFragment @Inject constructor( if (state.step is BootstrapStep.GetBackupSecretPassForMigration) { val isPasswordVisible = state.step.isPasswordVisible views.bootstrapMigrateEditText.showPassword(isPasswordVisible, updateCursor = false) - views.bootstrapMigrateShowPassword.setImageResource(if (isPasswordVisible) R.drawable.ic_eye_closed else R.drawable.ic_eye) + views.bootstrapMigrateShowPassword.render(isPasswordVisible) } views.bootstrapDescriptionText.text = getString(R.string.bootstrap_migration_enter_backup_password) diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSaveRecoveryKeyFragment.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSaveRecoveryKeyFragment.kt index 82ac15a069..d5e7305e11 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSaveRecoveryKeyFragment.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSaveRecoveryKeyFragment.kt @@ -52,9 +52,9 @@ class BootstrapSaveRecoveryKeyFragment @Inject constructor( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - views.recoverySave.views.itemVerificationClickableZone.debouncedClicks { downloadRecoveryKey() } - views.recoveryCopy.views.itemVerificationClickableZone.debouncedClicks { shareRecoveryKey() } - views.recoveryContinue.views.itemVerificationClickableZone.debouncedClicks { + views.recoverySave.views.bottomSheetActionClickableZone.debouncedClicks { downloadRecoveryKey() } + views.recoveryCopy.views.bottomSheetActionClickableZone.debouncedClicks { shareRecoveryKey() } + views.recoveryContinue.views.bottomSheetActionClickableZone.debouncedClicks { // We do not display the final Fragment anymore // TODO Do some cleanup // sharedViewModel.handle(BootstrapActions.GoToCompleted) @@ -62,8 +62,7 @@ class BootstrapSaveRecoveryKeyFragment @Inject constructor( } } - private fun downloadRecoveryKey() = withState(sharedViewModel) { _ -> - + private fun downloadRecoveryKey() { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) intent.addCategory(Intent.CATEGORY_OPENABLE) intent.type = "text/plain" diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSetupRecoveryKeyFragment.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSetupRecoveryKeyFragment.kt index 9131ab3c36..8676f1fb6b 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSetupRecoveryKeyFragment.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSetupRecoveryKeyFragment.kt @@ -42,15 +42,15 @@ class BootstrapSetupRecoveryKeyFragment @Inject constructor() super.onViewCreated(view, savedInstanceState) // Actions when a key backup exist - views.bootstrapSetupSecureSubmit.views.itemVerificationClickableZone.debouncedClicks { + views.bootstrapSetupSecureSubmit.views.bottomSheetActionClickableZone.debouncedClicks { sharedViewModel.handle(BootstrapActions.StartKeyBackupMigration) } // Actions when there is no key backup - views.bootstrapSetupSecureUseSecurityKey.views.itemVerificationClickableZone.debouncedClicks { + views.bootstrapSetupSecureUseSecurityKey.views.bottomSheetActionClickableZone.debouncedClicks { sharedViewModel.handle(BootstrapActions.Start(userWantsToEnterPassphrase = false)) } - views.bootstrapSetupSecureUseSecurityPassphrase.views.itemVerificationClickableZone.debouncedClicks { + views.bootstrapSetupSecureUseSecurityPassphrase.views.bottomSheetActionClickableZone.debouncedClicks { sharedViewModel.handle(BootstrapActions.Start(userWantsToEnterPassphrase = true)) } } diff --git a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSharedViewModel.kt b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSharedViewModel.kt index 42278cd948..fe55d81cc4 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSharedViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/recover/BootstrapSharedViewModel.kt @@ -55,6 +55,7 @@ import org.matrix.android.sdk.internal.util.awaitCallback import java.io.OutputStream import kotlin.coroutines.Continuation import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException class BootstrapSharedViewModel @AssistedInject constructor( @Assisted initialState: BootstrapViewState, @@ -421,7 +422,7 @@ class BootstrapSharedViewModel @AssistedInject constructor( _viewEvents.post(BootstrapViewEvents.RequestReAuth(flowResponse, errCode)) } else -> { - promise.resumeWith(Result.failure(UnsupportedOperationException())) + promise.resumeWithException(UnsupportedOperationException()) } } } diff --git a/vector/src/main/java/im/vector/app/features/crypto/util/Extensions.kt b/vector/src/main/java/im/vector/app/features/crypto/util/Extensions.kt deleted file mode 100644 index c75bcb252b..0000000000 --- a/vector/src/main/java/im/vector/app/features/crypto/util/Extensions.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2020 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.crypto.util - -import androidx.annotation.DrawableRes -import im.vector.app.R -import im.vector.app.core.extensions.exhaustive -import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel - -@DrawableRes -fun RoomEncryptionTrustLevel?.toImageRes(): Int { - return when (this) { - null -> 0 - RoomEncryptionTrustLevel.Default -> R.drawable.ic_shield_black - RoomEncryptionTrustLevel.Warning -> R.drawable.ic_shield_warning - RoomEncryptionTrustLevel.Trusted -> R.drawable.ic_shield_trusted - }.exhaustive -} diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt index 51aa6d063b..48f3f0a460 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt @@ -18,11 +18,11 @@ package im.vector.app.features.crypto.verification import android.content.Context import im.vector.app.R import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.home.room.detail.RoomDetailArgs import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.popup.VerificationVectorAlert -import im.vector.app.features.themes.ThemeUtils import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.verification.PendingVerificationRequest import org.matrix.android.sdk.api.session.crypto.verification.VerificationService @@ -31,6 +31,7 @@ import org.matrix.android.sdk.api.session.crypto.verification.VerificationTxStat import org.matrix.android.sdk.api.util.toMatrixItem import timber.log.Timber import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton /** @@ -39,6 +40,7 @@ import javax.inject.Singleton @Singleton class IncomingVerificationRequestHandler @Inject constructor( private val context: Context, + private var avatarRenderer: Provider, private val popupAlertManager: PopupAlertManager) : VerificationService.Listener { private var session: Session? = null @@ -60,9 +62,8 @@ class IncomingVerificationRequestHandler @Inject constructor( when (tx.state) { is VerificationTxState.OnStarted -> { // Add a notification for every incoming request - val name = session?.getUser(tx.otherUserId)?.displayName - ?: tx.otherUserId - + val user = session?.getUser(tx.otherUserId) + val name = user?.getBestName() ?: tx.otherUserId val alert = VerificationVectorAlert( uid, context.getString(R.string.sas_incoming_request_notif_title), @@ -77,10 +78,10 @@ class IncomingVerificationRequestHandler @Inject constructor( } } ?: true } else true - }, - matrixItem = session?.getUser(tx.otherUserId)?.toMatrixItem() + } ) .apply { + viewBinder = VerificationVectorAlert.ViewBinder(user?.toMatrixItem(), avatarRenderer.get()) contentAction = Runnable { (weakCurrentActivity?.get() as? VectorBaseActivity<*>)?.let { it.navigator.performDeviceVerification(it, tx.otherUserId, tx.transactionId) @@ -91,13 +92,11 @@ class IncomingVerificationRequestHandler @Inject constructor( } addButton( context.getString(R.string.ignore), - Runnable { - tx.cancel() - } + { tx.cancel() } ) addButton( context.getString(R.string.action_open), - Runnable { + { (weakCurrentActivity?.get() as? VectorBaseActivity<*>)?.let { it.navigator.performDeviceVerification(it, tx.otherUserId, tx.transactionId) } @@ -120,8 +119,8 @@ class IncomingVerificationRequestHandler @Inject constructor( Timber.v("## SAS verificationRequestCreated ${pr.transactionId}") // For incoming request we should prompt (if not in activity where this request apply) if (pr.isIncoming) { - val name = session?.getUser(pr.otherUserId)?.displayName - ?: pr.otherUserId + val user = session?.getUser(pr.otherUserId) + val name = user?.getBestName() ?: pr.otherUserId val alert = VerificationVectorAlert( uniqueIdForVerificationRequest(pr), @@ -134,10 +133,10 @@ class IncomingVerificationRequestHandler @Inject constructor( it.roomId != pr.roomId } ?: true } else true - }, - matrixItem = session?.getUser(pr.otherUserId)?.toMatrixItem() + } ) .apply { + viewBinder = VerificationVectorAlert.ViewBinder(user?.toMatrixItem(), avatarRenderer.get()) contentAction = Runnable { (weakCurrentActivity?.get() as? VectorBaseActivity<*>)?.let { val roomId = pr.roomId @@ -154,7 +153,7 @@ class IncomingVerificationRequestHandler @Inject constructor( pr.roomId ?: "" ) } - colorInt = ThemeUtils.getColor(context, R.attr.vctr_notice_secondary) + colorAttribute = R.attr.vctr_notice_secondary // 5mn expiration expirationTimestamp = System.currentTimeMillis() + (5 * 60 * 1000L) } diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/VerificationBottomSheet.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/VerificationBottomSheet.kt index eb20e815f6..8d15249c11 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/VerificationBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/VerificationBottomSheet.kt @@ -24,7 +24,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AlertDialog -import androidx.core.view.isVisible import androidx.fragment.app.Fragment import com.airbnb.mvrx.MvRx import com.airbnb.mvrx.fragmentViewModel @@ -49,6 +48,7 @@ import im.vector.app.features.crypto.verification.request.VerificationRequestFra import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.settings.VectorSettingsActivity import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.crosssigning.KEYBACKUP_SECRET_SSSS_NAME import org.matrix.android.sdk.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME @@ -162,23 +162,22 @@ class VerificationBottomSheet : VectorBaseBottomSheetDialogFragment() { @EpoxyAttribute - var imageRes: Int = 0 - - @EpoxyAttribute - var contentDescription: String? = null + lateinit var roomEncryptionTrustLevel: RoomEncryptionTrustLevel override fun bind(holder: Holder) { super.bind(holder) - holder.image.setImageResource(imageRes) - - if (contentDescription == null) { - ViewCompat.setImportantForAccessibility(holder.image, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO) - } else { - ViewCompat.setImportantForAccessibility(holder.image, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES) - holder.image.contentDescription = contentDescription - } + holder.image.render(roomEncryptionTrustLevel) } class Holder : VectorEpoxyHolder() { - val image by bind(R.id.itemVerificationBigImage) + val image by bind(R.id.itemVerificationBigImage) } } diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/qrconfirmation/VerificationQRWaitingController.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/qrconfirmation/VerificationQRWaitingController.kt index 8cb5068b63..4b7452b511 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/qrconfirmation/VerificationQRWaitingController.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/qrconfirmation/VerificationQRWaitingController.kt @@ -23,6 +23,7 @@ import im.vector.app.core.resources.StringProvider import im.vector.app.features.crypto.verification.epoxy.bottomSheetVerificationBigImageItem import im.vector.app.features.crypto.verification.epoxy.bottomSheetVerificationNoticeItem import im.vector.app.features.crypto.verification.epoxy.bottomSheetVerificationWaitingItem +import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel import javax.inject.Inject class VerificationQRWaitingController @Inject constructor( @@ -49,7 +50,7 @@ class VerificationQRWaitingController @Inject constructor( bottomSheetVerificationBigImageItem { id("image") - imageRes(R.drawable.ic_shield_trusted) + roomEncryptionTrustLevel(RoomEncryptionTrustLevel.Trusted) } bottomSheetVerificationWaitingItem { diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/qrconfirmation/VerificationQrScannedByOtherController.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/qrconfirmation/VerificationQrScannedByOtherController.kt index cabce6410d..da382f75f1 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/qrconfirmation/VerificationQrScannedByOtherController.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/qrconfirmation/VerificationQrScannedByOtherController.kt @@ -25,6 +25,7 @@ import im.vector.app.features.crypto.verification.VerificationBottomSheetViewSta import im.vector.app.features.crypto.verification.epoxy.bottomSheetVerificationActionItem import im.vector.app.features.crypto.verification.epoxy.bottomSheetVerificationBigImageItem import im.vector.app.features.crypto.verification.epoxy.bottomSheetVerificationNoticeItem +import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel import javax.inject.Inject class VerificationQrScannedByOtherController @Inject constructor( @@ -58,7 +59,7 @@ class VerificationQrScannedByOtherController @Inject constructor( bottomSheetVerificationBigImageItem { id("image") - imageRes(R.drawable.ic_shield_trusted) + roomEncryptionTrustLevel(RoomEncryptionTrustLevel.Trusted) } dividerItem { diff --git a/vector/src/main/java/im/vector/app/features/devtools/DevToolsInteractionListener.kt b/vector/src/main/java/im/vector/app/features/devtools/DevToolsInteractionListener.kt new file mode 100644 index 0000000000..e1e6f6b7cb --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/devtools/DevToolsInteractionListener.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.devtools + +interface DevToolsInteractionListener { + fun processAction(action: RoomDevToolAction) +} diff --git a/vector/src/main/java/im/vector/app/features/devtools/DevToolsViewEvents.kt b/vector/src/main/java/im/vector/app/features/devtools/DevToolsViewEvents.kt new file mode 100644 index 0000000000..96aed20dc4 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/devtools/DevToolsViewEvents.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.devtools + +import im.vector.app.core.platform.VectorViewEvents + +sealed class DevToolsViewEvents : VectorViewEvents { + object Dismiss : DevToolsViewEvents() + + // object ShowStateList : DevToolsViewEvents() + data class ShowAlertMessage(val message: String) : DevToolsViewEvents() + data class ShowSnackMessage(val message: String) : DevToolsViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolAction.kt b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolAction.kt new file mode 100644 index 0000000000..c6246bbe08 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolAction.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.devtools + +import im.vector.app.core.platform.VectorViewModelAction +import org.matrix.android.sdk.api.session.events.model.Event + +sealed class RoomDevToolAction : VectorViewModelAction { + object ExploreRoomState : RoomDevToolAction() + object OnBackPressed : RoomDevToolAction() + object MenuEdit : RoomDevToolAction() + object MenuItemSend : RoomDevToolAction() + data class ShowStateEvent(val event: Event) : RoomDevToolAction() + data class ShowStateEventType(val stateEventType: String) : RoomDevToolAction() + data class UpdateContentText(val contentJson: String) : RoomDevToolAction() + data class SendCustomEvent(val isStateEvent: Boolean) : RoomDevToolAction() + data class CustomEventTypeChange(val type: String) : RoomDevToolAction() + data class CustomEventContentChange(val content: String) : RoomDevToolAction() + data class CustomEventStateKeyChange(val stateKey: String) : RoomDevToolAction() +} diff --git a/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolActivity.kt b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolActivity.kt new file mode 100644 index 0000000000..31b495a4d4 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolActivity.kt @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.devtools + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.app.AlertDialog +import androidx.core.view.forEach +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRx +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.viewModel +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.di.ScreenComponent +import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.extensions.replaceFragment +import im.vector.app.core.extensions.toMvRxBundle +import im.vector.app.core.platform.SimpleFragmentActivity +import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.utils.createJSonViewerStyleProvider +import kotlinx.parcelize.Parcelize +import org.billcarsonfr.jsonviewer.JSonViewerFragment +import javax.inject.Inject + +class RoomDevToolActivity : SimpleFragmentActivity(), RoomDevToolViewModel.Factory, + FragmentManager.OnBackStackChangedListener { + + @Inject lateinit var viewModelFactory: RoomDevToolViewModel.Factory + @Inject lateinit var colorProvider: ColorProvider + + // private lateinit var viewModel: RoomDevToolViewModel + private val viewModel: RoomDevToolViewModel by viewModel() + + override fun getTitleRes() = R.string.dev_tools_menu_name + + override fun getMenuRes() = R.menu.menu_devtools + + private var currentDisplayMode: RoomDevToolViewState.Mode? = null + + @Parcelize + data class Args( + val roomId: String + ) : Parcelable + + override fun injectWith(injector: ScreenComponent) { + super.injectWith(injector) + injector.inject(this) + } + + override fun create(initialState: RoomDevToolViewState): RoomDevToolViewModel { + return viewModelFactory.create(initialState) + } + + override fun initUiAndData() { + super.initUiAndData() + viewModel.subscribe(this) { + renderState(it) + } + + viewModel.observeViewEvents { + when (it) { + DevToolsViewEvents.Dismiss -> finish() + is DevToolsViewEvents.ShowAlertMessage -> { + AlertDialog.Builder(this) + .setMessage(it.message) + .setPositiveButton(R.string.ok, null) + .show() + Unit + } + is DevToolsViewEvents.ShowSnackMessage -> showSnackbar(it.message) + }.exhaustive + } + supportFragmentManager.addOnBackStackChangedListener(this) + } + + private fun renderState(it: RoomDevToolViewState) { + if (it.displayMode != currentDisplayMode) { + when (it.displayMode) { + RoomDevToolViewState.Mode.Root -> { + val classJava = RoomDevToolFragment::class.java + val tag = classJava.name + if (supportFragmentManager.findFragmentByTag(tag) == null) { + replaceFragment(R.id.container, RoomDevToolFragment::class.java) + } else { + supportFragmentManager.popBackStack() + } + } + RoomDevToolViewState.Mode.StateEventDetail -> { + val frag = JSonViewerFragment.newInstance( + jsonString = it.selectedEventJson ?: "", + initialOpenDepth = -1, + wrap = true, + styleProvider = createJSonViewerStyleProvider(colorProvider) + ) + navigateTo(frag) + } + RoomDevToolViewState.Mode.StateEventList, + RoomDevToolViewState.Mode.StateEventListByType -> { + val frag = createFragment(RoomDevToolStateEventListFragment::class.java, Bundle().toMvRxBundle()) + navigateTo(frag) + } + RoomDevToolViewState.Mode.EditEventContent -> { + val frag = createFragment(RoomDevToolEditFragment::class.java, Bundle().toMvRxBundle()) + navigateTo(frag) + } + is RoomDevToolViewState.Mode.SendEventForm -> { + val frag = createFragment(RoomDevToolSendFormFragment::class.java, Bundle().toMvRxBundle()) + navigateTo(frag) + } + } + currentDisplayMode = it.displayMode + invalidateOptionsMenu() + } + + when (it.modalLoading) { + is Loading -> showWaitingView() + is Success -> hideWaitingView() + is Fail -> { + hideWaitingView() + } + Uninitialized -> { + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressed() + return true + } + if (item.itemId == R.id.menuItemEdit) { + viewModel.handle(RoomDevToolAction.MenuEdit) + return true + } + if (item.itemId == R.id.menuItemSend) { + viewModel.handle(RoomDevToolAction.MenuItemSend) + return true + } + return super.onOptionsItemSelected(item) + } + + override fun onBackPressed() { + viewModel.handle(RoomDevToolAction.OnBackPressed) + } + + private fun navigateTo(fragment: Fragment) { + val tag = fragment.javaClass.name + if (supportFragmentManager.findFragmentByTag(tag) == null) { + supportFragmentManager.beginTransaction() + .setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) + .replace(R.id.container, fragment, tag) + .addToBackStack(tag) + .commit() + } else { + if (!supportFragmentManager.popBackStackImmediate(tag, 0)) { + supportFragmentManager.beginTransaction() + .setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) + .replace(R.id.container, fragment, tag) + .addToBackStack(tag) + .commit() + } + } + } + + override fun onDestroy() { + supportFragmentManager.removeOnBackStackChangedListener(this) + currentDisplayMode = null + super.onDestroy() + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean = withState(viewModel) { state -> + menu?.forEach { + val isVisible = when (it.itemId) { + R.id.menuItemEdit -> { + state.displayMode is RoomDevToolViewState.Mode.StateEventDetail + } + R.id.menuItemSend -> { + state.displayMode is RoomDevToolViewState.Mode.EditEventContent + || state.displayMode is RoomDevToolViewState.Mode.SendEventForm + } + else -> true + } + it.isVisible = isVisible + } + return@withState true + } + + companion object { + + fun intent(context: Context, roomId: String): Intent { + return Intent(context, RoomDevToolActivity::class.java).apply { + putExtra(MvRx.KEY_ARG, Args(roomId)) + } + } + } + + override fun onBackStackChanged() = withState(viewModel) { state -> + updateToolBar(state) + } + + private fun updateToolBar(state: RoomDevToolViewState) { + val title = when (state.displayMode) { + RoomDevToolViewState.Mode.Root -> { + getString(getTitleRes()) + } + RoomDevToolViewState.Mode.StateEventList -> { + getString(R.string.dev_tools_state_event) + } + RoomDevToolViewState.Mode.StateEventDetail -> { + state.selectedEvent?.type + } + RoomDevToolViewState.Mode.EditEventContent -> { + getString(R.string.dev_tools_edit_content) + } + RoomDevToolViewState.Mode.StateEventListByType -> { + state.currentStateType ?: "" + } + is RoomDevToolViewState.Mode.SendEventForm -> { + getString( + if (state.displayMode.isState) R.string.dev_tools_send_custom_state_event + else R.string.dev_tools_send_custom_event + ) + } + } + + supportActionBar?.let { + it.title = title + } ?: run { + setTitle(title) + } + invalidateOptionsMenu() + } +} diff --git a/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolEditFragment.kt b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolEditFragment.kt new file mode 100644 index 0000000000..98fe19a765 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolEditFragment.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.devtools + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.withState +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.databinding.FragmentDevtoolsEditorBinding +import javax.inject.Inject + +class RoomDevToolEditFragment @Inject constructor() + : VectorBaseFragment() { + + private val sharedViewModel: RoomDevToolViewModel by activityViewModel() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentDevtoolsEditorBinding { + return FragmentDevtoolsEditorBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + withState(sharedViewModel) { + views.editText.setText(it.editedContent ?: "{}") + } + views.editText.textChanges() + .skipInitialValue() + .subscribe { + sharedViewModel.handle(RoomDevToolAction.UpdateContentText(it.toString())) + } + .disposeOnDestroyView() + } + + override fun onResume() { + super.onResume() + views.editText.requestFocus() + } + + override fun onStop() { + super.onStop() + views.editText.hideKeyboard() + } +} diff --git a/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolFragment.kt b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolFragment.kt new file mode 100644 index 0000000000..0cc2a69bcf --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolFragment.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.devtools + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.activityViewModel +import im.vector.app.core.extensions.cleanup +import im.vector.app.core.extensions.configureWith +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.databinding.FragmentGenericRecyclerBinding +import javax.inject.Inject + +class RoomDevToolFragment @Inject constructor( + private val epoxyController: RoomDevToolRootController +) : VectorBaseFragment(), + DevToolsInteractionListener { + + private val sharedViewModel: RoomDevToolViewModel by activityViewModel() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentGenericRecyclerBinding { + return FragmentGenericRecyclerBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + views.genericRecyclerView.configureWith(epoxyController, showDivider = true) + epoxyController.interactionListener = this + +// sharedViewModel.observeViewEvents { +// when (it) { +// is DevToolsViewEvents.showJson -> { +// JSonViewerDialog.newInstance(it.jsonString, -1, createJSonViewerStyleProvider(colorProvider)) +// .show(childFragmentManager, "JSON_VIEWER") +// +// } +// } +// } + } + + override fun onDestroyView() { + views.genericRecyclerView.cleanup() + epoxyController.interactionListener = null + super.onDestroyView() + } + + override fun processAction(action: RoomDevToolAction) { + sharedViewModel.handle(action) + } +} diff --git a/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolRootController.kt b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolRootController.kt new file mode 100644 index 0000000000..785e0140ac --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolRootController.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.devtools + +import android.view.View +import com.airbnb.epoxy.EpoxyController +import im.vector.app.R +import im.vector.app.core.resources.StringProvider +import im.vector.app.core.ui.list.genericButtonItem +import javax.inject.Inject + +class RoomDevToolRootController @Inject constructor( + private val stringProvider: StringProvider +) : EpoxyController() { + + init { + requestModelBuild() + } + + var interactionListener: DevToolsInteractionListener? = null + + override fun buildModels() { + genericButtonItem { + id("explore") + text(stringProvider.getString(R.string.dev_tools_explore_room_state)) + buttonClickAction(View.OnClickListener { + interactionListener?.processAction(RoomDevToolAction.ExploreRoomState) + }) + } + genericButtonItem { + id("send") + text(stringProvider.getString(R.string.dev_tools_send_custom_event)) + buttonClickAction(View.OnClickListener { + interactionListener?.processAction(RoomDevToolAction.SendCustomEvent(false)) + }) + } + genericButtonItem { + id("send_state") + text(stringProvider.getString(R.string.dev_tools_send_state_event)) + buttonClickAction(View.OnClickListener { + interactionListener?.processAction(RoomDevToolAction.SendCustomEvent(true)) + }) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolSendFormController.kt b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolSendFormController.kt new file mode 100644 index 0000000000..e5b3fb737e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolSendFormController.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.devtools + +import com.airbnb.epoxy.TypedEpoxyController +import im.vector.app.R +import im.vector.app.core.resources.StringProvider +import im.vector.app.core.ui.list.genericFooterItem +import im.vector.app.features.form.formEditTextItem +import im.vector.app.features.form.formMultiLineEditTextItem +import javax.inject.Inject + +class RoomDevToolSendFormController @Inject constructor( + private val stringProvider: StringProvider +) : TypedEpoxyController() { + + var interactionListener: DevToolsInteractionListener? = null + + override fun buildModels(data: RoomDevToolViewState?) { + val sendEventForm = (data?.displayMode as? RoomDevToolViewState.Mode.SendEventForm) ?: return + + genericFooterItem { + id("topSpace") + text("") + } + formEditTextItem { + id("event_type") + enabled(true) + value(data.sendEventDraft?.type) + hint(stringProvider.getString(R.string.dev_tools_form_hint_type)) + showBottomSeparator(false) + onTextChange { text -> + interactionListener?.processAction(RoomDevToolAction.CustomEventTypeChange(text)) + } + } + + if (sendEventForm.isState) { + formEditTextItem { + id("state_key") + enabled(true) + value(data.sendEventDraft?.stateKey) + hint(stringProvider.getString(R.string.dev_tools_form_hint_state_key)) + showBottomSeparator(false) + onTextChange { text -> + interactionListener?.processAction(RoomDevToolAction.CustomEventStateKeyChange(text)) + } + } + } + + formMultiLineEditTextItem { + id("event_content") + enabled(true) + value(data.sendEventDraft?.content) + hint(stringProvider.getString(R.string.dev_tools_form_hint_event_content)) + showBottomSeparator(false) + onTextChange { text -> + interactionListener?.processAction(RoomDevToolAction.CustomEventContentChange(text)) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolSendFormFragment.kt b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolSendFormFragment.kt new file mode 100644 index 0000000000..abda6104cd --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolSendFormFragment.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.devtools + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.withState +import im.vector.app.core.extensions.cleanup +import im.vector.app.core.extensions.configureWith +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.databinding.FragmentGenericRecyclerBinding +import javax.inject.Inject + +class RoomDevToolSendFormFragment @Inject constructor( + private val epoxyController: RoomDevToolSendFormController +) : VectorBaseFragment(), DevToolsInteractionListener { + + val sharedViewModel: RoomDevToolViewModel by activityViewModel() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentGenericRecyclerBinding { + return FragmentGenericRecyclerBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + views.genericRecyclerView.configureWith(epoxyController, showDivider = false) + epoxyController.interactionListener = this + } + + override fun onDestroyView() { + views.genericRecyclerView.cleanup() + epoxyController.interactionListener = null + super.onDestroyView() + } + + override fun invalidate() = withState(sharedViewModel) { state -> + epoxyController.setData(state) + } + + override fun processAction(action: RoomDevToolAction) { + sharedViewModel.handle(action) + } +} diff --git a/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolStateEventListFragment.kt b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolStateEventListFragment.kt new file mode 100644 index 0000000000..600464bb6d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolStateEventListFragment.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.devtools + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.withState +import im.vector.app.core.extensions.cleanup +import im.vector.app.core.extensions.configureWith +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.databinding.FragmentGenericRecyclerBinding +import javax.inject.Inject + +class RoomDevToolStateEventListFragment @Inject constructor( + private val epoxyController: RoomStateListController +) : VectorBaseFragment(), DevToolsInteractionListener { + + val sharedViewModel: RoomDevToolViewModel by activityViewModel() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentGenericRecyclerBinding { + return FragmentGenericRecyclerBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + views.genericRecyclerView.configureWith(epoxyController, showDivider = true) + epoxyController.interactionListener = this + } + + override fun onDestroyView() { + views.genericRecyclerView.cleanup() + epoxyController.interactionListener = null + super.onDestroyView() + } + + override fun invalidate() = withState(sharedViewModel) { state -> + epoxyController.setData(state) + } + + override fun processAction(action: RoomDevToolAction) { + sharedViewModel.handle(action) + } +} diff --git a/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolViewModel.kt b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolViewModel.kt new file mode 100644 index 0000000000..9fffe70872 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolViewModel.kt @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.devtools + +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.ViewModelContext +import com.squareup.moshi.Types +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.R +import im.vector.app.core.error.ErrorFormatter +import im.vector.app.core.platform.VectorViewModel +import im.vector.app.core.resources.StringProvider +import kotlinx.coroutines.launch +import org.json.JSONObject +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.util.JsonDict +import org.matrix.android.sdk.internal.di.MoshiProvider +import org.matrix.android.sdk.rx.rx + +class RoomDevToolViewModel @AssistedInject constructor( + @Assisted val initialState: RoomDevToolViewState, + private val errorFormatter: ErrorFormatter, + private val stringProvider: StringProvider, + private val session: Session +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory { + fun create(initialState: RoomDevToolViewState): RoomDevToolViewModel + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: RoomDevToolViewState): RoomDevToolViewModel { + val factory = when (viewModelContext) { + is FragmentViewModelContext -> viewModelContext.fragment as? Factory + is ActivityViewModelContext -> viewModelContext.activity as? Factory + } + return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface") + } + } + + init { + session.getRoom(initialState.roomId) + ?.rx() + ?.liveStateEvents(emptySet()) + ?.execute { async -> + copy(stateEvents = async) + } + } + + override fun handle(action: RoomDevToolAction) { + when (action) { + RoomDevToolAction.ExploreRoomState -> { + setState { + copy( + displayMode = RoomDevToolViewState.Mode.StateEventList, + selectedEvent = null + ) + } + } + is RoomDevToolAction.ShowStateEvent -> { + val jsonString = MoshiProvider.providesMoshi() + .adapter(Event::class.java) + .toJson(action.event) + + setState { + copy( + displayMode = RoomDevToolViewState.Mode.StateEventDetail, + selectedEvent = action.event, + selectedEventJson = jsonString + ) + } + } + RoomDevToolAction.OnBackPressed -> { + handleBack() + } + RoomDevToolAction.MenuEdit -> { + withState { + if (it.displayMode == RoomDevToolViewState.Mode.StateEventDetail) { + // we want to edit it + val content = it.selectedEvent?.content?.let { JSONObject(it).toString(4) } ?: "{\n\t\n}" + setState { + copy( + editedContent = content, + displayMode = RoomDevToolViewState.Mode.EditEventContent + ) + } + } + } + } + is RoomDevToolAction.ShowStateEventType -> { + setState { + copy( + displayMode = RoomDevToolViewState.Mode.StateEventListByType, + currentStateType = action.stateEventType + ) + } + } + RoomDevToolAction.MenuItemSend -> { + handleMenuItemSend() + } + is RoomDevToolAction.UpdateContentText -> { + setState { + copy(editedContent = action.contentJson) + } + } + is RoomDevToolAction.SendCustomEvent -> { + setState { + copy( + displayMode = RoomDevToolViewState.Mode.SendEventForm(action.isStateEvent), + sendEventDraft = RoomDevToolViewState.SendEventDraft(EventType.MESSAGE, null, "{\n}") + ) + } + } + is RoomDevToolAction.CustomEventTypeChange -> { + setState { + copy( + sendEventDraft = sendEventDraft?.copy(type = action.type) + ) + } + } + is RoomDevToolAction.CustomEventStateKeyChange -> { + setState { + copy( + sendEventDraft = sendEventDraft?.copy(stateKey = action.stateKey) + ) + } + } + is RoomDevToolAction.CustomEventContentChange -> { + setState { + copy( + sendEventDraft = sendEventDraft?.copy(content = action.content) + ) + } + } + } + } + + private fun handleMenuItemSend() = withState { state -> + when (state.displayMode) { + RoomDevToolViewState.Mode.EditEventContent -> editEventContent(state) + is RoomDevToolViewState.Mode.SendEventForm -> sendEventContent(state, state.displayMode.isState) + else -> Unit + } + } + + private fun editEventContent(state: RoomDevToolViewState) { + setState { copy(modalLoading = Loading()) } + + viewModelScope.launch { + try { + val room = session.getRoom(initialState.roomId) + ?: throw IllegalArgumentException(stringProvider.getString(R.string.room_error_not_found)) + + val adapter = MoshiProvider.providesMoshi() + .adapter(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)) + val json = adapter.fromJson(state.editedContent ?: "") + ?: throw IllegalArgumentException(stringProvider.getString(R.string.dev_tools_error_no_content)) + + room.sendStateEvent( + state.selectedEvent?.type ?: "", + state.selectedEvent?.stateKey, + json + + ) + _viewEvents.post(DevToolsViewEvents.ShowSnackMessage(stringProvider.getString(R.string.dev_tools_success_state_event))) + setState { + copy( + modalLoading = Success(Unit), + selectedEventJson = null, + editedContent = null, + displayMode = RoomDevToolViewState.Mode.StateEventListByType + ) + } + } catch (failure: Throwable) { + _viewEvents.post(DevToolsViewEvents.ShowAlertMessage(errorFormatter.toHumanReadable(failure))) + setState { copy(modalLoading = Fail(failure)) } + } + } + } + + private fun sendEventContent(state: RoomDevToolViewState, isState: Boolean) { + setState { copy(modalLoading = Loading()) } + viewModelScope.launch { + try { + val room = session.getRoom(initialState.roomId) + ?: throw IllegalArgumentException(stringProvider.getString(R.string.room_error_not_found)) + + val adapter = MoshiProvider.providesMoshi() + .adapter(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)) + val json = adapter.fromJson(state.sendEventDraft?.content ?: "") + ?: throw IllegalArgumentException(stringProvider.getString(R.string.dev_tools_error_no_content)) + + val eventType = state.sendEventDraft?.type + ?: throw IllegalArgumentException(stringProvider.getString(R.string.dev_tools_error_no_message_type)) + + if (isState) { + room.sendStateEvent( + eventType, + state.sendEventDraft.stateKey, + json + ) + } else { + // can we try to do some validation?? + // val validParse = MoshiProvider.providesMoshi().adapter(MessageContent::class.java).fromJson(it.sendEventDraft.content ?: "") + json.toModel(catchError = false) + ?: throw IllegalArgumentException(stringProvider.getString(R.string.dev_tools_error_malformed_event)) + room.sendEvent( + eventType, + json + ) + } + + _viewEvents.post(DevToolsViewEvents.ShowSnackMessage(stringProvider.getString(R.string.dev_tools_success_event))) + setState { + copy( + modalLoading = Success(Unit), + sendEventDraft = null, + displayMode = RoomDevToolViewState.Mode.Root + ) + } + } catch (failure: Throwable) { + _viewEvents.post(DevToolsViewEvents.ShowAlertMessage(errorFormatter.toHumanReadable(failure))) + setState { copy(modalLoading = Fail(failure)) } + } + } + } + + private fun handleBack() = withState { + when (it.displayMode) { + RoomDevToolViewState.Mode.Root -> { + _viewEvents.post(DevToolsViewEvents.Dismiss) + } + RoomDevToolViewState.Mode.StateEventList -> { + setState { + copy( + selectedEvent = null, + selectedEventJson = null, + displayMode = RoomDevToolViewState.Mode.Root + ) + } + } + RoomDevToolViewState.Mode.StateEventDetail -> { + setState { + copy( + selectedEvent = null, + selectedEventJson = null, + displayMode = RoomDevToolViewState.Mode.StateEventListByType + ) + } + } + RoomDevToolViewState.Mode.EditEventContent -> { + setState { + copy( + displayMode = RoomDevToolViewState.Mode.StateEventDetail + ) + } + } + RoomDevToolViewState.Mode.StateEventListByType -> { + setState { + copy( + currentStateType = null, + displayMode = RoomDevToolViewState.Mode.StateEventList + ) + } + } + is RoomDevToolViewState.Mode.SendEventForm -> { + setState { + copy( + displayMode = RoomDevToolViewState.Mode.Root + ) + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolViewState.kt b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolViewState.kt new file mode 100644 index 0000000000..885de005b0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolViewState.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.devtools + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.Uninitialized +import org.matrix.android.sdk.api.session.events.model.Event + +data class RoomDevToolViewState( + val roomId: String = "", + val displayMode: Mode = Mode.Root, + val stateEvents: Async> = Uninitialized, + val currentStateType: String? = null, + val selectedEvent: Event? = null, + val selectedEventJson: String? = null, + val editedContent: String? = null, + val modalLoading: Async = Uninitialized, + val sendEventDraft: SendEventDraft? = null +) : MvRxState { + + constructor(args: RoomDevToolActivity.Args) : this(roomId = args.roomId, displayMode = Mode.Root) + + sealed class Mode { + object Root : Mode() + object StateEventList : Mode() + object StateEventListByType : Mode() + object StateEventDetail : Mode() + object EditEventContent : Mode() + data class SendEventForm(val isState: Boolean) : Mode() + } + + data class SendEventDraft( + val type: String?, + val stateKey: String?, + val content: String? + ) +} diff --git a/vector/src/main/java/im/vector/app/features/devtools/RoomStateListController.kt b/vector/src/main/java/im/vector/app/features/devtools/RoomStateListController.kt new file mode 100644 index 0000000000..69070c509b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/devtools/RoomStateListController.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.devtools + +import com.airbnb.epoxy.TypedEpoxyController +import im.vector.app.R +import im.vector.app.core.epoxy.noResultItem +import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.resources.StringProvider +import im.vector.app.core.ui.list.GenericItem +import im.vector.app.core.ui.list.genericItem +import me.gujun.android.span.span +import org.json.JSONObject +import javax.inject.Inject + +class RoomStateListController @Inject constructor( + private val stringProvider: StringProvider, + private val colorProvider: ColorProvider +) : TypedEpoxyController() { + + var interactionListener: DevToolsInteractionListener? = null + + override fun buildModels(data: RoomDevToolViewState?) { + when (data?.displayMode) { + RoomDevToolViewState.Mode.StateEventList -> { + val stateEventsGroups = data.stateEvents.invoke().orEmpty().groupBy { it.type } + + if (stateEventsGroups.isEmpty()) { + noResultItem { + id("no state events") + text(stringProvider.getString(R.string.no_result_placeholder)) + } + } else { + stateEventsGroups.forEach { entry -> + genericItem { + id(entry.key) + title(entry.key) + description(stringProvider.getQuantityString(R.plurals.entries, entry.value.size, entry.value.size)) + itemClickAction(GenericItem.Action("view").apply { + perform = Runnable { + interactionListener?.processAction(RoomDevToolAction.ShowStateEventType(entry.key)) + } + }) + } + } + } + } + RoomDevToolViewState.Mode.StateEventListByType -> { + val stateEvents = data.stateEvents.invoke().orEmpty().filter { it.type == data.currentStateType } + if (stateEvents.isEmpty()) { + noResultItem { + id("no state events") + text(stringProvider.getString(R.string.no_result_placeholder)) + } + } else { + stateEvents.forEach { stateEvent -> + val contentJson = JSONObject(stateEvent.content.orEmpty()).toString().let { + if (it.length > 140) { + it.take(140) + Typography.ellipsis + } else { + it.take(140) + } + } + genericItem { + id(stateEvent.eventId) + title(span { + +"Type: " + span { + textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary) + text = "\"${stateEvent.type}\"" + textStyle = "normal" + } + +"\nState Key: " + span { + textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary) + text = stateEvent.stateKey.let { "\"$it\"" } + textStyle = "normal" + } + }) + description(contentJson) + itemClickAction(GenericItem.Action("view").apply { + perform = Runnable { + interactionListener?.processAction(RoomDevToolAction.ShowStateEvent(stateEvent)) + } + }) + } + } + } + } + else -> { + // nop + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/form/FormMultiLineEditTextItem.kt b/vector/src/main/java/im/vector/app/features/form/FormMultiLineEditTextItem.kt new file mode 100644 index 0000000000..4ba668a051 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/form/FormMultiLineEditTextItem.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.form + +import android.graphics.Typeface +import android.text.Editable +import android.view.View +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.extensions.setTextSafe +import im.vector.app.core.platform.SimpleTextWatcher + +@EpoxyModelClass(layout = R.layout.item_form_multiline_text_input) +abstract class FormMultiLineEditTextItem : VectorEpoxyModel() { + + @EpoxyAttribute + var hint: String? = null + + @EpoxyAttribute + var value: String? = null + + @EpoxyAttribute + var showBottomSeparator: Boolean = true + + @EpoxyAttribute + var errorMessage: String? = null + + @EpoxyAttribute + var enabled: Boolean = true + + @EpoxyAttribute + var textSizeSp: Int? = null + + @EpoxyAttribute + var minLines: Int = 3 + + @EpoxyAttribute + var typeFace: Typeface = Typeface.DEFAULT + + @EpoxyAttribute + var onTextChange: ((String) -> Unit)? = null + + private val onTextChangeListener = object : SimpleTextWatcher() { + override fun afterTextChanged(s: Editable) { + onTextChange?.invoke(s.toString()) + } + } + + override fun bind(holder: Holder) { + super.bind(holder) + holder.textInputLayout.isEnabled = enabled + holder.textInputLayout.hint = hint + holder.textInputLayout.error = errorMessage + + holder.textInputEditText.typeface = typeFace + holder.textInputEditText.textSize = textSizeSp?.toFloat() ?: 12f + holder.textInputEditText.minLines = minLines + + // Update only if text is different and value is not null + holder.textInputEditText.setTextSafe(value) + holder.textInputEditText.isEnabled = enabled + + holder.textInputEditText.addTextChangedListener(onTextChangeListener) + holder.bottomSeparator.isVisible = showBottomSeparator + } + + override fun shouldSaveViewState(): Boolean { + return false + } + + override fun unbind(holder: Holder) { + super.unbind(holder) + holder.textInputEditText.removeTextChangedListener(onTextChangeListener) + } + + class Holder : VectorEpoxyHolder() { + val textInputLayout by bind(R.id.formMultiLineTextInputLayout) + val textInputEditText by bind(R.id.formMultiLineEditText) + val bottomSeparator by bind(R.id.formTextInputDivider) + } +} diff --git a/vector/src/main/java/im/vector/app/features/grouplist/GroupListViewModel.kt b/vector/src/main/java/im/vector/app/features/grouplist/GroupListViewModel.kt index 3b096adbfb..4b187f83ca 100644 --- a/vector/src/main/java/im/vector/app/features/grouplist/GroupListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/grouplist/GroupListViewModel.kt @@ -29,7 +29,6 @@ import im.vector.app.R import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider import io.reactivex.Observable -import io.reactivex.functions.BiFunction import kotlinx.coroutines.launch import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session @@ -123,7 +122,7 @@ class GroupListViewModel @AssistedInject constructor(@Assisted initialState: Gro session .rx() .liveGroupSummaries(groupSummariesQueryParams), - BiFunction { allCommunityGroup, communityGroups -> + { allCommunityGroup, communityGroups -> listOf(allCommunityGroup) + communityGroups } ) diff --git a/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt b/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt index 08f18a00ba..1d673a2a07 100644 --- a/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt @@ -20,9 +20,13 @@ import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.widget.ImageView import androidx.annotation.AnyThread +import androidx.annotation.ColorInt import androidx.annotation.UiThread import androidx.core.graphics.drawable.toBitmap import com.amulyakhare.textdrawable.TextDrawable +import com.bumptech.glide.load.MultiTransformation +import com.bumptech.glide.load.Transformation +import com.bumptech.glide.load.resource.bitmap.CircleCrop import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.DrawableImageViewTarget import com.bumptech.glide.request.target.Target @@ -32,6 +36,8 @@ import im.vector.app.core.glide.GlideApp import im.vector.app.core.glide.GlideRequest import im.vector.app.core.glide.GlideRequests import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider +import jp.wasabeef.glide.transformations.BlurTransformation +import jp.wasabeef.glide.transformations.ColorFilterTransformation import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.content.ContentUrlResolver import org.matrix.android.sdk.api.util.MatrixItem @@ -90,6 +96,7 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active target: Target) { val placeholder = getPlaceholderDrawable(matrixItem) buildGlideRequest(glideRequests, matrixItem.avatarUrl) + .apply(RequestOptions.circleCropTransform()) .placeholder(placeholder) .into(target) } @@ -117,10 +124,27 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active .get() } + @UiThread + fun renderBlur(matrixItem: MatrixItem, imageView: ImageView, sampling: Int, rounded: Boolean, @ColorInt colorFilter: Int? = null) { + val transformations = mutableListOf>( + BlurTransformation(20, sampling) + ) + if (colorFilter != null) { + transformations.add(ColorFilterTransformation(colorFilter)) + } + if (rounded) { + transformations.add(CircleCrop()) + } + buildGlideRequest(GlideApp.with(imageView), matrixItem.avatarUrl) + .apply(RequestOptions.bitmapTransform(MultiTransformation(transformations))) + .into(imageView) + } + @AnyThread fun getCachedDrawable(glideRequests: GlideRequests, matrixItem: MatrixItem): Drawable { return buildGlideRequest(glideRequests, matrixItem.avatarUrl) .onlyRetrieveFromCache(true) + .apply(RequestOptions.circleCropTransform()) .submit() .get() } @@ -139,9 +163,7 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active private fun buildGlideRequest(glideRequests: GlideRequests, avatarUrl: String?): GlideRequest { val resolvedUrl = resolvedUrl(avatarUrl) - return glideRequests - .load(resolvedUrl) - .apply(RequestOptions.circleCropTransform()) + return glideRequests.load(resolvedUrl) } private fun resolvedUrl(avatarUrl: String?): String? { diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt index 108e0512a7..6a381ec049 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt @@ -93,6 +93,7 @@ class HomeActivity : @Inject lateinit var shortcutsHandler: ShortcutsHandler @Inject lateinit var unknownDeviceViewModelFactory: UnknownDeviceDetectorSharedViewModel.Factory @Inject lateinit var permalinkHandler: PermalinkHandler + @Inject lateinit var avatarRenderer: AvatarRenderer private val drawerListener = object : DrawerLayout.SimpleDrawerListener() { override fun onDrawerStateChanged(newState: Int) { @@ -128,9 +129,9 @@ class HomeActivity : .observe() .subscribe { sharedAction -> when (sharedAction) { - is HomeActivitySharedAction.OpenDrawer -> views.drawerLayout.openDrawer(GravityCompat.START) + is HomeActivitySharedAction.OpenDrawer -> views.drawerLayout.openDrawer(GravityCompat.START) is HomeActivitySharedAction.CloseDrawer -> views.drawerLayout.closeDrawer(GravityCompat.START) - is HomeActivitySharedAction.OpenGroup -> { + is HomeActivitySharedAction.OpenGroup -> { views.drawerLayout.closeDrawer(GravityCompat.START) replaceFragment(R.id.homeDetailFragmentContainer, HomeDetailFragment::class.java, allowStateLoss = true) } @@ -147,9 +148,9 @@ class HomeActivity : homeActivityViewModel.observeViewEvents { when (it) { is HomeActivityViewEvents.AskPasswordToInitCrossSigning -> handleAskPasswordToInitCrossSigning(it) - is HomeActivityViewEvents.OnNewSession -> handleOnNewSession(it) - HomeActivityViewEvents.PromptToEnableSessionPush -> handlePromptToEnablePush() - is HomeActivityViewEvents.OnCrossSignedInvalidated -> handleCrossSigningInvalidated(it) + is HomeActivityViewEvents.OnNewSession -> handleOnNewSession(it) + HomeActivityViewEvents.PromptToEnableSessionPush -> handlePromptToEnablePush() + is HomeActivityViewEvents.OnCrossSignedInvalidated -> handleCrossSigningInvalidated(it) }.exhaustive } homeActivityViewModel.subscribe(this) { renderState(it) } @@ -202,7 +203,7 @@ class HomeActivity : private fun renderState(state: HomeActivityViewState) { when (val status = state.initialSyncProgressServiceStatus) { - is InitialSyncProgressService.Status.Idle -> { + is InitialSyncProgressService.Status.Idle -> { views.waitingView.root.isVisible = false } is InitialSyncProgressService.Status.Progressing -> { @@ -284,10 +285,10 @@ class HomeActivity : dismissedAction = Runnable { homeActivityViewModel.handle(HomeActivityViewActions.PushPromptHasBeenReviewed) } - addButton(getString(R.string.dismiss), Runnable { + addButton(getString(R.string.dismiss), { homeActivityViewModel.handle(HomeActivityViewActions.PushPromptHasBeenReviewed) }, true) - addButton(getString(R.string.settings), Runnable { + addButton(getString(R.string.settings), { (weakCurrentActivity?.get() as? VectorBaseActivity<*>)?.let { // action(it) homeActivityViewModel.handle(HomeActivityViewActions.PushPromptHasBeenReviewed) @@ -304,9 +305,9 @@ class HomeActivity : uid = "upgradeSecurity", title = getString(titleRes), description = getString(descRes), - iconId = R.drawable.ic_shield_warning, - matrixItem = userItem + iconId = R.drawable.ic_shield_warning ).apply { + viewBinder = VerificationVectorAlert.ViewBinder(userItem, avatarRenderer) colorInt = ContextCompat.getColor(this@HomeActivity, R.color.riotx_positive_accent) contentAction = Runnable { (weakCurrentActivity?.get() as? VectorBaseActivity<*>)?.let { @@ -367,11 +368,11 @@ class HomeActivity : bugReporter.openBugReportScreen(this, false) return true } - R.id.menu_home_filter -> { + R.id.menu_home_filter -> { navigator.openRoomsFiltering(this) return true } - R.id.menu_home_setting -> { + R.id.menu_home_setting -> { navigator.openSettings(this) return true } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt index afaa290190..62bdc61b63 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt @@ -32,26 +32,27 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.auth.UIABaseAuth import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor +import org.matrix.android.sdk.api.auth.UserPasswordAuth import org.matrix.android.sdk.api.auth.data.LoginFlowTypes +import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse +import org.matrix.android.sdk.api.auth.registration.nextUncompletedStage import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.pushrules.RuleIds import org.matrix.android.sdk.api.session.InitialSyncProgressService import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams import org.matrix.android.sdk.api.util.toMatrixItem -import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse -import org.matrix.android.sdk.api.auth.registration.nextUncompletedStage import org.matrix.android.sdk.internal.crypto.model.CryptoDeviceInfo import org.matrix.android.sdk.internal.crypto.model.MXUsersDevicesMap -import org.matrix.android.sdk.api.auth.UIABaseAuth -import org.matrix.android.sdk.api.auth.UserPasswordAuth import org.matrix.android.sdk.internal.util.awaitCallback import org.matrix.android.sdk.rx.asObservable import org.matrix.android.sdk.rx.rx import timber.log.Timber import kotlin.coroutines.Continuation import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException class HomeActivityViewModel @AssistedInject constructor( @Assisted initialState: HomeActivityViewState, @@ -211,8 +212,8 @@ class HomeActivityViewModel @AssistedInject constructor( } else { // Try to initialize cross signing in background if possible Timber.d("Initialize cross signing...") - awaitCallback { - try { + try { + awaitCallback { session.cryptoService().crossSigningService().initializeCrossSigning( object : UserInteractiveAuthInterceptor { override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) { @@ -228,16 +229,16 @@ class HomeActivityViewModel @AssistedInject constructor( ) ) } else { - promise.resumeWith(Result.failure(Exception("Cannot silently initialize cross signing, UIA missing"))) + promise.resumeWithException(Exception("Cannot silently initialize cross signing, UIA missing")) } } }, callback = it ) Timber.d("Initialize cross signing SUCCESS") - } catch (failure: Throwable) { - Timber.e(failure, "Failed to initialize cross signing") } + } catch (failure: Throwable) { + Timber.e(failure, "Failed to initialize cross signing") } } } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt index db9fb61cb3..4c7b7aa991 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt @@ -21,7 +21,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.content.ContextCompat -import androidx.lifecycle.Observer import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState @@ -33,13 +32,13 @@ import im.vector.app.core.glide.GlideApp import im.vector.app.core.platform.ToolbarConfigurable import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorBaseFragment -import im.vector.app.core.ui.views.ActiveCallView -import im.vector.app.core.ui.views.ActiveCallViewHolder +import im.vector.app.core.ui.views.CurrentCallsView +import im.vector.app.core.ui.views.KnownCallsViewHolder import im.vector.app.core.ui.views.KeysBackupBanner import im.vector.app.databinding.FragmentHomeDetailBinding -import im.vector.app.features.call.SharedActiveCallViewModel +import im.vector.app.features.call.SharedKnownCallsViewModel import im.vector.app.features.call.VectorCallActivity -import im.vector.app.features.call.WebRtcPeerConnectionManager +import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.home.room.list.RoomListFragment import im.vector.app.features.home.room.list.RoomListParams import im.vector.app.features.popup.PopupAlertManager @@ -66,11 +65,11 @@ class HomeDetailFragment @Inject constructor( private val serverBackupStatusViewModelFactory: ServerBackupStatusViewModel.Factory, private val avatarRenderer: AvatarRenderer, private val alertManager: PopupAlertManager, - private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager, + private val callManager: WebRtcCallManager, private val vectorPreferences: VectorPreferences ) : VectorBaseFragment(), KeysBackupBanner.Delegate, - ActiveCallView.Callback, + CurrentCallsView.Callback, ServerBackupStatusViewModel.Factory { private val viewModel: HomeDetailViewModel by fragmentViewModel() @@ -78,18 +77,18 @@ class HomeDetailFragment @Inject constructor( private val serverBackupStatusViewModel: ServerBackupStatusViewModel by activityViewModel() private lateinit var sharedActionViewModel: HomeSharedActionViewModel - private lateinit var sharedCallActionViewModel: SharedActiveCallViewModel + private lateinit var sharedCallActionViewModel: SharedKnownCallsViewModel override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeDetailBinding { return FragmentHomeDetailBinding.inflate(inflater, container, false) } - private val activeCallViewHolder = ActiveCallViewHolder() + private val activeCallViewHolder = KnownCallsViewHolder() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) sharedActionViewModel = activityViewModelProvider.get(HomeSharedActionViewModel::class.java) - sharedCallActionViewModel = activityViewModelProvider.get(SharedActiveCallViewModel::class.java) + sharedCallActionViewModel = activityViewModelProvider.get(SharedKnownCallsViewModel::class.java) setupBottomNavigationView() setupToolbar() @@ -127,9 +126,9 @@ class HomeDetailFragment @Inject constructor( } sharedCallActionViewModel - .activeCall - .observe(viewLifecycleOwner, Observer { - activeCallViewHolder.updateCall(it, webRtcPeerConnectionManager) + .liveKnownCalls + .observe(viewLifecycleOwner, { + activeCallViewHolder.updateCall(callManager.getCurrentCall(), callManager.getCalls()) invalidateOptionsMenu() }) } @@ -160,9 +159,9 @@ class HomeDetailFragment @Inject constructor( uid = uid, title = getString(R.string.new_session), description = getString(R.string.verify_this_session, newest.displayName ?: newest.deviceId ?: ""), - iconId = R.drawable.ic_shield_warning, - matrixItem = user + iconId = R.drawable.ic_shield_warning ).apply { + viewBinder = VerificationVectorAlert.ViewBinder(user, avatarRenderer) colorInt = ContextCompat.getColor(requireActivity(), R.color.riotx_accent) contentAction = Runnable { (weakCurrentActivity?.get() as? VectorBaseActivity<*>) @@ -188,9 +187,9 @@ class HomeDetailFragment @Inject constructor( uid = uid, title = getString(R.string.review_logins), description = getString(R.string.verify_other_sessions), - iconId = R.drawable.ic_shield_warning, - matrixItem = user + iconId = R.drawable.ic_shield_warning ).apply { + viewBinder = VerificationVectorAlert.ViewBinder(user, avatarRenderer) colorInt = ContextCompat.getColor(requireActivity(), R.color.riotx_accent) contentAction = Runnable { (weakCurrentActivity?.get() as? VectorBaseActivity<*>)?.let { @@ -336,14 +335,14 @@ class HomeDetailFragment @Inject constructor( } override fun onTapToReturnToCall() { - sharedCallActionViewModel.activeCall.value?.let { call -> + callManager.getCurrentCall()?.let { call -> VectorCallActivity.newIntent( context = requireContext(), callId = call.callId, - roomId = call.roomId, - otherUserId = call.otherUserId, - isIncomingCall = !call.isOutgoing, - isVideoCall = call.isVideoCall, + roomId = call.mxCall.roomId, + otherUserId = call.mxCall.opponentUserId, + isIncomingCall = !call.mxCall.isOutgoing, + isVideoCall = call.mxCall.isVideoCall, mode = null ).let { startActivity(it) diff --git a/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt b/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt index f4f16502ab..988b4fbabe 100644 --- a/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt @@ -32,7 +32,6 @@ import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.features.settings.VectorPreferences import io.reactivex.Observable -import io.reactivex.functions.Function3 import org.matrix.android.sdk.api.NoOpMatrixCallback import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.Session @@ -103,7 +102,7 @@ class UnknownDeviceDetectorSharedViewModel @AssistedInject constructor(@Assisted session.rx().liveUserCryptoDevices(session.myUserId), session.rx().liveMyDevicesInfo(), session.rx().liveCrossSigningPrivateKeys(), - Function3 { cryptoList, infoList, pInfo -> + { cryptoList, infoList, pInfo -> // Timber.v("## Detector trigger ${cryptoList.map { "${it.deviceId} ${it.trustLevel}" }}") // Timber.v("## Detector trigger canCrossSign ${pInfo.get().selfSigned != null}") infoList diff --git a/vector/src/main/java/im/vector/app/features/home/room/breadcrumbs/BreadcrumbsController.kt b/vector/src/main/java/im/vector/app/features/home/room/breadcrumbs/BreadcrumbsController.kt index 4e6bb124ad..a406803bbb 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/breadcrumbs/BreadcrumbsController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/breadcrumbs/BreadcrumbsController.kt @@ -16,7 +16,6 @@ package im.vector.app.features.home.room.breadcrumbs -import android.view.View import com.airbnb.epoxy.EpoxyController import im.vector.app.core.epoxy.zeroItem import im.vector.app.core.utils.DebouncedClickListener @@ -65,7 +64,7 @@ class BreadcrumbsController @Inject constructor( hasUnreadMessage(it.hasUnreadMessages) hasDraft(it.userDrafts.isNotEmpty()) itemClickListener( - DebouncedClickListener(View.OnClickListener { _ -> + DebouncedClickListener({ _ -> listener?.onBreadcrumbClicked(it.roomId) }) ) diff --git a/vector/src/main/java/im/vector/app/features/home/room/breadcrumbs/BreadcrumbsItem.kt b/vector/src/main/java/im/vector/app/features/home/room/breadcrumbs/BreadcrumbsItem.kt index e1343f6f51..f39b7b6d0a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/breadcrumbs/BreadcrumbsItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/breadcrumbs/BreadcrumbsItem.kt @@ -46,6 +46,7 @@ abstract class BreadcrumbsItem : VectorEpoxyModel() { holder.rootView.setOnClickListener(itemClickListener) holder.unreadIndentIndicator.isVisible = hasUnreadMessage avatarRenderer.render(matrixItem, holder.avatarImageView) + holder.avatarImageView.contentDescription = matrixItem.getBestName() holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted)) holder.draftIndentIndicator.isVisible = hasDraft holder.typingIndicator.isVisible = hasTypingUsers diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt index d3f9b6eda5..2810b27aa6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt @@ -60,9 +60,9 @@ class JumpToBottomViewVisibilityManager( } fun maybeShowJumpToBottomViewVisibilityWithDelay() { - debouncer.debounce("jump_to_bottom_visibility", 250, Runnable { + debouncer.debounce("jump_to_bottom_visibility", 250) { maybeShowJumpToBottomViewVisibility() - }) + } } private fun maybeShowJumpToBottomViewVisibility() { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index e034e373f3..98ad6c454c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -72,7 +72,10 @@ sealed class RoomDetailAction : VectorViewModelAction { data class IgnoreUser(val userId: String?) : RoomDetailAction() object ResendAll : RoomDetailAction() + + data class StartCallWithPhoneNumber(val phoneNumber: String, val videoCall: Boolean): RoomDetailAction() data class StartCall(val isVideo: Boolean) : RoomDetailAction() + data class AcceptCall(val callId: String): RoomDetailAction() object EndCall : RoomDetailAction() data class AcceptVerificationRequest(val transactionId: String, val otherUserId: String) : RoomDetailAction() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt index c5484b2724..906d81bc25 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt @@ -74,7 +74,7 @@ class RoomDetailActivity : } // Simple filter - private var currentRoomId: String? = null + var currentRoomId: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index aeb1c30f4b..c511c9e666 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -52,6 +52,7 @@ import androidx.core.view.ViewCompat import androidx.core.view.forEach import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -89,15 +90,13 @@ import im.vector.app.core.intent.getFilenameFromUri import im.vector.app.core.intent.getMimeTypeFromUri import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.resources.ColorProvider -import im.vector.app.core.ui.views.ActiveCallView -import im.vector.app.core.ui.views.ActiveCallViewHolder +import im.vector.app.core.ui.views.CurrentCallsView +import im.vector.app.core.ui.views.KnownCallsViewHolder import im.vector.app.core.ui.views.ActiveConferenceView import im.vector.app.core.ui.views.JumpToReadMarkerView import im.vector.app.core.ui.views.NotificationAreaView import im.vector.app.core.utils.Debouncer import im.vector.app.core.utils.KeyboardStateUtils -import im.vector.app.core.utils.PERMISSIONS_FOR_AUDIO_IP_CALL -import im.vector.app.core.utils.PERMISSIONS_FOR_VIDEO_IP_CALL import im.vector.app.core.utils.PERMISSIONS_FOR_WRITING_FILES import im.vector.app.core.utils.TextUtils import im.vector.app.core.utils.checkPermissions @@ -120,13 +119,12 @@ import im.vector.app.features.attachments.ContactAttachment import im.vector.app.features.attachments.preview.AttachmentsPreviewActivity import im.vector.app.features.attachments.preview.AttachmentsPreviewArgs import im.vector.app.features.attachments.toGroupedContentAttachmentData -import im.vector.app.features.call.SharedActiveCallViewModel +import im.vector.app.features.call.SharedKnownCallsViewModel import im.vector.app.features.call.VectorCallActivity -import im.vector.app.features.call.WebRtcPeerConnectionManager import im.vector.app.features.call.conference.JitsiCallViewModel +import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.command.Command import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreActivity -import im.vector.app.features.crypto.util.toImageRes import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.composer.TextComposerView @@ -224,11 +222,11 @@ class RoomDetailFragment @Inject constructor( private val vectorPreferences: VectorPreferences, private val colorProvider: ColorProvider, private val notificationUtils: NotificationUtils, - private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager, private val matrixItemColorProvider: MatrixItemColorProvider, private val imageContentRenderer: ImageContentRenderer, private val roomDetailPendingActionStore: RoomDetailPendingActionStore, - private val pillsPostProcessorFactory: PillsPostProcessor.Factory + private val pillsPostProcessorFactory: PillsPostProcessor.Factory, + private val callManager: WebRtcCallManager ) : VectorBaseFragment(), TimelineEventController.Callback, @@ -237,7 +235,7 @@ class RoomDetailFragment @Inject constructor( AttachmentTypeSelectorView.Callback, AttachmentsHelper.Callback, GalleryOrCameraDialogHelper.Listener, - ActiveCallView.Callback { + CurrentCallsView.Callback { companion object { /** @@ -283,7 +281,7 @@ class RoomDetailFragment @Inject constructor( override fun getMenuRes() = R.menu.menu_timeline private lateinit var sharedActionViewModel: MessageSharedActionViewModel - private lateinit var sharedCallActionViewModel: SharedActiveCallViewModel + private lateinit var knownCallsViewModel: SharedKnownCallsViewModel private lateinit var layoutManager: LinearLayoutManager private lateinit var jumpToBottomViewVisibilityManager: JumpToBottomViewVisibilityManager @@ -291,19 +289,30 @@ class RoomDetailFragment @Inject constructor( private lateinit var attachmentsHelper: AttachmentsHelper private lateinit var keyboardStateUtils: KeyboardStateUtils + private lateinit var callActionsHandler : StartCallActionsHandler private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView private var lockSendButton = false - private val activeCallViewHolder = ActiveCallViewHolder() + private val knownCallsViewHolder = KnownCallsViewHolder() private lateinit var emojiPopup: EmojiPopup override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java) - sharedCallActionViewModel = activityViewModelProvider.get(SharedActiveCallViewModel::class.java) + knownCallsViewModel = activityViewModelProvider.get(SharedKnownCallsViewModel::class.java) attachmentsHelper = AttachmentsHelper(requireContext(), this).register() + callActionsHandler = StartCallActionsHandler( + roomId = roomDetailArgs.roomId, + fragment = this, + vectorPreferences = vectorPreferences, + roomDetailViewModel = roomDetailViewModel, + callManager = callManager, + startCallActivityResultLauncher = startCallActivityResultLauncher, + showDialogWithMessage = ::showDialogWithMessage, + onTapToReturnToCall = ::onTapToReturnToCall + ).register() keyboardStateUtils = KeyboardStateUtils(requireActivity()) setupToolbar(views.roomToolbar) setupRecyclerView() @@ -327,10 +336,10 @@ class RoomDetailFragment @Inject constructor( } .disposeOnDestroyView() - sharedCallActionViewModel - .activeCall + knownCallsViewModel + .liveKnownCalls .observe(viewLifecycleOwner, { - activeCallViewHolder.updateCall(it, webRtcPeerConnectionManager) + knownCallsViewHolder.updateCall(callManager.getCurrentCall(), it) invalidateOptionsMenu() }) @@ -387,6 +396,7 @@ class RoomDetailFragment @Inject constructor( } is RoomDetailViewEvents.StartChatEffect -> handleChatEffect(it.type) RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects() + is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it) }.exhaustive } @@ -395,7 +405,16 @@ class RoomDetailFragment @Inject constructor( } } - private fun handleChatEffect(chatEffect: ChatEffect) { + private fun acceptIncomingCall(event: RoomDetailViewEvents.DisplayAndAcceptCall) { + val intent = VectorCallActivity.newIntent( + context = vectorBaseActivity, + mxCall = event.call.mxCall, + mode = VectorCallActivity.INCOMING_ACCEPT + ) + startActivity(intent) + } + + private fun handleChatEffect(chatEffect: ChatEffect) { when (chatEffect) { ChatEffect.CONFETTI -> { views.viewKonfetti.isVisible = true @@ -518,8 +537,18 @@ class RoomDetailFragment @Inject constructor( .Builder .fromRootView(views.rootConstraintLayout) .setKeyboardAnimationStyle(R.style.emoji_fade_animation_style) - .setOnEmojiPopupShownListener { views.composerLayout.views.composerEmojiButton.setImageResource(R.drawable.ic_keyboard) } - .setOnEmojiPopupDismissListener { views.composerLayout.views.composerEmojiButton.setImageResource(R.drawable.ic_insert_emoji) } + .setOnEmojiPopupShownListener { + views.composerLayout.views.composerEmojiButton.let { + it.setImageResource(R.drawable.ic_keyboard) + it.contentDescription = getString(R.string.a11y_close_emoji_picker) + } + } + .setOnEmojiPopupDismissListener { + views.composerLayout.views.composerEmojiButton.let { + it.setImageResource(R.drawable.ic_insert_emoji) + it.contentDescription = getString(R.string.a11y_open_emoji_picker) + } + } .build(views.composerLayout.views.composerEditText) views.composerLayout.views.composerEmojiButton.debouncedClicks { @@ -599,7 +628,7 @@ class RoomDetailFragment @Inject constructor( } override fun onDestroy() { - activeCallViewHolder.unBind(webRtcPeerConnectionManager) + knownCallsViewHolder.unBind() roomDetailViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState) super.onDestroy() } @@ -630,7 +659,7 @@ class RoomDetailFragment @Inject constructor( } private fun setupActiveCallView() { - activeCallViewHolder.bind( + knownCallsViewHolder.bind( views.activeCallPiP, views.activeCallView, views.activeCallPiPWrap, @@ -754,9 +783,12 @@ class RoomDetailFragment @Inject constructor( roomDetailViewModel.handle(RoomDetailAction.ManageIntegrations) true } - R.id.voice_call, + R.id.voice_call -> { + callActionsHandler.onVoiceCallClicked() + true + } R.id.video_call -> { - handleCallRequest(item) + callActionsHandler.onVideoCallClicked() true } R.id.hangup_call -> { @@ -767,6 +799,10 @@ class RoomDetailFragment @Inject constructor( handleSearchAction() true } + R.id.dev_tools -> { + navigator.openDevTools(requireContext(), roomDetailArgs.roomId) + true + } else -> super.onOptionsItemSelected(item) } } @@ -779,76 +815,6 @@ class RoomDetailFragment @Inject constructor( } } - private fun handleCallRequest(item: MenuItem) = withState(roomDetailViewModel) { state -> - val roomSummary = state.asyncRoomSummary.invoke() ?: return@withState - val isVideoCall = item.itemId == R.id.video_call - when (roomSummary.joinedMembersCount) { - 1 -> { - val pendingInvite = roomSummary.invitedMembersCount ?: 0 > 0 - if (pendingInvite) { - // wait for other to join - showDialogWithMessage(getString(R.string.cannot_call_yourself_with_invite)) - } else { - // You cannot place a call with yourself. - showDialogWithMessage(getString(R.string.cannot_call_yourself)) - } - } - 2 -> { - val activeCall = sharedCallActionViewModel.activeCall.value - 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 if (!state.isAllowedToStartWebRTCCall) { - showDialogWithMessage(getString( - if (state.isDm()) { - R.string.no_permissions_to_start_webrtc_call_in_direct_room - } else { - R.string.no_permissions_to_start_webrtc_call - }) - ) - } else { - safeStartCall(isVideoCall) - } - } - else -> { - // it's jitsi call - // can you add widgets?? - if (!state.isAllowedToManageWidgets) { - // You do not have permission to start a conference call in this room - showDialogWithMessage(getString( - if (state.isDm()) { - R.string.no_permissions_to_start_conf_call_in_direct_room - } else { - R.string.no_permissions_to_start_conf_call - } - )) - } else { - if (state.activeRoomWidgets()?.filter { it.type == WidgetType.Jitsi }?.any() == true) { - // A conference is already in progress! - showDialogWithMessage(getString(R.string.conference_call_in_progress)) - } else { - AlertDialog.Builder(requireContext()) - .setTitle(if (isVideoCall) R.string.video_meeting else R.string.audio_meeting) - .setMessage(R.string.audio_video_meeting_description) - .setPositiveButton(getString(R.string.create)) { _, _ -> - // create the widget, then navigate to it.. - roomDetailViewModel.handle(RoomDetailAction.AddJitsiWidget(isVideoCall)) - } - .setNegativeButton(getString(R.string.cancel), null) - .show() - } - } - } - } - } - private fun displayDisabledIntegrationDialog() { AlertDialog.Builder(requireActivity()) .setTitle(R.string.disabled_integration_dialog_title) @@ -860,54 +826,6 @@ class RoomDetailFragment @Inject constructor( .show() } - private fun safeStartCall(isVideoCall: Boolean) { - if (vectorPreferences.preventAccidentalCall()) { - AlertDialog.Builder(requireActivity()) - .setMessage(if (isVideoCall) R.string.start_video_call_prompt_msg else R.string.start_voice_call_prompt_msg) - .setPositiveButton(if (isVideoCall) R.string.start_video_call else R.string.start_voice_call) { _, _ -> - safeStartCall2(isVideoCall) - } - .setNegativeButton(R.string.cancel, null) - .show() - } else { - safeStartCall2(isVideoCall) - } - } - - private val startCallActivityResultLauncher = registerForPermissionsResult { allGranted -> - if (allGranted) { - (roomDetailViewModel.pendingAction as? RoomDetailAction.StartCall)?.let { - roomDetailViewModel.pendingAction = null - roomDetailViewModel.handle(it) - } - } else { - context?.toast(R.string.permissions_action_not_performed_missing_permissions) - cleanUpAfterPermissionNotGranted() - } - } - - private fun safeStartCall2(isVideoCall: Boolean) { - val startCallAction = RoomDetailAction.StartCall(isVideoCall) - roomDetailViewModel.pendingAction = startCallAction - if (isVideoCall) { - if (checkPermissions(PERMISSIONS_FOR_VIDEO_IP_CALL, - requireActivity(), - startCallActivityResultLauncher, - R.string.permissions_rationale_msg_camera_and_audio)) { - roomDetailViewModel.pendingAction = null - roomDetailViewModel.handle(startCallAction) - } - } else { - if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, - requireActivity(), - startCallActivityResultLauncher, - R.string.permissions_rationale_msg_record_audio)) { - roomDetailViewModel.pendingAction = null - roomDetailViewModel.handle(startCallAction) - } - } - } - private fun renderRegularMode(text: String) { autoCompleter.exitSpecialMode() views.composerLayout.collapse() @@ -1047,6 +965,18 @@ class RoomDetailFragment @Inject constructor( } } + private val startCallActivityResultLauncher = registerForPermissionsResult { allGranted -> + if (allGranted) { + (roomDetailViewModel.pendingAction as? RoomDetailAction.StartCall)?.let { + roomDetailViewModel.pendingAction = null + roomDetailViewModel.handle(it) + } + } else { + context?.toast(R.string.permissions_action_not_performed_missing_permissions) + cleanUpAfterPermissionNotGranted() + } + } + // PRIVATE METHODS ***************************************************************************** private fun setupRecyclerView() { @@ -1114,7 +1044,7 @@ class RoomDetailFragment @Inject constructor( } private fun updateJumpToReadMarkerViewVisibility() { - views.jumpToReadMarkerView.post { + viewLifecycleOwner.lifecycleScope.launchWhenResumed { withState(roomDetailViewModel) { val showJumpToUnreadBanner = when (it.unreadState) { UnreadState.Unknown, @@ -1273,10 +1203,7 @@ class RoomDetailFragment @Inject constructor( avatarRenderer.render(roomSummary.toMatrixItem(), views.roomToolbarAvatarImageView) renderSubTitle(typingMessage, roomSummary.topic) - views.roomToolbarDecorationImageView.let { - it.setImageResource(roomSummary.roomEncryptionTrustLevel.toImageRes()) - it.isVisible = roomSummary.roomEncryptionTrustLevel != null - } + views.roomToolbarDecorationImageView.render(roomSummary.roomEncryptionTrustLevel) } } @@ -2015,14 +1942,14 @@ class RoomDetailFragment @Inject constructor( } override fun onTapToReturnToCall() { - sharedCallActionViewModel.activeCall.value?.let { call -> + callManager.getCurrentCall()?.let { call -> VectorCallActivity.newIntent( context = requireContext(), callId = call.callId, roomId = call.roomId, - otherUserId = call.otherUserId, - isIncomingCall = !call.isOutgoing, - isVideoCall = call.isVideoCall, + otherUserId = call.mxCall.opponentUserId, + isIncomingCall = !call.mxCall.isOutgoing, + isVideoCall = call.mxCall.isVideoCall, mode = null ).let { startActivity(it) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt index 81d3d622e7..9f801e7272 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt @@ -20,6 +20,7 @@ import android.net.Uri import android.view.View import androidx.annotation.StringRes import im.vector.app.core.platform.VectorViewEvents +import im.vector.app.features.call.webrtc.WebRtcCall import im.vector.app.features.command.Command import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.api.util.MatrixItem @@ -73,6 +74,8 @@ sealed class RoomDetailViewEvents : VectorViewEvents { abstract class SendMessageResult : RoomDetailViewEvents() + data class DisplayAndAcceptCall(val call: WebRtcCall): RoomDetailViewEvents() + object DisplayPromptForIntegrationManager : RoomDetailViewEvents() object DisplayEnableIntegrationsWarning : RoomDetailViewEvents() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index 7eedd5ca8e..c7a5873a65 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -26,19 +26,21 @@ import com.airbnb.mvrx.ViewModelContext import com.jakewharton.rxrelay2.BehaviorRelay import com.jakewharton.rxrelay2.PublishRelay import dagger.assisted.Assisted -import dagger.assisted.AssistedInject import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import im.vector.app.R import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider -import im.vector.app.features.call.WebRtcPeerConnectionManager +import im.vector.app.features.call.dialpad.DialPadLookup +import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.command.CommandParser import im.vector.app.features.command.ParsedCommand +import im.vector.app.features.createdirect.DirectRoomHelper import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler -import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder +import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder import im.vector.app.features.home.room.detail.timeline.helper.TimelineSettingsFactory import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.home.room.typing.TypingHelper @@ -47,7 +49,6 @@ import im.vector.app.features.raw.wellknown.getElementWellknown import im.vector.app.features.settings.VectorLocale import im.vector.app.features.settings.VectorPreferences import io.reactivex.Observable -import io.reactivex.functions.BiFunction import io.reactivex.rxkotlin.subscribeBy import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.Dispatchers @@ -63,6 +64,7 @@ import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.call.PSTNProtocolChecker import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.LocalEcho @@ -112,12 +114,14 @@ class RoomDetailViewModel @AssistedInject constructor( private val rawService: RawService, private val supportedVerificationMethodsProvider: SupportedVerificationMethodsProvider, private val stickerPickerActionHandler: StickerPickerActionHandler, - private val roomSummaryHolder: RoomSummaryHolder, + private val roomSummariesHolder: RoomSummariesHolder, private val typingHelper: TypingHelper, - private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager, + private val callManager: WebRtcCallManager, private val chatEffectManager: ChatEffectManager, + private val directRoomHelper: DirectRoomHelper, timelineSettingsFactory: TimelineSettingsFactory -) : VectorViewModel(initialState), Timeline.Listener, ChatEffectManager.Delegate { +) : VectorViewModel(initialState), + Timeline.Listener, ChatEffectManager.Delegate, PSTNProtocolChecker.Listener { private val room = session.getRoom(initialState.roomId)!! private val eventId = initialState.eventId @@ -167,10 +171,13 @@ class RoomDetailViewModel @AssistedInject constructor( observeMyRoomMember() observeActiveRoomWidgets() observePowerLevel() + updateShowDialerOptionState() room.getRoomSummaryLive() room.markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, NoOpMatrixCallback()) // Inform the SDK that the room is displayed session.onRoomDisplayed(initialState.roomId) + callManager.addPstnSupportListener(this) + callManager.checkForPSTNSupportIfNeeded() chatEffectManager.delegate = this } @@ -226,66 +233,85 @@ class RoomDetailViewModel @AssistedInject constructor( override fun handle(action: RoomDetailAction) { when (action) { - is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action) - is RoomDetailAction.SaveDraft -> handleSaveDraft(action) - is RoomDetailAction.SendMessage -> handleSendMessage(action) - is RoomDetailAction.SendMedia -> handleSendMedia(action) - is RoomDetailAction.SendSticker -> handleSendSticker(action) - is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action) - is RoomDetailAction.TimelineEventTurnsInvisible -> handleEventInvisible(action) - is RoomDetailAction.LoadMoreTimelineEvents -> handleLoadMore(action) - is RoomDetailAction.SendReaction -> handleSendReaction(action) - is RoomDetailAction.AcceptInvite -> handleAcceptInvite() - is RoomDetailAction.RejectInvite -> handleRejectInvite() - is RoomDetailAction.RedactAction -> handleRedactEvent(action) - is RoomDetailAction.UndoReaction -> handleUndoReact(action) - is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(action) - is RoomDetailAction.EnterRegularMode -> handleEnterRegularMode(action) - is RoomDetailAction.EnterEditMode -> handleEditAction(action) - is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action) - is RoomDetailAction.EnterReplyMode -> handleReplyAction(action) - is RoomDetailAction.DownloadOrOpen -> handleOpenOrDownloadFile(action) - is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action) - is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action) - is RoomDetailAction.ResendMessage -> handleResendEvent(action) - is RoomDetailAction.RemoveFailedEcho -> handleRemove(action) - is RoomDetailAction.ResendAll -> handleResendAll() - is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead() - is RoomDetailAction.ReportContent -> handleReportContent(action) - is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action) + is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action) + is RoomDetailAction.SaveDraft -> handleSaveDraft(action) + is RoomDetailAction.SendMessage -> handleSendMessage(action) + is RoomDetailAction.SendMedia -> handleSendMedia(action) + is RoomDetailAction.SendSticker -> handleSendSticker(action) + is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action) + is RoomDetailAction.TimelineEventTurnsInvisible -> handleEventInvisible(action) + is RoomDetailAction.LoadMoreTimelineEvents -> handleLoadMore(action) + is RoomDetailAction.SendReaction -> handleSendReaction(action) + is RoomDetailAction.AcceptInvite -> handleAcceptInvite() + is RoomDetailAction.RejectInvite -> handleRejectInvite() + is RoomDetailAction.RedactAction -> handleRedactEvent(action) + is RoomDetailAction.UndoReaction -> handleUndoReact(action) + is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(action) + is RoomDetailAction.EnterRegularMode -> handleEnterRegularMode(action) + is RoomDetailAction.EnterEditMode -> handleEditAction(action) + is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action) + is RoomDetailAction.EnterReplyMode -> handleReplyAction(action) + is RoomDetailAction.DownloadOrOpen -> handleOpenOrDownloadFile(action) + is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action) + is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action) + is RoomDetailAction.ResendMessage -> handleResendEvent(action) + is RoomDetailAction.RemoveFailedEcho -> handleRemove(action) + is RoomDetailAction.ResendAll -> handleResendAll() + is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead() + is RoomDetailAction.ReportContent -> handleReportContent(action) + is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action) is RoomDetailAction.EnterTrackingUnreadMessagesState -> startTrackingUnreadMessages() - is RoomDetailAction.ExitTrackingUnreadMessagesState -> stopTrackingUnreadMessages() - is RoomDetailAction.ReplyToOptions -> handleReplyToOptions(action) - is RoomDetailAction.AcceptVerificationRequest -> handleAcceptVerification(action) - is RoomDetailAction.DeclineVerificationRequest -> handleDeclineVerification(action) - is RoomDetailAction.RequestVerification -> handleRequestVerification(action) - is RoomDetailAction.ResumeVerification -> handleResumeRequestVerification(action) - is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) - is RoomDetailAction.TapOnFailedToDecrypt -> handleTapOnFailedToDecrypt(action) - is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() - is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() - is RoomDetailAction.StartCall -> handleStartCall(action) - is RoomDetailAction.EndCall -> handleEndCall() - is RoomDetailAction.ManageIntegrations -> handleManageIntegrations() - is RoomDetailAction.AddJitsiWidget -> handleAddJitsiConference(action) - is RoomDetailAction.RemoveWidget -> handleDeleteWidget(action.widgetId) - is RoomDetailAction.EnsureNativeWidgetAllowed -> handleCheckWidgetAllowed(action) - is RoomDetailAction.CancelSend -> handleCancel(action) - is RoomDetailAction.OpenOrCreateDm -> handleOpenOrCreateDm(action) - is RoomDetailAction.JumpToReadReceipt -> handleJumpToReadReceipt(action) - RoomDetailAction.QuickActionInvitePeople -> handleInvitePeople() - RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar() - is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action) - RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings) - is RoomDetailAction.ShowRoomAvatarFullScreen -> { + is RoomDetailAction.ExitTrackingUnreadMessagesState -> stopTrackingUnreadMessages() + is RoomDetailAction.ReplyToOptions -> handleReplyToOptions(action) + is RoomDetailAction.AcceptVerificationRequest -> handleAcceptVerification(action) + is RoomDetailAction.DeclineVerificationRequest -> handleDeclineVerification(action) + is RoomDetailAction.RequestVerification -> handleRequestVerification(action) + is RoomDetailAction.ResumeVerification -> handleResumeRequestVerification(action) + is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) + is RoomDetailAction.TapOnFailedToDecrypt -> handleTapOnFailedToDecrypt(action) + is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() + is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() + is RoomDetailAction.StartCallWithPhoneNumber -> handleStartCallWithPhoneNumber(action) + is RoomDetailAction.StartCall -> handleStartCall(action) + is RoomDetailAction.AcceptCall -> handleAcceptCall(action) + is RoomDetailAction.EndCall -> handleEndCall() + is RoomDetailAction.ManageIntegrations -> handleManageIntegrations() + is RoomDetailAction.AddJitsiWidget -> handleAddJitsiConference(action) + is RoomDetailAction.RemoveWidget -> handleDeleteWidget(action.widgetId) + is RoomDetailAction.EnsureNativeWidgetAllowed -> handleCheckWidgetAllowed(action) + is RoomDetailAction.CancelSend -> handleCancel(action) + is RoomDetailAction.OpenOrCreateDm -> handleOpenOrCreateDm(action) + is RoomDetailAction.JumpToReadReceipt -> handleJumpToReadReceipt(action) + RoomDetailAction.QuickActionInvitePeople -> handleInvitePeople() + RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar() + is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action) + RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings) + is RoomDetailAction.ShowRoomAvatarFullScreen -> { _viewEvents.post( RoomDetailViewEvents.ShowRoomAvatarFullScreen(action.matrixItem, action.transitionView) ) } - is RoomDetailAction.DoNotShowPreviewUrlFor -> handleDoNotShowPreviewUrlFor(action) + is RoomDetailAction.DoNotShowPreviewUrlFor -> handleDoNotShowPreviewUrlFor(action) }.exhaustive } + private fun handleStartCallWithPhoneNumber(action: RoomDetailAction.StartCallWithPhoneNumber) { + viewModelScope.launch { + try { + val result = DialPadLookup(session, directRoomHelper, callManager).lookupPhoneNumber(action.phoneNumber) + callManager.startOutgoingCall(result.roomId, result.userId, action.videoCall) + } catch (failure: Throwable) { + _viewEvents.post(RoomDetailViewEvents.ActionFailure(action, failure)) + } + } + } + + private fun handleAcceptCall(action: RoomDetailAction.AcceptCall) { + callManager.getCallById(action.callId)?.also { + _viewEvents.post(RoomDetailViewEvents.DisplayAndAcceptCall(it)) + } + } + private fun handleDoNotShowPreviewUrlFor(action: RoomDetailAction.DoNotShowPreviewUrlFor) { previewUrlRetriever.doNotShowPreviewUrlFor(action.eventId, action.url) } @@ -310,18 +336,15 @@ class RoomDetailViewModel @AssistedInject constructor( } private fun handleOpenOrCreateDm(action: RoomDetailAction.OpenOrCreateDm) { - val existingDmRoomId = session.getExistingDirectRoomWithUser(action.userId) - if (existingDmRoomId == null) { - // First create a direct room - viewModelScope.launch(Dispatchers.IO) { - val roomId = awaitCallback { - session.createDirectRoom(action.userId, it) - } - _viewEvents.post(RoomDetailViewEvents.OpenRoom(roomId)) + viewModelScope.launch { + val roomId = try { + directRoomHelper.ensureDMExists(action.userId) + } catch (failure: Throwable) { + _viewEvents.post(RoomDetailViewEvents.ActionFailure(action, failure)) + return@launch } - } else { - if (existingDmRoomId != initialState.roomId) { - _viewEvents.post(RoomDetailViewEvents.OpenRoom(existingDmRoomId)) + if (roomId != initialState.roomId) { + _viewEvents.post(RoomDetailViewEvents.OpenRoom(roomId = roomId)) } } } @@ -337,12 +360,12 @@ class RoomDetailViewModel @AssistedInject constructor( private fun handleStartCall(action: RoomDetailAction.StartCall) { room.roomSummary()?.otherMemberIds?.firstOrNull()?.let { - webRtcPeerConnectionManager.startOutgoingCall(room.roomId, it, action.isVideo) + callManager.startOutgoingCall(room.roomId, it, action.isVideo) } } private fun handleEndCall() { - webRtcPeerConnectionManager.endCall() + callManager.endCallForRoom(initialState.roomId) } private fun handleSelectStickerAttachment() { @@ -597,15 +620,16 @@ class RoomDetailViewModel @AssistedInject constructor( return@withState false } when (itemId) { - R.id.resend_all -> state.asyncRoomSummary()?.hasFailedSending == true + R.id.resend_all -> state.asyncRoomSummary()?.hasFailedSending == true R.id.timeline_setting -> true - R.id.invite -> state.canInvite - R.id.clear_all -> state.asyncRoomSummary()?.hasFailedSending == true + R.id.invite -> state.canInvite + R.id.clear_all -> state.asyncRoomSummary()?.hasFailedSending == true R.id.open_matrix_apps -> true R.id.voice_call, - R.id.video_call -> true // always show for discoverability - R.id.hangup_call -> webRtcPeerConnectionManager.currentCall != null + R.id.video_call -> callManager.getCallsByRoomId(state.roomId).isEmpty() + R.id.hangup_call -> callManager.getCallsByRoomId(state.roomId).isNotEmpty() R.id.search -> true + R.id.dev_tools -> vectorPreferences.developerMode() else -> false } } @@ -719,7 +743,7 @@ class RoomDetailViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) popDraft() } - is ParsedCommand.SendChatEffect -> { + is ParsedCommand.SendChatEffect -> { sendChatEffect(slashCommandResult) _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) popDraft() @@ -752,7 +776,7 @@ class RoomDetailViewModel @AssistedInject constructor( } }.exhaustive } - is SendMode.EDIT -> { + is SendMode.EDIT -> { // is original event a reply? val inReplyTo = state.sendMode.timelineEvent.getRelationContent()?.inReplyTo?.eventId if (inReplyTo != null) { @@ -777,7 +801,7 @@ class RoomDetailViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.MessageSent) popDraft() } - is SendMode.QUOTE -> { + is SendMode.QUOTE -> { val messageContent: MessageContent? = state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() ?: state.sendMode.timelineEvent.root.getClearContent().toModel() @@ -800,7 +824,7 @@ class RoomDetailViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.MessageSent) popDraft() } - is SendMode.REPLY -> { + is SendMode.REPLY -> { state.sendMode.timelineEvent.let { room.replyToMessage(it, action.text.toString(), action.autoMarkdown) _viewEvents.post(RoomDetailViewEvents.MessageSent) @@ -1308,7 +1332,7 @@ class RoomDetailViewModel @AssistedInject constructor( .combineLatest, RoomSummary, UnreadState>( timelineEvents.observeOn(Schedulers.computation()), room.rx().liveRoomSummary().unwrap(), - BiFunction { timelineEvents, roomSummary -> + { timelineEvents, roomSummary -> computeUnreadState(timelineEvents, roomSummary) } ) @@ -1321,6 +1345,7 @@ class RoomDetailViewModel @AssistedInject constructor( } } .subscribe { + Timber.v("Unread state: $it") setState { copy(unreadState = it) } } .disposeOnClear() @@ -1373,7 +1398,7 @@ class RoomDetailViewModel @AssistedInject constructor( private fun observeSummaryState() { asyncSubscribe(RoomDetailViewState::asyncRoomSummary) { summary -> - roomSummaryHolder.set(summary) + roomSummariesHolder.set(summary) setState { val typingMessage = typingHelper.getTypingMessage(summary.typingUsers) copy(typingMessage = typingMessage) @@ -1417,8 +1442,18 @@ class RoomDetailViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.OnNewTimelineEvents(eventIds)) } + override fun onPSTNSupportUpdated() { + updateShowDialerOptionState() + } + + private fun updateShowDialerOptionState() { + setState { + copy(showDialerOption = callManager.supportsPSTNProtocol) + } + } + override fun onCleared() { - roomSummaryHolder.clear() + roomSummariesHolder.remove(room.roomId) timeline.dispose() timeline.removeAllListeners() if (vectorPreferences.sendTypingNotifs()) { @@ -1426,6 +1461,7 @@ class RoomDetailViewModel @AssistedInject constructor( } chatEffectManager.delegate = null chatEffectManager.dispose() + callManager.removePstnSupportListener(this) super.onCleared() } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index 38b93f9363..8c2b3ffe98 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -74,7 +74,8 @@ data class RoomDetailViewState( val canSendMessage: Boolean = true, val canInvite: Boolean = true, val isAllowedToManageWidgets: Boolean = false, - val isAllowedToStartWebRTCCall: Boolean = true + val isAllowedToStartWebRTCCall: Boolean = true, + val showDialerOption: Boolean = false ) : MvRxState { constructor(args: RoomDetailArgs) : this( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/ScrollOnNewMessageCallback.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/ScrollOnNewMessageCallback.kt index af56e2eb02..fbf9ebe32f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/ScrollOnNewMessageCallback.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/ScrollOnNewMessageCallback.kt @@ -19,7 +19,7 @@ package im.vector.app.features.home.room.detail import androidx.recyclerview.widget.LinearLayoutManager import im.vector.app.core.platform.DefaultListUpdateCallback import im.vector.app.features.home.room.detail.timeline.TimelineEventController -import im.vector.app.features.home.room.detail.timeline.item.BaseEventItem +import im.vector.app.features.home.room.detail.timeline.item.ItemWithEvents import timber.log.Timber import java.util.concurrent.CopyOnWriteArrayList @@ -47,8 +47,8 @@ class ScrollOnNewMessageCallback(private val layoutManager: LinearLayoutManager, if (layoutManager.findFirstVisibleItemPosition() != position) { return } - val firstNewItem = timelineEventController.adapter.getModelAtPosition(position) as? BaseEventItem ?: return - val firstNewItemIds = firstNewItem.getEventIds().firstOrNull() + val firstNewItem = timelineEventController.adapter.getModelAtPosition(position) as? ItemWithEvents ?: return + val firstNewItemIds = firstNewItem.getEventIds().firstOrNull() ?: return val indexOfFirstNewItem = newTimelineEventIds.indexOf(firstNewItemIds) if (indexOfFirstNewItem != -1) { Timber.v("Should scroll to position: $position") diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt new file mode 100644 index 0000000000..30f1ecdc6d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail + +import android.os.Bundle +import androidx.activity.result.ActivityResultLauncher +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.platform.Restorable +import im.vector.app.core.utils.PERMISSIONS_FOR_AUDIO_IP_CALL +import im.vector.app.core.utils.PERMISSIONS_FOR_VIDEO_IP_CALL +import im.vector.app.core.utils.checkPermissions +import im.vector.app.features.call.DialerChoiceBottomSheet +import im.vector.app.features.call.dialpad.CallDialPadBottomSheet +import im.vector.app.features.call.dialpad.DialPadFragment +import im.vector.app.features.call.webrtc.WebRtcCallManager +import im.vector.app.features.settings.VectorPreferences +import org.matrix.android.sdk.api.session.widgets.model.WidgetType + +private const val DIALER_OPTION_TAG = "DIALER_OPTION_TAG" +private const val DIAL_PAD_TAG = "DIAL_PAD_TAG" + +class StartCallActionsHandler( + private val roomId: String, + private val fragment: Fragment, + private val callManager: WebRtcCallManager, + private val vectorPreferences: VectorPreferences, + private val roomDetailViewModel: RoomDetailViewModel, + private val startCallActivityResultLauncher: ActivityResultLauncher>, + private val showDialogWithMessage: (String) -> Unit, + private val onTapToReturnToCall: () -> Unit): Restorable { + + fun onVideoCallClicked() { + handleCallRequest(true) + } + + fun onVoiceCallClicked() = withState(roomDetailViewModel) { + if (it.showDialerOption) { + displayDialerChoiceBottomSheet() + } else { + handleCallRequest(false) + } + } + + private fun DialerChoiceBottomSheet.applyListeners(): DialerChoiceBottomSheet { + onDialPadClicked = ::displayDialPadBottomSheet + onVoiceCallClicked = { handleCallRequest(false) } + return this + } + + private fun CallDialPadBottomSheet.applyCallback(): CallDialPadBottomSheet { + callback = object : DialPadFragment.Callback { + override fun onOkClicked(formatted: String?, raw: String?) { + if (raw.isNullOrEmpty()) return + roomDetailViewModel.handle(RoomDetailAction.StartCallWithPhoneNumber(raw, false)) + } + } + return this + } + + private fun displayDialerChoiceBottomSheet() { + DialerChoiceBottomSheet() + .applyListeners() + .show(fragment.parentFragmentManager, DIALER_OPTION_TAG) + } + + private fun displayDialPadBottomSheet() { + CallDialPadBottomSheet.newInstance(true) + .applyCallback() + .show(fragment.parentFragmentManager, DIAL_PAD_TAG) + } + + private fun handleCallRequest(isVideoCall: Boolean) = withState(roomDetailViewModel) { state -> + val roomSummary = state.asyncRoomSummary.invoke() ?: return@withState + when (roomSummary.joinedMembersCount) { + 1 -> { + val pendingInvite = roomSummary.invitedMembersCount ?: 0 > 0 + if (pendingInvite) { + // wait for other to join + showDialogWithMessage(fragment.getString(R.string.cannot_call_yourself_with_invite)) + } else { + // You cannot place a call with yourself. + showDialogWithMessage(fragment.getString(R.string.cannot_call_yourself)) + } + } + 2 -> { + val currentCall = callManager.getCurrentCall() + if (currentCall != null) { + // resume existing if same room, if not prompt to kill and then restart new call? + if (currentCall.roomId == roomId) { + onTapToReturnToCall() + } + // else { + // TODO might not work well, and should prompt + // webRtcPeerConnectionManager.endCall() + // safeStartCall(it, isVideoCall) + // } + } else if (!state.isAllowedToStartWebRTCCall) { + showDialogWithMessage(fragment.getString( + if (state.isDm()) { + R.string.no_permissions_to_start_webrtc_call_in_direct_room + } else { + R.string.no_permissions_to_start_webrtc_call + }) + ) + } else { + safeStartCall(isVideoCall) + } + } + else -> { + // it's jitsi call + // can you add widgets?? + if (!state.isAllowedToManageWidgets) { + // You do not have permission to start a conference call in this room + showDialogWithMessage(fragment.getString( + if (state.isDm()) { + R.string.no_permissions_to_start_conf_call_in_direct_room + } else { + R.string.no_permissions_to_start_conf_call + } + )) + } else { + if (state.activeRoomWidgets()?.filter { it.type == WidgetType.Jitsi }?.any() == true) { + // A conference is already in progress! + showDialogWithMessage(fragment.getString(R.string.conference_call_in_progress)) + } else { + AlertDialog.Builder(fragment.requireContext()) + .setTitle(if (isVideoCall) R.string.video_meeting else R.string.audio_meeting) + .setMessage(R.string.audio_video_meeting_description) + .setPositiveButton(fragment.getString(R.string.create)) { _, _ -> + // create the widget, then navigate to it.. + roomDetailViewModel.handle(RoomDetailAction.AddJitsiWidget(isVideoCall)) + } + .setNegativeButton(fragment.getString(R.string.cancel), null) + .show() + } + } + } + } + } + + private fun safeStartCall(isVideoCall: Boolean) { + if (vectorPreferences.preventAccidentalCall()) { + AlertDialog.Builder(fragment.requireActivity()) + .setMessage(if (isVideoCall) R.string.start_video_call_prompt_msg else R.string.start_voice_call_prompt_msg) + .setPositiveButton(if (isVideoCall) R.string.start_video_call else R.string.start_voice_call) { _, _ -> + safeStartCall2(isVideoCall) + } + .setNegativeButton(R.string.cancel, null) + .show() + } else { + safeStartCall2(isVideoCall) + } + } + + private fun safeStartCall2(isVideoCall: Boolean) { + val startCallAction = RoomDetailAction.StartCall(isVideoCall) + roomDetailViewModel.pendingAction = startCallAction + if (isVideoCall) { + if (checkPermissions(PERMISSIONS_FOR_VIDEO_IP_CALL, + fragment.requireActivity(), + startCallActivityResultLauncher, + R.string.permissions_rationale_msg_camera_and_audio)) { + roomDetailViewModel.pendingAction = null + roomDetailViewModel.handle(startCallAction) + } + } else { + if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, + fragment.requireActivity(), + startCallActivityResultLauncher, + R.string.permissions_rationale_msg_record_audio)) { + roomDetailViewModel.pendingAction = null + roomDetailViewModel.handle(startCallAction) + } + } + } + + override fun onSaveInstanceState(outState: Bundle) = Unit + + override fun onRestoreInstanceState(savedInstanceState: Bundle?) { + if (savedInstanceState != null) { + (fragment.parentFragmentManager.findFragmentByTag(DIALER_OPTION_TAG) as? DialerChoiceBottomSheet)?.applyListeners() + (fragment.parentFragmentManager.findFragmentByTag(DIAL_PAD_TAG) as? CallDialPadBottomSheet)?.applyCallback() + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerView.kt index 5baec0a8f4..6c7721ca02 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerView.kt @@ -24,7 +24,6 @@ import android.view.ViewGroup import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.core.text.toSpannable -import androidx.core.view.isVisible import androidx.transition.ChangeBounds import androidx.transition.Fade import androidx.transition.Transition @@ -41,8 +40,8 @@ import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel */ class TextComposerView @JvmOverloads constructor( context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) { + attrs: AttributeSet? = null, + defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) { interface Callback : ComposerEditText.Callback { fun onCloseRelatedMessage() @@ -143,16 +142,10 @@ class TextComposerView @JvmOverloads constructor( fun setRoomEncrypted(isEncrypted: Boolean, roomEncryptionTrustLevel: RoomEncryptionTrustLevel?) { if (isEncrypted) { views.composerEditText.setHint(R.string.room_message_placeholder) - views.composerShieldImageView.isVisible = true - val shieldRes = when (roomEncryptionTrustLevel) { - RoomEncryptionTrustLevel.Trusted -> R.drawable.ic_shield_trusted - RoomEncryptionTrustLevel.Warning -> R.drawable.ic_shield_warning - else -> R.drawable.ic_shield_black - } - views.composerShieldImageView.setImageResource(shieldRes) + views.composerShieldImageView.render(roomEncryptionTrustLevel) } else { views.composerEditText.setHint(R.string.room_message_placeholder) - views.composerShieldImageView.isVisible = false + views.composerShieldImageView.render(null) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/sticker/StickerPickerActionHandler.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/sticker/StickerPickerActionHandler.kt index d24b41ffb0..5039459c0c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/sticker/StickerPickerActionHandler.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/sticker/StickerPickerActionHandler.kt @@ -32,7 +32,7 @@ class StickerPickerActionHandler @Inject constructor(private val session: Sessio return@withContext RoomDetailViewEvents.DisplayEnableIntegrationsWarning } val stickerWidget = session.widgetService().getUserWidgets(WidgetType.StickerPicker.values()).firstOrNull { it.isActive } - if (stickerWidget == null || stickerWidget.computedUrl.isNullOrBlank()) { + if (stickerWidget == null || stickerWidget.widgetContent.url.isNullOrBlank()) { RoomDetailViewEvents.DisplayPromptForIntegrationManager } else { RoomDetailViewEvents.OpenStickerPicker( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt index 1e108a2062..9acd34c827 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt @@ -30,6 +30,7 @@ import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.epoxy.LoadingItem_ import im.vector.app.core.extensions.localDateTime import im.vector.app.core.extensions.nextOrNull +import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.home.room.detail.RoomDetailAction import im.vector.app.features.home.room.detail.RoomDetailViewState import im.vector.app.features.home.room.detail.UnreadState @@ -37,17 +38,15 @@ import im.vector.app.features.home.room.detail.timeline.factory.MergedHeaderItem import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactory import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder -import im.vector.app.features.home.room.detail.timeline.helper.ReadMarkerVisibilityStateChangedListener +import im.vector.app.features.home.room.detail.timeline.helper.TimelineControllerInterceptorHelper import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider -import im.vector.app.features.home.room.detail.timeline.item.BaseEventItem import im.vector.app.features.home.room.detail.timeline.item.BasedMergedItem import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData -import im.vector.app.features.home.room.detail.timeline.item.TimelineReadMarkerItem_ import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.VideoContentRenderer @@ -73,6 +72,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec private val timelineMediaSizeProvider: TimelineMediaSizeProvider, private val mergedHeaderItemFactory: MergedHeaderItemFactory, private val session: Session, + private val callManager: WebRtcCallManager, @TimelineEventControllerHandler private val backgroundHandler: Handler ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener, EpoxyController.Interceptor { @@ -99,6 +99,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec // TODO move all callbacks to this? fun onTimelineItemAction(itemAction: RoomDetailAction) + // Introduce ViewModel scoped component (or Hilt?) fun getPreviewUrlRetriever(): PreviewUrlRetriever } @@ -190,58 +191,20 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } + private val interceptorHelper = TimelineControllerInterceptorHelper( + ::positionOfReadMarker, + adapterPositionMapping, + vectorPreferences, + callManager + ) + init { addInterceptor(this) requestModelBuild() } - // Update position when we are building new items override fun intercept(models: MutableList>) = synchronized(modelCache) { - positionOfReadMarker = null - adapterPositionMapping.clear() - models.forEachIndexed { index, epoxyModel -> - if (epoxyModel is BaseEventItem) { - epoxyModel.getEventIds().forEach { - adapterPositionMapping[it] = index - } - } - } - val currentUnreadState = this.unreadState - if (currentUnreadState is UnreadState.HasUnread) { - val position = adapterPositionMapping[currentUnreadState.firstUnreadEventId]?.plus(1) - positionOfReadMarker = position - if (position != null) { - val readMarker = TimelineReadMarkerItem_() - .also { - it.id("read_marker") - it.setOnVisibilityStateChanged(ReadMarkerVisibilityStateChangedListener(callback)) - } - models.add(position, readMarker) - } - } - val shouldAddBackwardPrefetch = timeline?.hasMoreToLoad(Timeline.Direction.BACKWARDS) ?: false - if (shouldAddBackwardPrefetch) { - val indexOfPrefetchBackward = (previousModelsSize - 1) - .coerceAtMost(models.size - DEFAULT_PREFETCH_THRESHOLD) - .coerceAtLeast(0) - - val loadingItem = LoadingItem_() - .id("prefetch_backward_loading${System.currentTimeMillis()}") - .showLoader(false) - .setVisibilityStateChangedListener(Timeline.Direction.BACKWARDS) - - models.add(indexOfPrefetchBackward, loadingItem) - } - val shouldAddForwardPrefetch = timeline?.hasMoreToLoad(Timeline.Direction.FORWARDS) ?: false - if (shouldAddForwardPrefetch) { - val indexOfPrefetchForward = DEFAULT_PREFETCH_THRESHOLD.coerceAtMost(models.size - 1) - val loadingItem = LoadingItem_() - .id("prefetch_forward_loading${System.currentTimeMillis()}") - .showLoader(false) - .setVisibilityStateChangedListener(Timeline.Direction.FORWARDS) - models.add(indexOfPrefetchForward, loadingItem) - } - previousModelsSize = models.size + interceptorHelper.intercept(models, unreadState, timeline, callback) } fun update(viewState: RoomDetailViewState) { @@ -410,6 +373,14 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } + private fun LoadingItem_.setVisibilityStateChangedListener(direction: Timeline.Direction): LoadingItem_ { + return onVisibilityStateChanged { _, _, visibilityState -> + if (visibilityState == VisibilityState.VISIBLE) { + callback?.onLoadMore(direction) + } + } + } + private fun updateUTDStates(event: TimelineEvent, nextEvent: TimelineEvent?) { if (vectorPreferences.labShowCompleteHistoryInEncryptedRoom()) { return @@ -440,14 +411,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec return shouldAdd } - private fun LoadingItem_.setVisibilityStateChangedListener(direction: Timeline.Direction): LoadingItem_ { - return onVisibilityStateChanged { _, _, visibilityState -> - if (visibilityState == VisibilityState.VISIBLE) { - callback?.onLoadMore(direction) - } - } - } - fun searchPositionOfEvent(eventId: String?): Int? = synchronized(modelCache) { return adapterPositionMapping[eventId] } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index 5008f0e0aa..1697d9250e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -197,7 +197,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted EventType.CALL_CANDIDATES, EventType.CALL_HANGUP, EventType.CALL_ANSWER -> { - noticeEventFormatter.format(timelineEvent, room?.roomSummary()) + noticeEventFormatter.format(timelineEvent) } else -> null } ?: "" diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt new file mode 100644 index 0000000000..d3dd94eae7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.app.features.home.room.detail.timeline.factory + +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.features.call.webrtc.WebRtcCallManager +import im.vector.app.features.home.room.detail.timeline.MessageColorProvider +import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider +import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory +import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory +import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder +import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem +import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem_ +import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent +import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent +import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent +import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent +import org.matrix.android.sdk.api.session.room.model.call.CallSignallingContent +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.util.toMatrixItem +import javax.inject.Inject + +class CallItemFactory @Inject constructor( + private val messageColorProvider: MessageColorProvider, + private val messageInformationDataFactory: MessageInformationDataFactory, + private val messageItemAttributesFactory: MessageItemAttributesFactory, + private val avatarSizeProvider: AvatarSizeProvider, + private val roomSummariesHolder: RoomSummariesHolder, + private val callManager: WebRtcCallManager +) { + + fun create(event: TimelineEvent, + highlight: Boolean, + callback: TimelineEventController.Callback? + ): VectorEpoxyModel<*>? { + if (event.root.eventId == null) return null + val roomId = event.roomId + val informationData = messageInformationDataFactory.create(event, null) + val callSignalingContent = event.getCallSignallingContent() ?: return null + val callId = callSignalingContent.callId ?: return null + val call = callManager.getCallById(callId) + val callKind = when { + call == null -> CallTileTimelineItem.CallKind.UNKNOWN + call.mxCall.isVideoCall -> CallTileTimelineItem.CallKind.VIDEO + else -> CallTileTimelineItem.CallKind.AUDIO + } + return when (event.root.getClearType()) { + EventType.CALL_ANSWER -> { + createCallTileTimelineItem( + roomId = roomId, + callId = callId, + callStatus = CallTileTimelineItem.CallStatus.IN_CALL, + callKind = callKind, + callback = callback, + highlight = highlight, + informationData = informationData, + isStillActive = call != null + ) + } + EventType.CALL_INVITE -> { + createCallTileTimelineItem( + roomId = roomId, + callId = callId, + callStatus = CallTileTimelineItem.CallStatus.INVITED, + callKind = callKind, + callback = callback, + highlight = highlight, + informationData = informationData, + isStillActive = call != null + ) + } + EventType.CALL_REJECT -> { + createCallTileTimelineItem( + roomId = roomId, + callId = callId, + callStatus = CallTileTimelineItem.CallStatus.REJECTED, + callKind = callKind, + callback = callback, + highlight = highlight, + informationData = informationData, + isStillActive = false + ) + } + EventType.CALL_HANGUP -> { + createCallTileTimelineItem( + roomId = roomId, + callId = callId, + callStatus = CallTileTimelineItem.CallStatus.ENDED, + callKind = callKind, + callback = callback, + highlight = highlight, + informationData = informationData, + isStillActive = false + ) + } + else -> null + } + } + + private fun TimelineEvent.getCallSignallingContent(): CallSignallingContent? { + return when (root.getClearType()) { + EventType.CALL_INVITE -> root.getClearContent().toModel() + EventType.CALL_HANGUP -> root.getClearContent().toModel() + EventType.CALL_REJECT -> root.getClearContent().toModel() + EventType.CALL_ANSWER -> root.getClearContent().toModel() + else -> null + } + } + + private fun createCallTileTimelineItem( + roomId: String, + callId: String, + callKind: CallTileTimelineItem.CallKind, + callStatus: CallTileTimelineItem.CallStatus, + informationData: MessageInformationData, + highlight: Boolean, + isStillActive: Boolean, + callback: TimelineEventController.Callback? + ): CallTileTimelineItem? { + val userOfInterest = roomSummariesHolder.get(roomId)?.toMatrixItem() ?: return null + val attributes = messageItemAttributesFactory.create(null, informationData, callback).let { + CallTileTimelineItem.Attributes( + callId = callId, + callKind = callKind, + callStatus = callStatus, + informationData = informationData, + avatarRenderer = it.avatarRenderer, + messageColorProvider = messageColorProvider, + itemClickListener = it.itemClickListener, + itemLongClickListener = it.itemLongClickListener, + reactionPillCallback = it.reactionPillCallback, + readReceiptsCallback = it.readReceiptsCallback, + userOfInterest = userOfInterest, + callback = callback, + isStillActive = isStillActive + ) + } + return CallTileTimelineItem_() + .attributes(attributes) + .highlighted(highlight) + .leftGuideline(avatarSizeProvider.leftGuideline) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/DefaultItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/DefaultItemFactory.kt index 2120576bee..9d82103d3b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/DefaultItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/DefaultItemFactory.kt @@ -16,7 +16,6 @@ package im.vector.app.features.home.room.detail.timeline.factory -import android.view.View import im.vector.app.R import im.vector.app.core.resources.StringProvider import im.vector.app.features.home.AvatarRenderer @@ -42,7 +41,7 @@ class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: Ava avatarRenderer = avatarRenderer, informationData = informationData, text = text, - itemLongClickListener = View.OnLongClickListener { view -> + itemLongClickListener = { view -> callback?.onEventLongClicked(informationData, null, view) ?: false }, readReceiptsCallback = callback diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index 23bd041e95..2134645d8d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -22,7 +22,7 @@ import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.MergedTimelineEventVisibilityStateChangedListener -import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder +import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder import im.vector.app.features.home.room.detail.timeline.helper.canBeMerged import im.vector.app.features.home.room.detail.timeline.helper.isRoomConfiguration import im.vector.app.features.home.room.detail.timeline.helper.prevSameTypeEvents @@ -47,7 +47,7 @@ import javax.inject.Inject class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolder: ActiveSessionHolder, private val avatarRenderer: AvatarRenderer, private val avatarSizeProvider: AvatarSizeProvider, - private val roomSummaryHolder: RoomSummaryHolder) { + private val roomSummariesHolder: RoomSummariesHolder) { private val collapsedEventIds = linkedSetOf() private val mergeItemCollapseStates = HashMap() @@ -77,7 +77,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde } } - private fun isDirectRoom() = roomSummaryHolder.roomSummary?.isDirect.orFalse() + private fun isDirectRoom(roomId: String) = roomSummariesHolder.get(roomId)?.isDirect.orFalse() private fun buildMembershipEventsMergedSummary(currentPosition: Int, items: List, @@ -102,7 +102,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde memberName = mergedEvent.senderInfo.disambiguatedDisplayName, localId = mergedEvent.localId, eventId = mergedEvent.root.eventId ?: "", - isDirectRoom = isDirectRoom() + isDirectRoom = isDirectRoom(event.roomId) ) mergedData.add(data) } @@ -174,7 +174,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde memberName = mergedEvent.senderInfo.disambiguatedDisplayName, localId = mergedEvent.localId, eventId = mergedEvent.root.eventId ?: "", - isDirectRoom = isDirectRoom() + isDirectRoom = isDirectRoom(event.roomId) ) mergedData.add(data) } @@ -191,8 +191,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde collapsedEventIds.removeAll(mergedEventIds) } val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() } - val powerLevelsHelper = roomSummaryHolder.roomSummary?.roomId - ?.let { activeSessionHolder.getSafeActiveSession()?.getRoom(it) } + val powerLevelsHelper = activeSessionHolder.getSafeActiveSession()?.getRoom(event.roomId) ?.let { it.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.NoCondition)?.content?.toModel() } ?.let { PowerLevelsHelper(it) } val currentUserId = activeSessionHolder.getSafeActiveSession()?.myUserId ?: "" @@ -209,7 +208,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde readReceiptsCallback = callback, callback = callback, currentUserId = currentUserId, - roomSummary = roomSummaryHolder.roomSummary, + roomSummary = roomSummariesHolder.get(event.roomId), canChangeAvatar = powerLevelsHelper?.isUserAllowedToSend(currentUserId, true, EventType.STATE_ROOM_AVATAR) ?: false, canChangeTopic = powerLevelsHelper?.isUserAllowedToSend(currentUserId, true, EventType.STATE_ROOM_TOPIC) ?: false, canChangeName = powerLevelsHelper?.isUserAllowedToSend(currentUserId, true, EventType.STATE_ROOM_NAME) ?: false diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 4f52fcb54c..e9cf8251de 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -38,7 +38,6 @@ import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadSt import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory -import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem import im.vector.app.features.home.room.detail.timeline.item.MessageBlockCodeItem @@ -106,15 +105,17 @@ class MessageItemFactory @Inject constructor( private val messageItemAttributesFactory: MessageItemAttributesFactory, private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder, private val contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder, - private val roomSummaryHolder: RoomSummaryHolder, private val defaultItemFactory: DefaultItemFactory, private val noticeItemFactory: NoticeItemFactory, private val avatarSizeProvider: AvatarSizeProvider, private val pillsPostProcessorFactory: PillsPostProcessor.Factory, private val session: Session) { + // TODO inject this properly? + private var roomId: String = "" + private val pillsPostProcessor by lazy { - pillsPostProcessorFactory.create(roomSummaryHolder.roomSummary?.roomId) + pillsPostProcessorFactory.create(roomId) } fun create(event: TimelineEvent, @@ -123,8 +124,8 @@ class MessageItemFactory @Inject constructor( callback: TimelineEventController.Callback? ): VectorEpoxyModel<*>? { event.root.eventId ?: return null + roomId = event.roomId val informationData = messageInformationDataFactory.create(event, nextEvent) - if (event.root.isRedacted()) { // message is redacted val attributes = messageItemAttributesFactory.create(null, informationData, callback) @@ -140,7 +141,7 @@ class MessageItemFactory @Inject constructor( || event.isEncrypted() && event.root.content.toModel()?.relatesTo?.type == RelationType.REPLACE ) { // This is an edit event, we should display it when debugging as a notice event - return noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback) + return noticeItemFactory.create(event, highlight, callback) } val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback) @@ -156,7 +157,7 @@ class MessageItemFactory @Inject constructor( is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, attributes) is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes) - is MessagePollResponseContent -> noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback) + is MessagePollResponseContent -> noticeItemFactory.create(event, highlight, callback) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } } @@ -230,14 +231,13 @@ class MessageItemFactory @Inject constructor( attributes: AbsMessageItem.Attributes): VerificationRequestItem? { // If this request is not sent by me or sent to me, we should ignore it in timeline val myUserId = session.myUserId - val roomId = roomSummaryHolder.roomSummary?.roomId if (informationData.senderId != myUserId && messageContent.toUserId != myUserId) { return null } val otherUserId = if (informationData.sentByMe) messageContent.toUserId else informationData.senderId val otherUserName = if (informationData.sentByMe) { - session.getRoomMember(messageContent.toUserId, roomId ?: "")?.displayName + session.getRoomMember(messageContent.toUserId, roomId)?.displayName } else { informationData.memberName } @@ -322,7 +322,7 @@ class MessageItemFactory @Inject constructor( mode(ImageContentRenderer.Mode.STICKER) } else { clickListener( - DebouncedClickListener(View.OnClickListener { view -> + DebouncedClickListener({ view -> callback?.onImageMessageClicked(messageContent, data, view) })) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/NoticeItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/NoticeItemFactory.kt index ec065543f5..12c7c2318a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/NoticeItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/NoticeItemFactory.kt @@ -16,7 +16,6 @@ package im.vector.app.features.home.room.detail.timeline.factory -import android.view.View import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.format.NoticeEventFormatter @@ -24,7 +23,6 @@ import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvide import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.app.features.home.room.detail.timeline.item.NoticeItem import im.vector.app.features.home.room.detail.timeline.item.NoticeItem_ -import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import javax.inject.Inject @@ -35,15 +33,14 @@ class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEv fun create(event: TimelineEvent, highlight: Boolean, - roomSummary: RoomSummary?, callback: TimelineEventController.Callback?): NoticeItem? { - val formattedText = eventFormatter.format(event, roomSummary) ?: return null + val formattedText = eventFormatter.format(event) ?: return null val informationData = informationDataFactory.create(event, null) val attributes = NoticeItem.Attributes( avatarRenderer = avatarRenderer, informationData = informationData, noticeText = formattedText, - itemLongClickListener = View.OnLongClickListener { view -> + itemLongClickListener = { view -> callback?.onEventLongClicked(informationData, null, view) ?: false }, readReceiptsCallback = callback, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt index 25b5fd718b..31adbdb8a6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/RoomCreateItemFactory.kt @@ -21,7 +21,6 @@ import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.features.home.room.detail.timeline.TimelineEventController -import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder import im.vector.app.features.home.room.detail.timeline.item.RoomCreateItem_ import me.gujun.android.span.span import org.matrix.android.sdk.api.session.Session @@ -33,7 +32,6 @@ import javax.inject.Inject class RoomCreateItemFactory @Inject constructor(private val stringProvider: StringProvider, private val userPreferencesProvider: UserPreferencesProvider, private val session: Session, - private val roomSummaryHolder: RoomSummaryHolder, private val noticeItemFactory: NoticeItemFactory) { fun create(event: TimelineEvent, callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? { @@ -54,7 +52,7 @@ class RoomCreateItemFactory @Inject constructor(private val stringProvider: Stri private fun defaultRendering(event: TimelineEvent, callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? { return if (userPreferencesProvider.shouldShowHiddenEvents()) { - noticeItemFactory.create(event, false, roomSummaryHolder.roomSummary, callback) + noticeItemFactory.create(event, false, callback) } else { null } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 943e78ae35..7fd50147d4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -16,11 +16,11 @@ package im.vector.app.features.home.room.detail.timeline.factory -import im.vector.app.core.epoxy.EmptyItem_ +import im.vector.app.core.epoxy.TimelineEmptyItem +import im.vector.app.core.epoxy.TimelineEmptyItem_ import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.features.home.room.detail.timeline.TimelineEventController -import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import timber.log.Timber @@ -33,8 +33,8 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me private val encryptionItemFactory: EncryptionItemFactory, private val roomCreateItemFactory: RoomCreateItemFactory, private val widgetItemFactory: WidgetItemFactory, - private val roomSummaryHolder: RoomSummaryHolder, private val verificationConclusionItemFactory: VerificationItemFactory, + private val callItemFactory: CallItemFactory, private val userPreferencesProvider: UserPreferencesProvider) { fun create(event: TimelineEvent, @@ -59,17 +59,19 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.STATE_ROOM_HISTORY_VISIBILITY, EventType.STATE_ROOM_SERVER_ACL, EventType.STATE_ROOM_GUEST_ACCESS, - EventType.CALL_INVITE, - EventType.CALL_HANGUP, - EventType.CALL_ANSWER, EventType.STATE_ROOM_POWER_LEVELS, EventType.REACTION, - EventType.REDACTION -> noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback) + EventType.REDACTION -> noticeItemFactory.create(event, highlight, callback) EventType.STATE_ROOM_WIDGET_LEGACY, EventType.STATE_ROOM_WIDGET -> widgetItemFactory.create(event, highlight, callback) EventType.STATE_ROOM_ENCRYPTION -> encryptionItemFactory.create(event, highlight, callback) // State room create EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(event, callback) + // Calls + EventType.CALL_INVITE, + EventType.CALL_HANGUP, + EventType.CALL_REJECT, + EventType.CALL_ANSWER -> callItemFactory.create(event, highlight, callback) // Crypto EventType.ENCRYPTED -> { if (event.root.isRedacted()) { @@ -85,11 +87,14 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.KEY_VERIFICATION_KEY, EventType.KEY_VERIFICATION_READY, EventType.KEY_VERIFICATION_MAC, - EventType.CALL_CANDIDATES -> { + EventType.CALL_CANDIDATES, + EventType.CALL_REPLACES, + EventType.CALL_SELECT_ANSWER, + EventType.CALL_NEGOTIATE -> { // TODO These are not filtered out by timeline when encrypted // For now manually ignore if (userPreferencesProvider.shouldShowHiddenEvents()) { - noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback) + noticeItemFactory.create(event, highlight, callback) } else { null } @@ -110,6 +115,12 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me Timber.e(throwable, "failed to create message item") defaultItemFactory.create(event, highlight, callback, throwable) } - return (computedModel ?: EmptyItem_()) + return computedModel ?: buildEmptyItem(event) + } + + private fun buildEmptyItem(timelineEvent: TimelineEvent): TimelineEmptyItem { + return TimelineEmptyItem_() + .id(timelineEvent.localId) + .eventId(timelineEvent.eventId) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VerificationItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VerificationItemFactory.kt index 59daf5a0a0..0b623d78f1 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VerificationItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VerificationItemFactory.kt @@ -24,7 +24,6 @@ import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory -import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder import im.vector.app.features.home.room.detail.timeline.item.StatusTileTimelineItem import im.vector.app.features.home.room.detail.timeline.item.StatusTileTimelineItem_ import org.matrix.android.sdk.api.session.Session @@ -51,7 +50,6 @@ class VerificationItemFactory @Inject constructor( private val avatarSizeProvider: AvatarSizeProvider, private val noticeItemFactory: NoticeItemFactory, private val userPreferencesProvider: UserPreferencesProvider, - private val roomSummaryHolder: RoomSummaryHolder, private val stringProvider: StringProvider, private val session: Session ) { @@ -153,7 +151,7 @@ class VerificationItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback? ): VectorEpoxyModel<*>? { - if (userPreferencesProvider.shouldShowHiddenEvents()) return noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback) + if (userPreferencesProvider.shouldShowHiddenEvents()) return noticeItemFactory.create(event, highlight, callback) return null } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt index 8d8f42b2d1..260958b19e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt @@ -24,7 +24,6 @@ import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory -import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder import im.vector.app.features.home.room.detail.timeline.item.WidgetTileTimelineItem import im.vector.app.features.home.room.detail.timeline.item.WidgetTileTimelineItem_ import org.matrix.android.sdk.api.extensions.orFalse @@ -37,7 +36,6 @@ import javax.inject.Inject class WidgetItemFactory @Inject constructor( private val sp: StringProvider, - private val roomSummaryHolder: RoomSummaryHolder, private val messageItemAttributesFactory: MessageItemAttributesFactory, private val informationDataFactory: MessageInformationDataFactory, private val noticeItemFactory: NoticeItemFactory, @@ -58,7 +56,7 @@ class WidgetItemFactory @Inject constructor( return when (WidgetType.fromString(widgetContent.type ?: previousWidgetContent?.type ?: "")) { WidgetType.Jitsi -> createJitsiItem(event, callback, widgetContent, previousWidgetContent) // There is lot of other widget types we could improve here - else -> noticeItemFactory.create(event, highlight, roomSummaryHolder.roomSummary, callback) + else -> noticeItemFactory.create(event, highlight, callback) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt index f4632b0e10..499e27f838 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt @@ -23,7 +23,6 @@ import im.vector.app.core.resources.StringProvider import me.gujun.android.span.span import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.toModel -import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS @@ -41,7 +40,7 @@ class DisplayableEventFormatter @Inject constructor( private val noticeEventFormatter: NoticeEventFormatter ) { - fun format(timelineEvent: TimelineEvent, appendAuthor: Boolean, roomSummary: RoomSummary?): CharSequence { + fun format(timelineEvent: TimelineEvent, appendAuthor: Boolean): CharSequence { if (timelineEvent.root.isRedacted()) { return noticeEventFormatter.formatRedactedEvent(timelineEvent.root) } @@ -131,7 +130,7 @@ class DisplayableEventFormatter @Inject constructor( } else -> { return span { - text = noticeEventFormatter.format(timelineEvent, roomSummary) ?: "" + text = noticeEventFormatter.format(timelineEvent) ?: "" textStyle = "italic" } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt index 8204ce39ec..1c86749abc 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt @@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail.timeline.format import im.vector.app.ActiveSessionDataSource import im.vector.app.R import im.vector.app.core.resources.StringProvider +import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder import im.vector.app.features.settings.VectorPreferences import org.matrix.android.sdk.api.extensions.appendNl import org.matrix.android.sdk.api.extensions.orFalse @@ -55,6 +56,7 @@ class NoticeEventFormatter @Inject constructor( private val activeSessionDataSource: ActiveSessionDataSource, private val roomHistoryVisibilityFormatter: RoomHistoryVisibilityFormatter, private val vectorPreferences: VectorPreferences, + private val roomSummariesHolder: RoomSummariesHolder, private val sp: StringProvider ) { @@ -65,7 +67,8 @@ class NoticeEventFormatter @Inject constructor( private fun RoomSummary?.isDm() = this?.isDirect.orFalse() - fun format(timelineEvent: TimelineEvent, rs: RoomSummary?): CharSequence? { + fun format(timelineEvent: TimelineEvent): CharSequence? { + val rs = roomSummariesHolder.get(timelineEvent.roomId) return when (val type = timelineEvent.root.getClearType()) { EventType.STATE_ROOM_JOIN_RULES -> formatJoinRulesEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName, rs) EventType.STATE_ROOM_CREATE -> formatRoomCreateEvent(timelineEvent.root, rs) @@ -88,7 +91,11 @@ class NoticeEventFormatter @Inject constructor( EventType.CALL_INVITE, EventType.CALL_CANDIDATES, EventType.CALL_HANGUP, + EventType.CALL_REJECT, EventType.CALL_ANSWER -> formatCallEvent(type, timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + EventType.CALL_NEGOTIATE, + EventType.CALL_SELECT_ANSWER, + EventType.CALL_REPLACES, EventType.MESSAGE, EventType.REACTION, EventType.KEY_VERIFICATION_START, @@ -176,6 +183,7 @@ class NoticeEventFormatter @Inject constructor( EventType.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(event, senderName, rs) EventType.CALL_INVITE, EventType.CALL_HANGUP, + EventType.CALL_REJECT, EventType.CALL_ANSWER -> formatCallEvent(type, event, senderName) EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(event, senderName, rs) else -> { @@ -344,6 +352,12 @@ class NoticeEventFormatter @Inject constructor( } else { sp.getString(R.string.notice_call_candidates, senderName) } + EventType.CALL_REJECT -> + if (event.isSentByCurrentUser()) { + sp.getString(R.string.call_tile_you_declined, "") + } else { + sp.getString(R.string.call_tile_other_declined, senderName) + } else -> null } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index 8a8bf364e1..802c177197 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -45,7 +45,7 @@ import javax.inject.Inject * This class compute if data of an event (such has avatar, display name, ...) should be displayed, depending on the previous event in the timeline */ class MessageInformationDataFactory @Inject constructor(private val session: Session, - private val roomSummaryHolder: RoomSummaryHolder, + private val roomSummariesHolder: RoomSummariesHolder, private val dateFormatter: VectorDateFormatter, private val vectorPreferences: VectorPreferences) { @@ -116,7 +116,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses } private fun getE2EDecoration(event: TimelineEvent): E2EDecoration { - val roomSummary = roomSummaryHolder.roomSummary + val roomSummary = roomSummariesHolder.get(event.roomId) return if ( event.root.sendState == SendState.SYNCED && roomSummary?.isEncrypted.orFalse() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt index c120fa671c..043fd9e896 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt @@ -15,7 +15,6 @@ */ package im.vector.app.features.home.room.detail.timeline.helper -import android.view.View import im.vector.app.EmojiCompatFontProvider import im.vector.app.core.utils.DebouncedClickListener import im.vector.app.features.home.AvatarRenderer @@ -39,13 +38,13 @@ class MessageItemAttributesFactory @Inject constructor( informationData = informationData, avatarRenderer = avatarRenderer, messageColorProvider = messageColorProvider, - itemLongClickListener = View.OnLongClickListener { view -> + itemLongClickListener = { view -> callback?.onEventLongClicked(informationData, messageContent, view) ?: false }, - itemClickListener = DebouncedClickListener(View.OnClickListener { view -> + itemClickListener = DebouncedClickListener({ view -> callback?.onEventCellClicked(informationData, messageContent, view) }), - memberClickListener = DebouncedClickListener(View.OnClickListener { + memberClickListener = DebouncedClickListener({ callback?.onMemberNameClicked(informationData) }), reactionPillCallback = callback, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/RoomSummaryHolder.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/RoomSummariesHolder.kt similarity index 63% rename from vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/RoomSummaryHolder.kt rename to vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/RoomSummariesHolder.kt index d3ae091733..ac953f91f7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/RoomSummaryHolder.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/RoomSummariesHolder.kt @@ -16,25 +16,28 @@ package im.vector.app.features.home.room.detail.timeline.helper -import im.vector.app.core.di.ScreenScope import org.matrix.android.sdk.api.session.room.model.RoomSummary import javax.inject.Inject +import javax.inject.Singleton /* - This holds an instance of the current room summary. - You should use this in the context of the timeline. + You can use this to share room summary instances within the app. + You should probably use this only in the context of the timeline */ -@ScreenScope -class RoomSummaryHolder @Inject constructor() { +@Singleton +class RoomSummariesHolder @Inject constructor() { - var roomSummary: RoomSummary? = null - private set + private var roomSummaries = HashMap() fun set(roomSummary: RoomSummary) { - this.roomSummary = roomSummary + roomSummaries[roomSummary.roomId] = roomSummary } + fun get(roomId: String) = roomSummaries[roomId] + + fun remove(roomId: String) = roomSummaries.remove(roomId) + fun clear() { - roomSummary = null + roomSummaries.clear() } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineControllerInterceptorHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineControllerInterceptorHelper.kt new file mode 100644 index 0000000000..971a3a35d8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineControllerInterceptorHelper.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.helper + +import com.airbnb.epoxy.EpoxyModel +import com.airbnb.epoxy.VisibilityState +import im.vector.app.core.epoxy.LoadingItem_ +import im.vector.app.core.epoxy.TimelineEmptyItem_ +import im.vector.app.features.call.webrtc.WebRtcCallManager +import im.vector.app.features.home.room.detail.UnreadState +import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem +import im.vector.app.features.home.room.detail.timeline.item.ItemWithEvents +import im.vector.app.features.home.room.detail.timeline.item.TimelineReadMarkerItem_ +import im.vector.app.features.settings.VectorPreferences +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import kotlin.reflect.KMutableProperty0 + +private const val DEFAULT_PREFETCH_THRESHOLD = 30 + +class TimelineControllerInterceptorHelper(private val positionOfReadMarker: KMutableProperty0, + private val adapterPositionMapping: MutableMap, + private val vectorPreferences: VectorPreferences, + private val callManager: WebRtcCallManager +) { + + private var previousModelsSize = 0 + + // Update position when we are building new items + fun intercept( + models: MutableList>, + unreadState: UnreadState, + timeline: Timeline?, + callback: TimelineEventController.Callback? + ) { + positionOfReadMarker.set(null) + adapterPositionMapping.clear() + val callIds = mutableSetOf() + + // Add some prefetch loader if needed + models.addBackwardPrefetchIfNeeded(timeline, callback) + models.addForwardPrefetchIfNeeded(timeline, callback) + + val modelsIterator = models.listIterator() + val showHiddenEvents = vectorPreferences.shouldShowHiddenEvents() + var index = 0 + val firstUnreadEventId = (unreadState as? UnreadState.HasUnread)?.firstUnreadEventId + // Then iterate on models so we have the exact positions in the adapter + modelsIterator.forEach { epoxyModel -> + if (epoxyModel is ItemWithEvents) { + epoxyModel.getEventIds().forEach { eventId -> + adapterPositionMapping[eventId] = index + if (eventId == firstUnreadEventId) { + modelsIterator.addReadMarkerItem(callback) + index++ + positionOfReadMarker.set(index) + } + } + } + if (epoxyModel is CallTileTimelineItem) { + modelsIterator.removeCallItemIfNeeded(epoxyModel, callIds, showHiddenEvents) + } + index++ + } + previousModelsSize = models.size + } + + private fun MutableListIterator>.addReadMarkerItem(callback: TimelineEventController.Callback?) { + val readMarker = TimelineReadMarkerItem_() + .also { + it.id("read_marker") + it.setOnVisibilityStateChanged(ReadMarkerVisibilityStateChangedListener(callback)) + } + add(readMarker) + // Use next as we still have some process to do before the next iterator loop + next() + } + + private fun MutableListIterator>.removeCallItemIfNeeded( + epoxyModel: CallTileTimelineItem, + callIds: MutableSet, + showHiddenEvents: Boolean + ) { + val callId = epoxyModel.attributes.callId + // We should remove the call tile if we already have one for this call or + // if this is an active call tile without an actual call (which can happen with permalink) + val shouldRemoveCallItem = callIds.contains(callId) + || (!callManager.getAdvertisedCalls().contains(callId) && epoxyModel.attributes.callStatus.isActive()) + if (shouldRemoveCallItem && !showHiddenEvents) { + remove() + val emptyItem = TimelineEmptyItem_() + .id(epoxyModel.id()) + .eventId(epoxyModel.attributes.informationData.eventId) + add(emptyItem) + } + callIds.add(callId) + } + + private fun MutableList>.addBackwardPrefetchIfNeeded(timeline: Timeline?, callback: TimelineEventController.Callback?) { + val shouldAddBackwardPrefetch = timeline?.hasMoreToLoad(Timeline.Direction.BACKWARDS) ?: false + if (shouldAddBackwardPrefetch) { + val indexOfPrefetchBackward = (previousModelsSize - 1) + .coerceAtMost(size - DEFAULT_PREFETCH_THRESHOLD) + .coerceAtLeast(0) + + val loadingItem = LoadingItem_() + .id("prefetch_backward_loading${System.currentTimeMillis()}") + .showLoader(false) + .setVisibilityStateChangedListener(Timeline.Direction.BACKWARDS, callback) + + add(indexOfPrefetchBackward, loadingItem) + } + } + + private fun MutableList>.addForwardPrefetchIfNeeded(timeline: Timeline?, callback: TimelineEventController.Callback?) { + val shouldAddForwardPrefetch = timeline?.hasMoreToLoad(Timeline.Direction.FORWARDS) ?: false + if (shouldAddForwardPrefetch) { + val indexOfPrefetchForward = DEFAULT_PREFETCH_THRESHOLD.coerceAtMost(size - 1) + val loadingItem = LoadingItem_() + .id("prefetch_forward_loading${System.currentTimeMillis()}") + .showLoader(false) + .setVisibilityStateChangedListener(Timeline.Direction.FORWARDS, callback) + add(indexOfPrefetchForward, loadingItem) + } + } + + private fun LoadingItem_.setVisibilityStateChangedListener( + direction: Timeline.Direction, + callback: TimelineEventController.Callback? + ): LoadingItem_ { + return onVisibilityStateChanged { _, _, visibilityState -> + if (visibilityState == VisibilityState.VISIBLE) { + callback?.onLoadMore(direction) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index 4fcac6c7f7..eb5b8081f9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -38,6 +38,7 @@ object TimelineDisplayableEvents { EventType.CALL_INVITE, EventType.CALL_HANGUP, EventType.CALL_ANSWER, + EventType.CALL_REJECT, EventType.ENCRYPTED, EventType.STATE_ROOM_ENCRYPTION, EventType.STATE_ROOM_GUEST_ACCESS, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt index 29aca2c4d5..a65f1e10f2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt @@ -23,11 +23,13 @@ import android.widget.TextView import androidx.annotation.IdRes import androidx.core.view.isVisible import im.vector.app.R +import im.vector.app.core.ui.views.ShieldImageView import im.vector.app.core.utils.DebouncedClickListener import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.reactions.widget.ReactionButton +import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel import org.matrix.android.sdk.api.session.room.send.SendState /** @@ -39,7 +41,7 @@ abstract class AbsBaseMessageItem : BaseEventItem abstract val baseAttributes: Attributes - private val _readReceiptsClickListener = DebouncedClickListener(View.OnClickListener { + private val _readReceiptsClickListener = DebouncedClickListener({ baseAttributes.readReceiptsCallback?.onReadReceiptsClicked(baseAttributes.informationData.readReceipts) }) @@ -94,13 +96,12 @@ abstract class AbsBaseMessageItem : BaseEventItem when (baseAttributes.informationData.e2eDecoration) { E2EDecoration.NONE -> { - holder.e2EDecorationView.isVisible = false + holder.e2EDecorationView.render(null) } E2EDecoration.WARN_IN_CLEAR, E2EDecoration.WARN_SENT_BY_UNVERIFIED, E2EDecoration.WARN_SENT_BY_UNKNOWN -> { - holder.e2EDecorationView.setImageResource(R.drawable.ic_shield_warning) - holder.e2EDecorationView.isVisible = true + holder.e2EDecorationView.render(RoomEncryptionTrustLevel.Warning) } } @@ -123,7 +124,7 @@ abstract class AbsBaseMessageItem : BaseEventItem abstract class Holder(@IdRes stubId: Int) : BaseEventItem.BaseHolder(stubId) { val reactionsContainer by bind(R.id.reactionsContainer) - val e2EDecorationView by bind(R.id.messageE2EDecoration) + val e2EDecorationView by bind(R.id.messageE2EDecoration) } /** diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt index e0f67fdd30..d4b1b8859a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -42,10 +42,10 @@ abstract class AbsMessageItem : AbsBaseMessageItem @EpoxyAttribute lateinit var attributes: Attributes - private val _avatarClickListener = DebouncedClickListener(View.OnClickListener { + private val _avatarClickListener = DebouncedClickListener({ attributes.avatarCallback?.onAvatarClicked(attributes.informationData) }) - private val _memberNameClickListener = DebouncedClickListener(View.OnClickListener { + private val _memberNameClickListener = DebouncedClickListener({ attributes.avatarCallback?.onMemberNameClicked(attributes.informationData) }) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BaseEventItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BaseEventItem.kt index e617489902..13bb6db6ef 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BaseEventItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/BaseEventItem.kt @@ -32,7 +32,7 @@ import im.vector.app.core.utils.DimensionConverter /** * Children must override getViewType() */ -abstract class BaseEventItem : VectorEpoxyModel() { +abstract class BaseEventItem : VectorEpoxyModel(), ItemWithEvents { // To use for instance when opening a permalink with an eventId @EpoxyAttribute @@ -53,12 +53,6 @@ abstract class BaseEventItem : VectorEpoxyModel holder.checkableBackground.isChecked = highlighted } - /** - * Returns the eventIds associated with the EventItem. - * Will generally get only one, but it handles the merging items. - */ - abstract fun getEventIds(): List - abstract class BaseHolder(@IdRes val stubId: Int) : VectorEpoxyHolder() { val leftGuideline by bind(R.id.messageStartGuideline) val checkableBackground by bind(R.id.messageSelectedBackground) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt new file mode 100644 index 0000000000..2d40035df1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.app.features.home.room.detail.timeline.item + +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.extensions.setLeftDrawable +import im.vector.app.core.extensions.setTextWithColoredPart +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.RoomDetailAction +import im.vector.app.features.home.room.detail.timeline.MessageColorProvider +import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import org.matrix.android.sdk.api.util.MatrixItem +import timber.log.Timber + +@EpoxyModelClass(layout = R.layout.item_timeline_event_base_state) +abstract class CallTileTimelineItem : AbsBaseMessageItem() { + + override val baseAttributes: AbsBaseMessageItem.Attributes + get() = attributes + + @EpoxyAttribute + lateinit var attributes: Attributes + + override fun getViewType() = STUB_ID + + override fun bind(holder: Holder) { + super.bind(holder) + holder.endGuideline.updateLayoutParams { + this.marginEnd = leftGuideline + } + holder.creatorNameView.text = attributes.userOfInterest.getBestName() + attributes.avatarRenderer.render(attributes.userOfInterest, holder.creatorAvatarView) + if (attributes.callKind != CallKind.UNKNOWN) { + holder.callKindView.isVisible = true + holder.callKindView.setText(attributes.callKind.title) + holder.callKindView.setLeftDrawable(attributes.callKind.icon) + } else { + holder.callKindView.isVisible = false + } + if (attributes.callStatus == CallStatus.INVITED && !attributes.informationData.sentByMe && attributes.isStillActive) { + holder.acceptRejectViewGroup.isVisible = true + holder.acceptView.setOnClickListener { + attributes.callback?.onTimelineItemAction(RoomDetailAction.AcceptCall(callId = attributes.callId)) + } + holder.rejectView.setLeftDrawable(R.drawable.ic_call_hangup, R.color.riotx_notice) + holder.rejectView.setOnClickListener { + attributes.callback?.onTimelineItemAction(RoomDetailAction.EndCall) + } + holder.statusView.isVisible = false + when (attributes.callKind) { + CallKind.CONFERENCE -> { + holder.rejectView.setText(R.string.ignore) + holder.acceptView.setText(R.string.join) + holder.acceptView.setLeftDrawable(R.drawable.ic_call_audio_small, R.color.riotx_accent) + } + CallKind.AUDIO -> { + holder.rejectView.setText(R.string.call_notification_reject) + holder.acceptView.setText(R.string.call_notification_answer) + holder.acceptView.setLeftDrawable(R.drawable.ic_call_audio_small, R.color.riotx_accent) + } + CallKind.VIDEO -> { + holder.rejectView.setText(R.string.call_notification_reject) + holder.acceptView.setText(R.string.call_notification_answer) + holder.acceptView.setLeftDrawable(R.drawable.ic_call_video_small, R.color.riotx_accent) + } + else -> { + Timber.w("Shouldn't be in that state") + } + } + } else { + holder.acceptRejectViewGroup.isVisible = false + holder.statusView.isVisible = true + } + holder.statusView.setCallStatus(attributes) + renderSendState(holder.view, null, holder.failedToSendIndicator) + } + + private fun TextView.setCallStatus(attributes: Attributes) { + when (attributes.callStatus) { + CallStatus.INVITED -> if (attributes.informationData.sentByMe) { + setText(R.string.call_tile_you_started_call) + } else { + text = context.getString(R.string.call_tile_other_started_call, attributes.userOfInterest.getBestName()) + } + CallStatus.IN_CALL -> setText(R.string.call_tile_in_call) + CallStatus.REJECTED -> if (attributes.informationData.sentByMe) { + setTextWithColoredPart(R.string.call_tile_you_declined, R.string.call_tile_call_back) { + val callbackAction = RoomDetailAction.StartCall(attributes.callKind == CallKind.VIDEO) + attributes.callback?.onTimelineItemAction(callbackAction) + } + } else { + text = context.getString(R.string.call_tile_other_declined, attributes.userOfInterest.getBestName()) + } + CallStatus.ENDED -> setText(R.string.call_tile_ended) + } + } + + class Holder : AbsBaseMessageItem.Holder(STUB_ID) { + val acceptView by bind