diff --git a/CHANGES.md b/CHANGES.md index 6448521a17..ae5c24a578 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,29 +1,69 @@ -Changes in Element 1.1.5 (2021-XX-XX) +Changes in Element 1.1.7 (2021-XX-XX) =================================================== Features ✨: - Allow changing nick colors (#2610) - + - Spaces beta + Improvements 🙌: - - + - Add ability to install APK from directly from Element (#2381) + - Delete and react to stickers (#3250) + - Compress video before sending (#442) + - Improve file too big error detection (#3245) + - User can now select video when selecting Gallery to send attachments to a room + - Add option to record a video from the camera Bugfix 🐛: - - + - Message states cosmetic changes (#3007) + - Fix exception in rxSingle (#3180) + - Do not invite the current user when creating a room (#3123) + - Fix color issues when the system theme is changed (#2738) + - Fix issues on Android 11 (#3067) + - Fix issue when opening encrypted files (#3186) + - Fix wording issue (#3242) + - Fix missing sender information after edits (#3184) + - Fix read marker not updating automatically (#3267) + - Sent video does not contains duration (#3272) + - Properly clean the back stack if the user cancel registration when waiting for email validation Translations 🗣: - SDK API changes ⚠️: - - + - RegistrationWizard.createAccount() parameters are now all optional, following Matrix spec (#3205) Build 🧱: - - + - Upgrade to gradle 7 + - https://github.com/Piasy/BigImageViewer is now hosted on mavenCentral() Test: - Other changes: - - + - New store descriptions + - `master` branch has been renamed to `main`. To apply change to your dev environment, run: +```sh +git branch -m master main +git fetch origin +git branch -u origin/main main +# And optionally +git remote prune origin +``` + - Allow cleartext (non-SSL) connections to Matrix servers on LAN hosts (#3166) + +Changes in Element 1.1.6 (2021-04-16) +=================================================== + +Bugfix 🐛: + - Fix crash on the timeline + - App crashes on "troubleshoot notifications" button (#3187) + +Changes in Element 1.1.5 (2021-04-15) +=================================================== + +Bugfix 🐛: + - Fix crash during Realm migration + - Fix crash when playing video (#3179) Changes in Element 1.1.4 (2021-04-09) =================================================== diff --git a/attachment-viewer/build.gradle b/attachment-viewer/build.gradle index 8db57a59af..0ac8ac4a9a 100644 --- a/attachment-viewer/build.gradle +++ b/attachment-viewer/build.gradle @@ -17,20 +17,6 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' -buildscript { - repositories { - maven { - url 'https://jitpack.io' - content { - // PhotoView - includeGroupByRegex 'com\\.github\\.chrisbanes' - } - } - jcenter() - } - -} - android { compileSdkVersion 30 diff --git a/build.gradle b/build.gradle index b8da6c3864..9c9e7a6c20 100644 --- a/build.gradle +++ b/build.gradle @@ -16,8 +16,8 @@ buildscript { classpath 'com.google.gms:google-services:4.3.5' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.1.1' - classpath 'com.google.android.gms:oss-licenses-plugin:0.10.3' - classpath "com.likethesalad.android:string-reference:1.2.1" + classpath 'com.google.android.gms:oss-licenses-plugin:0.10.4' + classpath "com.likethesalad.android:string-reference:1.2.2" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -45,25 +45,20 @@ allprojects { // PFLockScreen-Android includeGroupByRegex 'com\\.github\\.vector-im' - //Chat effects + // Chat effects includeGroupByRegex 'com\\.github\\.jetradarmobile' includeGroupByRegex 'nl\\.dionsegijn' } } - maven { - url "http://dl.bintray.com/piasy/maven" - content { - includeGroupByRegex "com\\.github\\.piasy" - } - } 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-3.1.0" + url "https://github.com/vector-im/jitsi_libre_maven/raw/main/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-3.1.0" } google() + mavenCentral() jcenter() } diff --git a/fastlane/metadata/android/ca/changelogs/40101020.txt b/fastlane/metadata/android/ca/changelogs/40101020.txt new file mode 100644 index 0000000000..43c140214f --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/40101020.txt @@ -0,0 +1,2 @@ +Canvis principals d'aquesta versió: millora de rendiment i correcció d'errors! +Registre de canvis complet: https://github.com/vector-im/element-android/releases/tag/v1.1.2 diff --git a/fastlane/metadata/android/ca/changelogs/40101030.txt b/fastlane/metadata/android/ca/changelogs/40101030.txt new file mode 100644 index 0000000000..9b2627e7f2 --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/40101030.txt @@ -0,0 +1,2 @@ +Canvis principals d'aquesta versió: millora de rendiment i correcció d'errors! +Registre de canvis complet: https://github.com/vector-im/element-android/releases/tag/v1.1.3 diff --git a/fastlane/metadata/android/de/changelogs/40101020.txt b/fastlane/metadata/android/de/changelogs/40101020.txt new file mode 100644 index 0000000000..32fabf7c2f --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/40101020.txt @@ -0,0 +1,2 @@ +Hauptänderungen in dieser Version: Leistungsverbesserung und Fehlerbehebungen! +Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases/tag/v1.1.2 diff --git a/fastlane/metadata/android/de/changelogs/40101030.txt b/fastlane/metadata/android/de/changelogs/40101030.txt new file mode 100644 index 0000000000..7e6dc25033 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/40101030.txt @@ -0,0 +1,2 @@ +Hauptänderungen in dieser Version: Leistungsverbesserung und Fehlerbehebungen! +Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases/tag/v1.1.3 diff --git a/fastlane/metadata/android/en-US/changelogs/40101050.txt b/fastlane/metadata/android/en-US/changelogs/40101050.txt new file mode 100644 index 0000000000..917dbee284 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40101050.txt @@ -0,0 +1,2 @@ +Main changes in this version: hot fixes for 1.1.4 +Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.1.5 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40101060.txt b/fastlane/metadata/android/en-US/changelogs/40101060.txt new file mode 100644 index 0000000000..6d895542eb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40101060.txt @@ -0,0 +1,2 @@ +Main changes in this version: hot fixes for 1.1.5 +Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.1.6 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index e939b75bb7..853885944c 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,30 +1,39 @@ -Element is a new type of messenger and collaboration app that: +Element is both a secure messenger and a productivity team collaboration app that is ideal for group chats while remote working. This chat app uses end-to-end encryption to provide powerful video conferencing, file sharing and voice calls. -1. Puts you in control to preserve your privacy -2. Lets you communicate with anyone in the Matrix network, and even beyond by integrating with apps such as Slack -3. Protects you from advertising, datamining and walled gardens -4. Secures you through end-to-end encryption, with cross-signing to verify others +Element’s features include: +- Advanced online communication tools +- Fully encrypted messages to allow safer corporate communication, even for remote workers +- Decentralized chat based on the Matrix open source framework +- File sharing securely with encrypted data while managing projects +- Video chats with Voice over IP and screen sharing +- Easy integration with your favourite online collaboration tools, project management tools, VoIP services and other team messaging apps -Element is completely different from other messaging and collaboration apps because it is decentralised and open source. +Element is completely different from other messaging and collaboration apps. It operates on Matrix, an open network for secure messaging and decentralized communication. It allows self-hosting to give users maximum ownership and control of their data and messages. -Element lets you self-host - or choose a host - so that you have privacy, ownership and control of your data and conversations. It gives you access to an open network; so you’re not just stuck speaking to other Element users only. And it is very secure. +Privacy and encrypted messaging +Element protects you from unwanted ads, data mining and walled gardens. It also secures all your data, one-to-one video and voice communication through end-to-end encryption and cross-signed device verification. -Element is able to do all this because it operates on Matrix - the standard for open, decentralised communication. +Element gives you control over your privacy while allowing you to communicate securely with anyone on the Matrix network, or other business collaboration tools by integrating with apps such as Slack. -Element puts you in control by letting you choose who hosts your conversations. From the Element app, you can choose to host in different ways: +Element can be self-hosted +To allow more control of your sensitive data and conversations, Element can be self-hosted or you can choose any Matrix-based host - the standard for open source, decentralized communication. Element gives you privacy, security compliance and integration flexibility. +Own your data +You decide where to keep your data and messages. Without the risk of data mining or access from third parties. + +Element puts you in control in different ways: 1. Get a free account on the matrix.org public server hosted by the Matrix developers, or choose from thousands of public servers hosted by volunteers -2. Self-host your account by running a server on your own hardware +2. Self-host your account by running a server on your own IT infrastructure 3. Sign up for an account on a custom server by simply subscribing to the Element Matrix Services hosting platform -Why choose Element? +Open messaging and collaboration +You can chat with anyone on the Matrix network, whether they’re using Element, another Matrix app or even if they are using a different messaging app. -OWN YOUR DATA: You decide where to keep your data and messages. You own it and control it, not some MEGACORP that mines your data or gives access to third parties. +Super secure +Real end-to-end encryption (only those in the conversation can decrypt messages), and cross-signed device verification. -OPEN MESSAGING AND COLLABORATION: You can chat with anyone else in the Matrix network, whether they’re using Element or another Matrix app, and even if they are using a different messaging system of the likes of Slack, IRC or XMPP. +Complete communication and integration +Messaging, voice and video calls, file sharing, screen sharing and a whole bunch of integrations, bots and widgets. Build rooms, communities, stay in touch and get things done. -SUPER-SECURE: Real end-to-end encryption (only those in the conversation can decrypt messages), and cross-signing to verify the devices of conversation participants. - -COMPLETE COMMUNICATION: Messaging, voice and video calls, file sharing, screen sharing and a whole bunch of integrations, bots and widgets. Build rooms, communities, stay in touch and get things done. - -EVERYWHERE YOU ARE: Stay in touch wherever you are with fully synchronised message history across all your devices and on the web at https://app.element.io. +Pick up where you left off +Stay in touch wherever you are with fully synchronised message history across all your devices and on the web at https://app.element.io \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt index 023b366c9a..5a98f6f772 100644 --- a/fastlane/metadata/android/en-US/short_description.txt +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -Secure decentralised chat & VoIP. Keep your data safe from third parties. \ No newline at end of file +Group messenger - encrypted messaging, group chat and video calls \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt index 039da1fc3b..12fa89b99b 100644 --- a/fastlane/metadata/android/en-US/title.txt +++ b/fastlane/metadata/android/en-US/title.txt @@ -1 +1 @@ -Element (previously Riot.im) \ No newline at end of file +Element - Secure Messenger \ No newline at end of file diff --git a/fastlane/metadata/android/et/changelogs/40101020.txt b/fastlane/metadata/android/et/changelogs/40101020.txt new file mode 100644 index 0000000000..5f34bb579f --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40101020.txt @@ -0,0 +1,2 @@ +Põhilised muutused selles versioonis: jõudluse parandused ja pisikohendused. +Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases/tag/v1.1.2 diff --git a/fastlane/metadata/android/et/changelogs/40101030.txt b/fastlane/metadata/android/et/changelogs/40101030.txt new file mode 100644 index 0000000000..5a558d911a --- /dev/null +++ b/fastlane/metadata/android/et/changelogs/40101030.txt @@ -0,0 +1,2 @@ +Põhilised muutused selles versioonis: jõudluse parandused ja pisikohendused. +Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases/tag/v1.1.3 diff --git a/fastlane/metadata/android/fr/changelogs/40101020.txt b/fastlane/metadata/android/fr/changelogs/40101020.txt new file mode 100644 index 0000000000..7bce8ac19a --- /dev/null +++ b/fastlane/metadata/android/fr/changelogs/40101020.txt @@ -0,0 +1,2 @@ +Principaux changements pour cette version : amélioration des performances et corrections de bugs ! +Intégralité des changements : https://github.com/vector-im/element-android/releases/tag/v1.1.2 diff --git a/fastlane/metadata/android/fr/changelogs/40101030.txt b/fastlane/metadata/android/fr/changelogs/40101030.txt new file mode 100644 index 0000000000..93f0b9e7fb --- /dev/null +++ b/fastlane/metadata/android/fr/changelogs/40101030.txt @@ -0,0 +1,2 @@ +Principaux changements pour cette version : amélioration des performances et corrections de bugs ! +Intégralité des changements : https://github.com/vector-im/element-android/releases/tag/v1.1.3 diff --git a/fastlane/metadata/android/fr/full_description.txt b/fastlane/metadata/android/fr/full_description.txt index 2b17d8f846..066b94868b 100644 --- a/fastlane/metadata/android/fr/full_description.txt +++ b/fastlane/metadata/android/fr/full_description.txt @@ -1,30 +1,30 @@ -Element est une nouvelle application de messagerie et de collaboration qui : +Element est une nouvelle application de messagerie et de collaboration qui : -1) Vous place aux commandes de votre vie privée -2) Vous permet de communiquer avec n'importe qui du réseau Matrix, et plus encore par des intégrations d'autres applications comme Slack ou Discord -3) Vous protège de la publicité et de la collecte de données -4) Vous sécurise grâce à du chiffrement bout-à-bout, avec de la signature croisée pour authentifier les autres utilisateurs +1. Vous permet de préserver votre vie privée +2. Vous permet de communiquer avec n’importe qui sur réseau Matrix, et plus encore grâce aux intégrations d’autres applications telles que Slack ou Discord +3. Vous protège de la publicité et de la collecte de données +4. Vous protège grâce au chiffrement de bout-à-bout et à la signature croisée pour authentifier les autres utilisateurs -Element est complètement différent des autres applications de messagerie et de collaboration puisque l'application est décentralisée et open-source. +Element est complètement différente des autres applications de messagerie et de collaboration puisque l’application est décentralisée et open-source. -Element vous permet d'héberger vous-même -ou de choisir un hôte- vous permettant d'assurer votre vie privée, la propriété et le contrôle de vos données et de vos conversations. Cela vous offre l'accès à un réseau ouvert, vous n'êtes donc pas condamné à parler à d'autres utilisateurs d'Element seulement. Et c'est très sécurisé. +Element vous permet d’héberger vous-même ou de choisir un hôte vous permettant d’assurer votre vie privée, la propriété et le contrôle de vos données et de vos conversations. Cela vous donne accès à un réseau ouvert. Vous n’êtes donc pas condamné à parler à d’autres utilisateurs d’Element seulement. Et c'est très sécurisé. -Element peut faire tout ça car il est basé sur Matrix, le protocole standard pour la communication ouverte et décentralisée. +Element peut faire tout ça car elle est basée sur Matrix, le protocole standard pour la communication ouverte et décentralisée. -Element vous donne le contrôle en vous laissant choisir qui héberge vos conversations. Depuis l'application Element, vous pouvez choisir votre hôte de différentes manières : +Element vous donne le contrôle en vous laissant choisir qui héberge vos conversations. Depuis l'application Element, vous pouvez choisir votre hôte de différentes manières : -1) Créer un compte gratuit sur le serveur public matrix.org hébergé par les développeurs de Matrix, ou choisir parler les milliers de serveurs public hébergés par des bénévoles -2) Héberger vous-même votre compte en installant un serveur sur votre propre machine -3) Créer un compte sur un serveur personnalisé en souscrivant sur la plateforme d'hébergement « Element Matrix Services » (EMS) +1. Créer un compte gratuit sur le serveur public matrix.org hébergé par les développeurs de Matrix, ou choisir parmi les milliers de serveurs public hébergés par des bénévoles +2. Héberger vous-même votre compte en installant un serveur sur votre propre machine +3. Créer un compte sur un serveur personnalisé en souscrivant sur la plateforme d'hébergement « Element Matrix Services » (EMS) Pourquoi choisir Element ? -POSSÉDEZ VOS DONNÉES : Vous décidez où conserver vos données et vos messages. Vous les possédez et vous les contrôlez, et non une MEGACORP qui mine vos données ou les donnent à des tiers +VOS DONNÉES VOUS APPARTIENNENT : vous décidez où stocker vos données et messages. Ils vous appartiennent et vous les maîtrisez. Aucune multinationale ne viendra extraire vos données pour les envoyer au plus offrant. -UNE MESSAGERIE OUVERTE ET COLLABORATIVE : Vous pouvez discuter avec n'importe qui sur le réseau Matrix, qu'ils utilisent Element ou une autre application basée sur Matrix, et même s'ils utilisent un système de messagerie différent comment Slack, Discord, IRC ou XMPP. +MESSAGERIE ET COLLABORATION OUVERTES : vous pouvez discuter avec tout le réseau Matrix, qu’ils utilisent Element ou une autre application Matrix, même s’ils utilisent une autre plateforme de messagerie telle que Slack, IRC ou XMPP. -SUPER SÉCURISÉ : Un réel chiffrement bout-à-bout (seulement ceux deux la conversation peuvent déchiffrer les messages), et une signature croisée pour vérifier les appareils des participants de la conversation. +ULTRA SÉCURISÉ : chiffrement de bout en bout (seuls les membres d’une conversation peuvent déchiffrer les messages), et signature croisée pour vérifier les appareils de vos interlocuteurs. -COMMUNICATION COMPLÈTE : Messagerie, appels vocaux et vidéo, transfert de fichiers, partage d'écran et un tas d'intégrations, robots et widgets. Construisez des salons, des communautés, restez en contact et accomplissez de grandes choses. +TOUTES VOS COMMUNICATIONS : messagerie, appels audio et vidéo, partage de fichier, partage d’écran et un grand nombre d’intégrations, robots et widgets. Participez à des salons, des communautés, restez en contact et faites avancer vos projets. -PARTOUT OÙ VOUS ÊTES : Restez connectés peu import où vous êtes avec la synchronisation complète de l'historique des messages sur tous vos appareils et sur le web sur https://app.element.io. +PARTOUT AVEC VOUS : votre historique reste synchronisé entre tous vos appareils et sur le web sur https://element.io/app. diff --git a/fastlane/metadata/android/it/changelogs/40101020.txt b/fastlane/metadata/android/it/changelogs/40101020.txt new file mode 100644 index 0000000000..21057629e3 --- /dev/null +++ b/fastlane/metadata/android/it/changelogs/40101020.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: prestazioni migliorate e correzione di errori! +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.1.2 diff --git a/fastlane/metadata/android/it/changelogs/40101030.txt b/fastlane/metadata/android/it/changelogs/40101030.txt new file mode 100644 index 0000000000..a62c4a0736 --- /dev/null +++ b/fastlane/metadata/android/it/changelogs/40101030.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: prestazioni migliorate e correzioni di errori! +Cronologia completa: https://github.com/vector-im/element-android/releases/tag/v1.1.3 diff --git a/fastlane/metadata/android/ja/changelogs/40100100.txt b/fastlane/metadata/android/ja/changelogs/40100100.txt new file mode 100644 index 0000000000..8359a12964 --- /dev/null +++ b/fastlane/metadata/android/ja/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/ja/changelogs/40100110.txt b/fastlane/metadata/android/ja/changelogs/40100110.txt new file mode 100644 index 0000000000..c93db421af --- /dev/null +++ b/fastlane/metadata/android/ja/changelogs/40100110.txt @@ -0,0 +1,2 @@ +今回の新バージョンでは、主にUI(ユーザーインターフェース)とUX(ユーザーエクスペリエンス)の向上が図られています。友達を招待したり、QRコードを読み取って素早くDMを作成できるようになりました。 +すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.11 diff --git a/fastlane/metadata/android/ja/changelogs/40100120.txt b/fastlane/metadata/android/ja/changelogs/40100120.txt new file mode 100644 index 0000000000..aace2ef79f --- /dev/null +++ b/fastlane/metadata/android/ja/changelogs/40100120.txt @@ -0,0 +1,2 @@ +このバージョンの主な変更点: URLプレビュー、新しい絵文字、新しいルーム設定機能、それにクリスマスには雪が! +すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.12 diff --git a/fastlane/metadata/android/ja/changelogs/40100130.txt b/fastlane/metadata/android/ja/changelogs/40100130.txt new file mode 100644 index 0000000000..97633621c5 --- /dev/null +++ b/fastlane/metadata/android/ja/changelogs/40100130.txt @@ -0,0 +1,2 @@ +このバージョンの主な変更点: URLプレビュー、新しい絵文字、新しいルーム設定機能、それにクリスマスには雪が! +すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/ja/changelogs/40100140.txt b/fastlane/metadata/android/ja/changelogs/40100140.txt new file mode 100644 index 0000000000..c340663127 --- /dev/null +++ b/fastlane/metadata/android/ja/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/ja/changelogs/40100150.txt b/fastlane/metadata/android/ja/changelogs/40100150.txt new file mode 100644 index 0000000000..42f28c7bea --- /dev/null +++ b/fastlane/metadata/android/ja/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/ja/changelogs/40100160.txt b/fastlane/metadata/android/ja/changelogs/40100160.txt new file mode 100644 index 0000000000..8b5196998a --- /dev/null +++ b/fastlane/metadata/android/ja/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/ja/changelogs/40100170.txt b/fastlane/metadata/android/ja/changelogs/40100170.txt new file mode 100644 index 0000000000..586b01cb2b --- /dev/null +++ b/fastlane/metadata/android/ja/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/ja/changelogs/40101000.txt b/fastlane/metadata/android/ja/changelogs/40101000.txt new file mode 100644 index 0000000000..25bbd7ab87 --- /dev/null +++ b/fastlane/metadata/android/ja/changelogs/40101000.txt @@ -0,0 +1,2 @@ +このバージョンの主な変更点: パフォーマンスの向上とバグの修正! +すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.0 diff --git a/fastlane/metadata/android/ja/changelogs/40101010.txt b/fastlane/metadata/android/ja/changelogs/40101010.txt new file mode 100644 index 0000000000..35ba933069 --- /dev/null +++ b/fastlane/metadata/android/ja/changelogs/40101010.txt @@ -0,0 +1,2 @@ +このバージョンの主な変更点: パフォーマンスの向上とバグの修正! +すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.1 diff --git a/fastlane/metadata/android/ja/changelogs/40101020.txt b/fastlane/metadata/android/ja/changelogs/40101020.txt new file mode 100644 index 0000000000..88e3c79ca8 --- /dev/null +++ b/fastlane/metadata/android/ja/changelogs/40101020.txt @@ -0,0 +1,2 @@ +このバージョンの主な変更点: パフォーマンスの向上とバグの修正! +すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.2 diff --git a/fastlane/metadata/android/ja/changelogs/40101030.txt b/fastlane/metadata/android/ja/changelogs/40101030.txt new file mode 100644 index 0000000000..87d191b226 --- /dev/null +++ b/fastlane/metadata/android/ja/changelogs/40101030.txt @@ -0,0 +1,2 @@ +このバージョンの主な変更点: パフォーマンスの向上とバグの修正! +すべての変更履歴はこちら: https://github.com/vector-im/element-android/releases/tag/v1.1.3 diff --git a/fastlane/metadata/android/ja/full_description.txt b/fastlane/metadata/android/ja/full_description.txt new file mode 100644 index 0000000000..855eb309c9 --- /dev/null +++ b/fastlane/metadata/android/ja/full_description.txt @@ -0,0 +1,30 @@ +Elementはまったく新しいタイプのメッセンジャーアプリです。 + +1. あなた自身がプライバシーをコントロールすることを可能にします。 +2. Matrixネットワークにいる誰とでも通信できることはもちろん、Slackなどのアプリとの連携によって他のネットワークとも通信ができます。 +3. 広告、データ収集、バックドア、ユーザーの囲い込みから逃れることができます。 +4. エンドツーエンド暗号化とクロス署名によってあなたを保護します。 + +Elementは非中央集権型でオープンソースであるため、他のメッセンジャーアプリとは完全に異なっています。 + +Elementはあなた自身でサーバーをホストすることも、サーバーを選ぶこともできます。これによってあなたのデータと会話に関するプライバシーや所有権はあなた自身で管理できるようになります。さらに、あなたは他のElementユーザーと話せるだけでなくオープンネットワークへのアクセスも可能です。とてもセキュアです。 + +Elementは、オープンな分散型通信の標準規格であるMatrixで動作するため、これらすべてを実現することができています。 + +Elementではあなたの会話をどのサーバーでホストするか決めることができます。アプリでは、さまざまな方法で選択できます。 + +1. matrix.orgの公開サーバーで無料のアカウントを取得します。 +2. あなた自身のハードウェアでサーバーを動かし、アカウントを管理します。 +3. Element Matrix Servicesのホスティングプラットフォームに登録することで、カスタムサーバー上のアカウントを取得できます。 + +なぜElementを選ぶべきなのか? + +データの所有権: 自分でデータやメッセージを保管する場所を決めることができます。あなたが所有権を持ってコントロールすることで、第三者にあなたのデータを渡したり、ビッグデータを収集する巨大テック企業に依存する必要がなくなります。 + +開かれたネットワークと共同作業: Matrixネットワーク内の他の誰とでも、あるいはElementや他のMatrixアプリを使っているかどうかに関わらず、またSlack、IRC、XMPPのような他のメッセージングシステムを使っているかどうかに関わらず、チャットすることができます。 + +はるかに安全: 本物のエンドツーエンド暗号化(会話に参加している者のみがメッセージを読める)と会話参加者の真正性を確認するためクロス署名によって。 + +完全なるコミュニケーションの訪れ: テキスト、音声通話、ビデオ通話、ファイル共有、画面共有、連携機能、ボット、ウィジェットなどのコミュニケーションに必要な機能の全てが実装されています。ルームやコミュニティを立ち上げて連絡を取り合い、物事をスムーズに成し遂げることができます。 + +いつでもどこでも!: すべてのデバイスとウェブ(https://app.element.io)でメッセージの履歴が完全に同期されるため、どこにいても連絡を取ることができます。 diff --git a/fastlane/metadata/android/ja/short_description.txt b/fastlane/metadata/android/ja/short_description.txt new file mode 100644 index 0000000000..c3991b7a93 --- /dev/null +++ b/fastlane/metadata/android/ja/short_description.txt @@ -0,0 +1 @@ +安全な分散型チャットとVoIP。あなたの情報が第三者から守られます。 diff --git a/fastlane/metadata/android/ja/title.txt b/fastlane/metadata/android/ja/title.txt new file mode 100644 index 0000000000..376f4a95de --- /dev/null +++ b/fastlane/metadata/android/ja/title.txt @@ -0,0 +1 @@ +Element(エレメントメッセンジャー) diff --git a/fastlane/metadata/android/nb-NO/changelogs/40100120.txt b/fastlane/metadata/android/nb-NO/changelogs/40100120.txt new file mode 100644 index 0000000000..163cd64cdc --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/40100120.txt @@ -0,0 +1,2 @@ +Hovedendringene i denne versjonen: URL-forhåndsvisning, nytt Emoji-tastatur, nye rominnstillingsmuligheter og snø til jul! +Full endringslogg: https://github.com/vector-im/element-android/releases/tag/v1.0.12 diff --git a/fastlane/metadata/android/nb-NO/changelogs/40100130.txt b/fastlane/metadata/android/nb-NO/changelogs/40100130.txt new file mode 100644 index 0000000000..23ab42ef2c --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/40100130.txt @@ -0,0 +1,2 @@ +Hovedendringene i denne versjonen: URL-forhåndsvisning, nytt Emoji-tastatur, nye rominnstillingsmuligheter og snø til jul! +Full endringslogg: https://github.com/vector-im/element-android/releases/tag/v1.0.13 diff --git a/fastlane/metadata/android/nb-NO/changelogs/40100140.txt b/fastlane/metadata/android/nb-NO/changelogs/40100140.txt new file mode 100644 index 0000000000..10a3d9b925 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/40100140.txt @@ -0,0 +1,2 @@ +Hovedendringene i denne versjonen: Rediger romtillatelser, automatisk lys/mørkt tema og en haug med feilrettinger. +Full endringslogg: https://github.com/vector-im/element-android/releases/tag/v1.0.14 diff --git a/fastlane/metadata/android/nb-NO/changelogs/40100150.txt b/fastlane/metadata/android/nb-NO/changelogs/40100150.txt new file mode 100644 index 0000000000..3237da115d --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/40100150.txt @@ -0,0 +1,2 @@ +Hovedendringene i denne versjonen: Sosial innloggingsstøtte. +Full endringslogg: https://github.com/vector-im/element-android/releases/tag/v1.0.15 diff --git a/fastlane/metadata/android/nb-NO/changelogs/40100160.txt b/fastlane/metadata/android/nb-NO/changelogs/40100160.txt new file mode 100644 index 0000000000..5502fd3ab1 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/40100160.txt @@ -0,0 +1,2 @@ +Hovedendringene i denne versjonen: Sosial innloggingsstøtte. +Full endringslogg: https://github.com/vector-im/element-android/releases/tag/v1.0.15 og https://github.com/vector-im/element-android/releases/tag/v1.0.16 diff --git a/fastlane/metadata/android/nb-NO/changelogs/40100170.txt b/fastlane/metadata/android/nb-NO/changelogs/40100170.txt new file mode 100644 index 0000000000..f9174a2ee4 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/40100170.txt @@ -0,0 +1,2 @@ +Hovedendringene i denne versjonen: Feilrettinger! +Full endringslogg: https://github.com/vector-im/element-android/releases/tag/v1.0.17 diff --git a/fastlane/metadata/android/nb-NO/changelogs/40101000.txt b/fastlane/metadata/android/nb-NO/changelogs/40101000.txt new file mode 100644 index 0000000000..370dbb36ce --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/40101000.txt @@ -0,0 +1,2 @@ +Hovedendringene i denne versjonen: forbedring av VoIP (lyd og videosamtaler i DM) og feilrettinger! +Full endringslogg: https://github.com/vector-im/element-android/releases/tag/v1.1.0 diff --git a/fastlane/metadata/android/nb-NO/changelogs/40101010.txt b/fastlane/metadata/android/nb-NO/changelogs/40101010.txt new file mode 100644 index 0000000000..c6109b8d9b --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/40101010.txt @@ -0,0 +1,2 @@ +Hovedendringene i denne versjonen: forbedring av ytelsen og feilrettinger! +Full endringslogg: https://github.com/vector-im/element-android/releases/tag/v1.1.1 diff --git a/fastlane/metadata/android/nb-NO/changelogs/40101020.txt b/fastlane/metadata/android/nb-NO/changelogs/40101020.txt new file mode 100644 index 0000000000..9464c6fb0f --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/40101020.txt @@ -0,0 +1,2 @@ +Hovedendringene i denne versjonen: forbedring av ytelsen og feilrettinger! +Full endringslogg: https://github.com/vector-im/element-android/releases/tag/v1.1.2 diff --git a/fastlane/metadata/android/nb-NO/changelogs/40101030.txt b/fastlane/metadata/android/nb-NO/changelogs/40101030.txt new file mode 100644 index 0000000000..1e12246e9a --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/40101030.txt @@ -0,0 +1,2 @@ +Hovedendringene i denne versjonen: forbedring av ytelsen og feilrettinger! +Full endringslogg: https://github.com/vector-im/element-android/releases/tag/v1.1.3 diff --git a/fastlane/metadata/android/nb-NO/full_description.txt b/fastlane/metadata/android/nb-NO/full_description.txt new file mode 100644 index 0000000000..92a3c4c5c3 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/full_description.txt @@ -0,0 +1,30 @@ +Element er en ny type messenger og samarbeidsapp som: + +1. Gir deg kontrollen for å bevare personvernet ditt +2. Lar deg kommunisere med hvem som helst i Matrix-nettverket, og til og med ved å integrere med apper som Slack +3. Beskytter deg mot reklame, datamining og inngjerdede hager +4. Sikrer deg gjennom end-to-end-kryptering, med kryssignering for å bekrefte andre + +Element er helt forskjellig fra andre meldings- og samarbeidsapper fordi det er desentralisert og åpen kildekode. + +Element lar deg selv være vert - eller velge en vert - slik at du har personvern, eierskap og kontroll over dataene og samtalene dine. Det gir deg tilgang til et åpent nettverk; slik at du ikke bare holder på å snakke med bare andre Element-brukere. Og det er veldig sikkert. + +Element er i stand til å gjøre alt dette fordi det opererer på Matrix - standarden for åpen, desentralisert kommunikasjon. + +Element setter deg i kontroll ved å la deg velge hvem som er vert for samtalene dine. Fra Element-appen kan du velge å være vert på forskjellige måter: + +1. Få en gratis konto på matrix.org-serveren som er vert for Matrix-utviklerne, eller velg blant tusenvis av offentlige servere som er vert for frivillige +2. Vær vert for kontoen din ved å kjøre en server på din egen maskinvare +3. Registrer deg for en konto på en tilpasset server ved å bare abonnere på Hosting Matrix Services-vertsplattformen + + Hvorfor velge Element? + + EGNE DATA DINE : Du bestemmer hvor du vil oppbevare dataene og meldingene dine. Du eier den og kontrollerer den, ikke noe MEGACORP som utvinner dataene dine eller gir tilgang til tredjeparter. + + ÅPEN MELDING OG SAMARBEID : Du kan chatte med alle andre i Matrix-nettverket, enten de bruker Element eller en annen Matrix-app, og selv om de bruker et annet meldingssystem som Slack, IRC eller XMPP. + + SUPER-SECURE : Ekte end-to-end-kryptering (bare de i samtalen kan dekryptere meldinger), og kryssignering for å verifisere enhetene til samtaledeltakerne. + + KOMPLETT KOMMUNIKASJON : Meldinger, tale- og videosamtaler, fildeling, skjermdeling og en hel haug med integrasjoner, bots og widgets. Bygg rom, lokalsamfunn, hold kontakten og få ting gjort. + + ALT DER DU ER : Hold kontakten uansett hvor du er med fullt synkronisert meldingslogg på alle enhetene dine og på nettet på https://app.element.io. diff --git a/fastlane/metadata/android/nb/changelogs/40100170.txt b/fastlane/metadata/android/nb/changelogs/40100170.txt new file mode 100644 index 0000000000..3593e50e05 --- /dev/null +++ b/fastlane/metadata/android/nb/changelogs/40100170.txt @@ -0,0 +1,2 @@ +Hovedendringene i denne versjonen: Bugfikser! +Full endringslogg: https://github.com/vector-im/element-android/releases/tag/v1.0.17 diff --git a/fastlane/metadata/android/nb/changelogs/40101010.txt b/fastlane/metadata/android/nb/changelogs/40101010.txt new file mode 100644 index 0000000000..2d80855ed8 --- /dev/null +++ b/fastlane/metadata/android/nb/changelogs/40101010.txt @@ -0,0 +1,2 @@ +Hovedendringene i denne versjonen: Forbedringer i ytelse og bugfikser! +Full endringslogg: https://github.com/vector-im/element-android/releases/tag/v1.1.1 diff --git a/fastlane/metadata/android/ru/changelogs/40101020.txt b/fastlane/metadata/android/ru/changelogs/40101020.txt new file mode 100644 index 0000000000..70e164f39d --- /dev/null +++ b/fastlane/metadata/android/ru/changelogs/40101020.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: улучшение производительности и исправления ошибок! +Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.1.2 diff --git a/fastlane/metadata/android/ru/changelogs/40101030.txt b/fastlane/metadata/android/ru/changelogs/40101030.txt new file mode 100644 index 0000000000..381c2761d0 --- /dev/null +++ b/fastlane/metadata/android/ru/changelogs/40101030.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: улучшение производительности и исправления ошибок! +Полный список изменений: https://github.com/vector-im/element-android/releases/tag/v1.1.3 diff --git a/fastlane/metadata/android/sv/changelogs/40101020.txt b/fastlane/metadata/android/sv/changelogs/40101020.txt new file mode 100644 index 0000000000..229793ab31 --- /dev/null +++ b/fastlane/metadata/android/sv/changelogs/40101020.txt @@ -0,0 +1,2 @@ +Huvudsakliga ändringar i den här versionen: prestandaförbättringar och buggfixar! +Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.1.2 diff --git a/fastlane/metadata/android/sv/changelogs/40101030.txt b/fastlane/metadata/android/sv/changelogs/40101030.txt new file mode 100644 index 0000000000..7e0f8c80d2 --- /dev/null +++ b/fastlane/metadata/android/sv/changelogs/40101030.txt @@ -0,0 +1,2 @@ +Huvudsakliga ändringar i den här versionen: prestandaförbättringar och buggfixar! +Full ändringslogg: https://github.com/vector-im/element-android/releases/tag/v1.1.3 diff --git a/fastlane/metadata/android/uk/changelogs/40101020.txt b/fastlane/metadata/android/uk/changelogs/40101020.txt new file mode 100644 index 0000000000..469de21a6f --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40101020.txt @@ -0,0 +1,2 @@ +Основні зміни в цій версії: поліпшення продуктивності та виправлення помилок! +Повний журнал змін: https://github.com/vector-im/element-android/releases/tag/v1.1.2 diff --git a/fastlane/metadata/android/uk/changelogs/40101030.txt b/fastlane/metadata/android/uk/changelogs/40101030.txt new file mode 100644 index 0000000000..da2bb0ddd6 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/40101030.txt @@ -0,0 +1,2 @@ +Основні зміни в цій версії: поліпшення продуктивності та виправлення помилок! +Повний журнал змін: https://github.com/vector-im/element-android/releases/tag/v1.1.3 diff --git a/fastlane/metadata/android/zh-Hant/changelogs/40101020.txt b/fastlane/metadata/android/zh-Hant/changelogs/40101020.txt new file mode 100644 index 0000000000..90e76b074e --- /dev/null +++ b/fastlane/metadata/android/zh-Hant/changelogs/40101020.txt @@ -0,0 +1,2 @@ +此版本中的主要變更:效能改進與錯誤修復! +完整變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.1.2 diff --git a/fastlane/metadata/android/zh-Hant/changelogs/40101030.txt b/fastlane/metadata/android/zh-Hant/changelogs/40101030.txt new file mode 100644 index 0000000000..c13d6ecfd4 --- /dev/null +++ b/fastlane/metadata/android/zh-Hant/changelogs/40101030.txt @@ -0,0 +1,2 @@ +此版本中的主要變更:效能改進與錯誤修復! +完整變更紀錄:https://github.com/vector-im/element-android/releases/tag/v1.1.3 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 6e61ea7487..9d174797f7 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=9af5c8e7e2cd1a3b0f694a4ac262b9f38c75262e74a9e8b5101af302a6beadd7 -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-all.zip +distributionSha256Sum=81003f83b0056d20eedf48cddd4f52a9813163d4ba185bcf8abd34b8eeea4cbd +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/matrix-sdk-android-rx/src/main/AndroidManifest.xml b/matrix-sdk-android-rx/src/main/AndroidManifest.xml index f1bb42638f..5f399e9f84 100644 --- a/matrix-sdk-android-rx/src/main/AndroidManifest.xml +++ b/matrix-sdk-android-rx/src/main/AndroidManifest.xml @@ -1,2 +1 @@ - + diff --git a/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxCallbackBuilders.kt b/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxCallbackBuilders.kt deleted file mode 100644 index ec30a31f6d..0000000000 --- a/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxCallbackBuilders.kt +++ /dev/null @@ -1,56 +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.rx - -import org.matrix.android.sdk.api.MatrixCallback -import org.matrix.android.sdk.api.util.Cancelable -import io.reactivex.Completable -import io.reactivex.Single - -fun singleBuilder(builder: (MatrixCallback) -> Cancelable): Single = Single.create { emitter -> - val callback = object : MatrixCallback { - override fun onSuccess(data: T) { - // Add `!!` to fix the warning: - // "Type mismatch: type parameter with nullable bounds is used T is used where T was expected. This warning will become an error soon" - emitter.onSuccess(data!!) - } - - override fun onFailure(failure: Throwable) { - emitter.tryOnError(failure) - } - } - val cancelable = builder(callback) - emitter.setCancellable { - cancelable.cancel() - } -} - -fun completableBuilder(builder: (MatrixCallback) -> Cancelable): Completable = Completable.create { emitter -> - val callback = object : MatrixCallback { - override fun onSuccess(data: T) { - emitter.onComplete() - } - - override fun onFailure(failure: Throwable) { - emitter.tryOnError(failure) - } - } - val cancelable = builder(callback) - emitter.setCancellable { - cancelable.cancel() - } -} diff --git a/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxSession.kt b/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxSession.kt index 0fe2b01576..67a35cac2e 100644 --- a/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxSession.kt +++ b/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxSession.kt @@ -39,6 +39,7 @@ import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams import org.matrix.android.sdk.api.session.sync.SyncState import org.matrix.android.sdk.api.session.user.model.User import org.matrix.android.sdk.api.session.widgets.model.Widget @@ -66,6 +67,13 @@ class RxSession(private val session: Session) { } } + fun liveSpaceSummaries(queryParams: SpaceSummaryQueryParams): Observable> { + return session.spaceService().getSpaceSummariesLive(queryParams).asObservable() + .startWithCallable { + session.spaceService().getSpaceSummaries(queryParams) + } + } + fun liveBreadcrumbs(queryParams: RoomSummaryQueryParams): Observable> { return session.getBreadcrumbsLive(queryParams).asObservable() .startWithCallable { diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 669444d563..4059004394 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -6,10 +6,13 @@ apply plugin: 'realm-android' buildscript { repositories { - mavenCentral() + // mavenCentral() + //noinspection GrDeprecatedAPIUsage + jcenter() } dependencies { - classpath "io.realm:realm-gradle-plugin:10.4.0" + // Stick to this version until https://github.com/realm/realm-java/issues/7402 is fixed + classpath "io.realm:realm-gradle-plugin:10.3.1" } } @@ -112,7 +115,7 @@ dependencies { def lifecycle_version = '2.2.0' def arch_version = '2.1.0' def markwon_version = '3.1.0' - def daggerVersion = '2.34' + def daggerVersion = '2.35' def work_version = '2.5.0' def retrofit_version = '2.9.0' @@ -165,8 +168,11 @@ dependencies { implementation 'com.jakewharton.timber:timber:4.7.1' implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0' + // Video compression + implementation 'com.otaliastudios:transcoder:0.10.3' + // Phone number https://github.com/google/libphonenumber - implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.21' + implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.22' testImplementation 'junit:junit:4.13.2' testImplementation 'org.robolectric:robolectric:4.5.1' @@ -184,7 +190,7 @@ dependencies { androidTestImplementation 'androidx.test:rules:1.3.0' androidTestImplementation 'androidx.test.ext:junit:1.1.2' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' - androidTestImplementation 'org.amshove.kluent:kluent-android:1.61' + androidTestImplementation 'org.amshove.kluent:kluent-android:1.65' androidTestImplementation 'io.mockk:mockk-android:1.11.0' androidTestImplementation "androidx.arch.core:core-testing:$arch_version" androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt index 122584142e..a2566c1414 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt @@ -18,6 +18,8 @@ package org.matrix.android.sdk.internal.crypto import android.util.Log import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.FixMethodOrder import org.junit.Test import org.junit.runner.RunWith @@ -29,8 +31,6 @@ import org.matrix.android.sdk.common.CommonTestHelper import org.matrix.android.sdk.common.CryptoTestHelper import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent import org.matrix.android.sdk.internal.crypto.model.event.RoomKeyContent -import kotlin.test.assertEquals -import kotlin.test.assertNotNull @RunWith(AndroidJUnit4::class) @FixMethodOrder(MethodSorters.JVM) @@ -54,7 +54,7 @@ class PreShareKeysTest : InstrumentedTest { && it.getClearType() == EventType.ROOM_KEY } - assertEquals(0, preShareCount, "Bob should not have receive any key from alice at this point") + assertEquals("Bob should not have receive any key from alice at this point", 0, preShareCount) Log.d("#Test", "Room Key Received from alice $preShareCount") // Force presharing of new outbound key @@ -78,14 +78,14 @@ class PreShareKeysTest : InstrumentedTest { } val content = latest?.getClearContent().toModel() - assertNotNull(content, "Bob should have received and decrypted a room key event from alice") - assertEquals(e2eRoomID, content.roomId, "Wrong room") + assertNotNull("Bob should have received and decrypted a room key event from alice", content) + assertEquals("Wrong room", e2eRoomID, content!!.roomId) val megolmSessionId = content.sessionId!! val sharedIndex = aliceSession.cryptoService().getSharedWithInfo(e2eRoomID, megolmSessionId) .getObject(bobSession.myUserId, bobSession.sessionParams.deviceId) - assertEquals(0, sharedIndex, "The session received by bob should match what alice sent") + assertEquals("The session received by bob should match what alice sent", 0, sharedIndex) // Just send a real message as test val sentEvent = mTestHelper.sendTextMessage(aliceSession.getRoom(e2eRoomID)!!, "Allo", 1).first() diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineWithManyMembersTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineWithManyMembersTest.kt index ff07cf1d1d..ace48cef77 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineWithManyMembersTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/TimelineWithManyMembersTest.kt @@ -16,6 +16,7 @@ package org.matrix.android.sdk.session.room.timeline +import org.junit.Assert.fail import org.junit.FixMethodOrder import org.junit.Test import org.junit.runner.RunWith @@ -29,7 +30,6 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import org.matrix.android.sdk.common.CommonTestHelper import org.matrix.android.sdk.common.CryptoTestHelper import java.util.concurrent.CountDownLatch -import kotlin.test.fail @RunWith(JUnit4::class) @FixMethodOrder(MethodSorters.JVM) @@ -80,6 +80,7 @@ class TimelineWithManyMembersTest : InstrumentedTest { return@createEventListener true } else { fail("User " + session.myUserId + " decrypted as " + body + " CryptoError: " + it.root.mCryptoError) + false } } ?: return@createEventListener false } diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceCreationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceCreationTest.kt new file mode 100644 index 0000000000..a1744a0dae --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceCreationTest.kt @@ -0,0 +1,222 @@ +/* + * Copyright 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.session.space + +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.query.ActiveSpaceFilter +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.RoomSummaryQueryParams +import org.matrix.android.sdk.api.session.room.model.GuestAccess +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent +import org.matrix.android.sdk.api.session.room.model.RoomGuestAccessContent +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibilityContent +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomPreset +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent +import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams +import org.matrix.android.sdk.api.session.space.JoinSpaceResult +import org.matrix.android.sdk.common.CommonTestHelper +import org.matrix.android.sdk.common.SessionTestParams + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class SpaceCreationTest : InstrumentedTest { + + private val commonTestHelper = CommonTestHelper(context()) + + @Test + fun createSimplePublicSpace() { + val session = commonTestHelper.createAccount("Hubble", SessionTestParams(true)) + val roomName = "My Space" + val topic = "A public space for test" + var spaceId: String = "" + commonTestHelper.waitWithLatch { + GlobalScope.launch { + spaceId = session.spaceService().createSpace(roomName, topic, null, true) + // wait a bit to let the summary update it self :/ + it.countDown() + } + } + + val syncedSpace = session.spaceService().getSpace(spaceId) + commonTestHelper.waitWithLatch { + commonTestHelper.retryPeriodicallyWithLatch(it) { + syncedSpace?.asRoom()?.roomSummary()?.name != null + } + } + assertEquals("Room name should be set", roomName, syncedSpace?.asRoom()?.roomSummary()?.name) + assertEquals("Room topic should be set", topic, syncedSpace?.asRoom()?.roomSummary()?.topic) + // assertEquals(topic, syncedSpace.asRoom().roomSummary()?., "Room topic should be set") + + assertNotNull("Space should be found by Id", syncedSpace) + val creationEvent = syncedSpace!!.asRoom().getStateEvent(EventType.STATE_ROOM_CREATE) + val createContent = creationEvent?.content.toModel() + assertEquals("Room type should be space", RoomType.SPACE, createContent?.type) + + var powerLevelsContent: PowerLevelsContent? = null + commonTestHelper.waitWithLatch { latch -> + commonTestHelper.retryPeriodicallyWithLatch(latch) { + val toModel = syncedSpace.asRoom().getStateEvent(EventType.STATE_ROOM_POWER_LEVELS)?.content.toModel() + powerLevelsContent = toModel + toModel != null + } + } + assertEquals("Space-rooms should be created with a power level for events_default of 100", 100, powerLevelsContent?.eventsDefault) + + val guestAccess = syncedSpace.asRoom().getStateEvent(EventType.STATE_ROOM_GUEST_ACCESS)?.content + ?.toModel()?.guestAccess + + assertEquals("Public space room should be peekable by guest", GuestAccess.CanJoin, guestAccess) + + val historyVisibility = syncedSpace.asRoom().getStateEvent(EventType.STATE_ROOM_HISTORY_VISIBILITY)?.content + ?.toModel()?.historyVisibility + + assertEquals("Public space room should be world readable", RoomHistoryVisibility.WORLD_READABLE, historyVisibility) + + commonTestHelper.signOutAndClose(session) + } + + @Test + fun testJoinSimplePublicSpace() { + val aliceSession = commonTestHelper.createAccount("alice", SessionTestParams(true)) + val bobSession = commonTestHelper.createAccount("bob", SessionTestParams(true)) + + val roomName = "My Space" + val topic = "A public space for test" + val spaceId: String + runBlocking { + spaceId = aliceSession.spaceService().createSpace(roomName, topic, null, true) + // wait a bit to let the summary update it self :/ + delay(400) + } + + // Try to join from bob, it's a public space no need to invite + + val joinResult: JoinSpaceResult + runBlocking { + joinResult = bobSession.spaceService().joinSpace(spaceId) + } + + assertEquals(JoinSpaceResult.Success, joinResult) + + val spaceBobPov = bobSession.spaceService().getSpace(spaceId) + assertEquals("Room name should be set", roomName, spaceBobPov?.asRoom()?.roomSummary()?.name) + assertEquals("Room topic should be set", topic, spaceBobPov?.asRoom()?.roomSummary()?.topic) + + commonTestHelper.signOutAndClose(aliceSession) + commonTestHelper.signOutAndClose(bobSession) + } + + @Test + fun testSimplePublicSpaceWithChildren() { + val aliceSession = commonTestHelper.createAccount("alice", SessionTestParams(true)) + val bobSession = commonTestHelper.createAccount("bob", SessionTestParams(true)) + + val roomName = "My Space" + val topic = "A public space for test" + + val spaceId: String = runBlocking { aliceSession.spaceService().createSpace(roomName, topic, null, true) } + val syncedSpace = aliceSession.spaceService().getSpace(spaceId) + + // create a room + var firstChild: String? = null + commonTestHelper.waitWithLatch { + GlobalScope.launch { + firstChild = aliceSession.createRoom(CreateRoomParams().apply { + this.name = "FirstRoom" + this.topic = "Description of first room" + this.preset = CreateRoomPreset.PRESET_PUBLIC_CHAT + }) + it.countDown() + } + } + + commonTestHelper.waitWithLatch { + GlobalScope.launch { + syncedSpace?.addChildren(firstChild!!, listOf(aliceSession.sessionParams.homeServerHost ?: ""), "a", true, suggested = true) + it.countDown() + } + } + + var secondChild: String? = null + commonTestHelper.waitWithLatch { + GlobalScope.launch { + secondChild = aliceSession.createRoom(CreateRoomParams().apply { + this.name = "SecondRoom" + this.topic = "Description of second room" + this.preset = CreateRoomPreset.PRESET_PUBLIC_CHAT + }) + it.countDown() + } + } + + commonTestHelper.waitWithLatch { + GlobalScope.launch { + syncedSpace?.addChildren(secondChild!!, listOf(aliceSession.sessionParams.homeServerHost ?: ""), "b", false, suggested = true) + it.countDown() + } + } + + // Try to join from bob, it's a public space no need to invite + var joinResult: JoinSpaceResult? = null + commonTestHelper.waitWithLatch { + GlobalScope.launch { + joinResult = bobSession.spaceService().joinSpace(spaceId) + // wait a bit to let the summary update it self :/ + it.countDown() + } + } + + assertEquals(JoinSpaceResult.Success, joinResult) + + val spaceBobPov = bobSession.spaceService().getSpace(spaceId) + assertEquals("Room name should be set", roomName, spaceBobPov?.asRoom()?.roomSummary()?.name) + assertEquals("Room topic should be set", topic, spaceBobPov?.asRoom()?.roomSummary()?.topic) + + // check if bob has joined automatically the first room + + val bobMembershipFirstRoom = bobSession.getRoomSummary(firstChild!!)?.membership + assertEquals("Bob should have joined this room", Membership.JOIN, bobMembershipFirstRoom) + RoomSummaryQueryParams.Builder() + + val childCount = bobSession.getRoomSummaries( + roomSummaryQueryParams { + activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(spaceId) + } + ).size + + assertEquals("Unexpected number of joined children", 1, childCount) + + commonTestHelper.signOutAndClose(aliceSession) + commonTestHelper.signOutAndClose(bobSession) + } +} diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt new file mode 100644 index 0000000000..521b5805bd --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt @@ -0,0 +1,472 @@ +/* + * Copyright 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.session.space + +import android.util.Log +import androidx.lifecycle.Observer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.query.ActiveSpaceFilter +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams +import org.matrix.android.sdk.common.CommonTestHelper +import org.matrix.android.sdk.common.SessionTestParams + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class SpaceHierarchyTest : InstrumentedTest { + + private val commonTestHelper = CommonTestHelper(context()) + + @Test + fun createCanonicalChildRelation() { + val session = commonTestHelper.createAccount("John", SessionTestParams(true)) + val spaceName = "My Space" + val topic = "A public space for test" + var spaceId: String = "" + commonTestHelper.waitWithLatch { + GlobalScope.launch { + spaceId = session.spaceService().createSpace(spaceName, topic, null, true) + it.countDown() + } + } + + val syncedSpace = session.spaceService().getSpace(spaceId) + + var roomId: String = "" + commonTestHelper.waitWithLatch { + GlobalScope.launch { + roomId = session.createRoom(CreateRoomParams().apply { name = "General" }) + it.countDown() + } + } + + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + + commonTestHelper.waitWithLatch { + GlobalScope.launch { + syncedSpace!!.addChildren(roomId, viaServers, null, true) + it.countDown() + } + } + + commonTestHelper.waitWithLatch { + GlobalScope.launch { + session.spaceService().setSpaceParent(roomId, spaceId, true, viaServers) + it.countDown() + } + } + + Thread.sleep(9000) + + val parents = session.getRoom(roomId)?.roomSummary()?.spaceParents + val canonicalParents = session.getRoom(roomId)?.roomSummary()?.spaceParents?.filter { it.canonical == true } + + parents?.forEach { + Log.d("## TEST", "parent : $it") + } + + assertNotNull(parents) + assertEquals(1, parents!!.size) + assertEquals(spaceName, parents.first().roomSummary?.name) + + assertNotNull(canonicalParents) + assertEquals(1, canonicalParents!!.size) + assertEquals(spaceName, canonicalParents.first().roomSummary?.name) + } + +// @Test +// fun testCreateChildRelations() { +// val session = commonTestHelper.createAccount("Jhon", SessionTestParams(true)) +// val spaceName = "My Space" +// val topic = "A public space for test" +// Log.d("## TEST", "Before") +// +// var spaceId = "" +// commonTestHelper.waitWithLatch { +// GlobalScope.launch { +// spaceId = session.spaceService().createSpace(spaceName, topic, null, true) +// it.countDown() +// } +// } +// +// Log.d("## TEST", "created space $spaceId ${Thread.currentThread()}") +// val syncedSpace = session.spaceService().getSpace(spaceId) +// +// val children = listOf("General" to true /*canonical*/, "Random" to false) +// +// // val roomIdList = children.map { +// // runBlocking { +// // session.createRoom(CreateRoomParams().apply { name = it.first }) +// // } to it.second +// // } +// val roomIdList = mutableListOf>() +// commonTestHelper.waitWithLatch { +// GlobalScope.launch { +// children.forEach { +// val rID = session.createRoom(CreateRoomParams().apply { name = it.first }) +// roomIdList.add(rID to it.second) +// } +// it.countDown() +// } +// } +// +// val viaServers = listOf(session.sessionParams.homeServerHost ?: "") +// +// commonTestHelper.waitWithLatch { +// GlobalScope.launch { +// roomIdList.forEach { entry -> +// syncedSpace!!.addChildren(entry.first, viaServers, null, true) +// } +// it.countDown() +// } +// } +// +// commonTestHelper.waitWithLatch { +// GlobalScope.launch { +// roomIdList.forEach { +// session.spaceService().setSpaceParent(it.first, spaceId, it.second, viaServers) +// } +// it.countDown() +// } +// } +// +// roomIdList.forEach { +// val parents = session.getRoom(it.first)?.roomSummary()?.spaceParents +// val canonicalParents = session.getRoom(it.first)?.roomSummary()?.spaceParents?.filter { it.canonical == true } +// +// assertNotNull(parents) +// assertEquals("Unexpected number of parent", 1, parents!!.size) +// assertEquals("Unexpected parent name", spaceName, parents.first().roomSummary?.name) +// assertEquals("Parent of ${it.first} should be canonical ${it.second}", if (it.second) 1 else 0, canonicalParents?.size ?: 0) +// } +// } + + @Test + fun testFilteringBySpace() { + val session = commonTestHelper.createAccount("John", SessionTestParams(true)) + + val spaceAInfo = createPublicSpace(session, "SpaceA", listOf( + Triple("A1", true /*auto-join*/, true/*canonical*/), + Triple("A2", true, true) + )) + + val spaceBInfo = createPublicSpace(session, "SpaceB", listOf( + Triple("B1", true /*auto-join*/, true/*canonical*/), + Triple("B2", true, true), + Triple("B3", true, true) + )) + + val spaceCInfo = createPublicSpace(session, "SpaceC", listOf( + Triple("C1", true /*auto-join*/, true/*canonical*/), + Triple("C2", true, true) + )) + + // add C as a subspace of A + val spaceA = session.spaceService().getSpace(spaceAInfo.spaceId) + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + commonTestHelper.waitWithLatch { + GlobalScope.launch { + spaceA!!.addChildren(spaceCInfo.spaceId, viaServers, null, true) + session.spaceService().setSpaceParent(spaceCInfo.spaceId, spaceAInfo.spaceId, true, viaServers) + it.countDown() + } + } + + // Create orphan rooms + + var orphan1 = "" + commonTestHelper.waitWithLatch { + GlobalScope.launch { + orphan1 = session.createRoom(CreateRoomParams().apply { name = "O1" }) + it.countDown() + } + } + + var orphan2 = "" + commonTestHelper.waitWithLatch { + GlobalScope.launch { + orphan2 = session.createRoom(CreateRoomParams().apply { name = "O2" }) + it.countDown() + } + } + + val allRooms = session.getRoomSummaries(roomSummaryQueryParams { excludeType = listOf(RoomType.SPACE) }) + + assertEquals("Unexpected number of rooms", 9, allRooms.size) + + val orphans = session.getFlattenRoomSummaryChildrenOf(null) + + assertEquals("Unexpected number of orphan rooms", 2, orphans.size) + assertTrue("O1 should be an orphan", orphans.any { it.roomId == orphan1 }) + assertTrue("O2 should be an orphan ${orphans.map { it.name }}", orphans.any { it.roomId == orphan2 }) + + val aChildren = session.getFlattenRoomSummaryChildrenOf(spaceAInfo.spaceId) + + assertEquals("Unexpected number of flatten child rooms", 4, aChildren.size) + assertTrue("A1 should be a child of A", aChildren.any { it.name == "A1" }) + assertTrue("A2 should be a child of A", aChildren.any { it.name == "A2" }) + assertTrue("CA should be a grand child of A", aChildren.any { it.name == "C1" }) + assertTrue("A1 should be a grand child of A", aChildren.any { it.name == "C2" }) + + // Add a non canonical child and check that it does not appear as orphan + commonTestHelper.waitWithLatch { + GlobalScope.launch { + val a3 = session.createRoom(CreateRoomParams().apply { name = "A3" }) + spaceA!!.addChildren(a3, viaServers, null, false) + it.countDown() + } + } + + Thread.sleep(2_000) + val orphansUpdate = session.getRoomSummaries(roomSummaryQueryParams { + activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(null) + }) + assertEquals("Unexpected number of orphan rooms ${orphansUpdate.map { it.name }}", 2, orphansUpdate.size) + } + + @Test + fun testBreakCycle() { + val session = commonTestHelper.createAccount("John", SessionTestParams(true)) + + val spaceAInfo = createPublicSpace(session, "SpaceA", listOf( + Triple("A1", true /*auto-join*/, true/*canonical*/), + Triple("A2", true, true) + )) + + val spaceCInfo = createPublicSpace(session, "SpaceC", listOf( + Triple("C1", true /*auto-join*/, true/*canonical*/), + Triple("C2", true, true) + )) + + // add C as a subspace of A + val spaceA = session.spaceService().getSpace(spaceAInfo.spaceId) + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + commonTestHelper.waitWithLatch { + GlobalScope.launch { + spaceA!!.addChildren(spaceCInfo.spaceId, viaServers, null, true) + session.spaceService().setSpaceParent(spaceCInfo.spaceId, spaceAInfo.spaceId, true, viaServers) + it.countDown() + } + } + + // add back A as subspace of C + commonTestHelper.waitWithLatch { + GlobalScope.launch { + val spaceC = session.spaceService().getSpace(spaceCInfo.spaceId) + spaceC!!.addChildren(spaceAInfo.spaceId, viaServers, null, true) + it.countDown() + } + } + + Thread.sleep(1000) + + // A -> C -> A + + val aChildren = session.getFlattenRoomSummaryChildrenOf(spaceAInfo.spaceId) + + assertEquals("Unexpected number of flatten child rooms ${aChildren.map { it.name }}", 4, aChildren.size) + assertTrue("A1 should be a child of A", aChildren.any { it.name == "A1" }) + assertTrue("A2 should be a child of A", aChildren.any { it.name == "A2" }) + assertTrue("CA should be a grand child of A", aChildren.any { it.name == "C1" }) + assertTrue("A1 should be a grand child of A", aChildren.any { it.name == "C2" }) + } + + @Test + fun testLiveFlatChildren() { + val session = commonTestHelper.createAccount("John", SessionTestParams(true)) + + val spaceAInfo = createPublicSpace(session, "SpaceA", listOf( + Triple("A1", true /*auto-join*/, true/*canonical*/), + Triple("A2", true, true) + )) + + val spaceBInfo = createPublicSpace(session, "SpaceB", listOf( + Triple("B1", true /*auto-join*/, true/*canonical*/), + Triple("B2", true, true), + Triple("B3", true, true) + )) + + // add B as a subspace of A + val spaceA = session.spaceService().getSpace(spaceAInfo.spaceId) + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + runBlocking { + spaceA!!.addChildren(spaceBInfo.spaceId, viaServers, null, true) + session.spaceService().setSpaceParent(spaceBInfo.spaceId, spaceAInfo.spaceId, true, viaServers) + } + + val flatAChildren = runBlocking(Dispatchers.Main) { + session.getFlattenRoomSummaryChildrenOfLive(spaceAInfo.spaceId) + } + + commonTestHelper.waitWithLatch { latch -> + + val childObserver = object : Observer> { + override fun onChanged(children: List?) { +// Log.d("## TEST", "Space A flat children update : ${children?.map { it.name }}") + System.out.println("## TEST | Space A flat children update : ${children?.map { it.name }}") + if (children?.any { it.name == "C1" } == true && children.any { it.name == "C2" }) { + // B1 has been added live! + latch.countDown() + flatAChildren.removeObserver(this) + } + } + } + + val spaceCInfo = createPublicSpace(session, "SpaceC", listOf( + Triple("C1", true /*auto-join*/, true/*canonical*/), + Triple("C2", true, true) + )) + + // add C as subspace of B + runBlocking { + val spaceB = session.spaceService().getSpace(spaceBInfo.spaceId) + spaceB!!.addChildren(spaceCInfo.spaceId, viaServers, null, true) + } + + // C1 and C2 should be in flatten child of A now + + GlobalScope.launch(Dispatchers.Main) { flatAChildren.observeForever(childObserver) } + } + + // Test part one of the rooms + + val bRoomId = spaceBInfo.roomIds.first() + val bRoom = session.getRoom(bRoomId) + + commonTestHelper.waitWithLatch { latch -> + + val childObserver = object : Observer> { + override fun onChanged(children: List?) { + System.out.println("## TEST | Space A flat children update : ${children?.map { it.name }}") + if (children?.any { it.roomId == bRoomId } == false) { + // B1 has been added live! + latch.countDown() + flatAChildren.removeObserver(this) + } + } + } + + // part from b room + runBlocking { + bRoom!!.leave(null) + } + // The room should have disapear from flat children + GlobalScope.launch(Dispatchers.Main) { flatAChildren.observeForever(childObserver) } + } + } + + data class TestSpaceCreationResult( + val spaceId: String, + val roomIds: List + ) + + private fun createPublicSpace(session: Session, + spaceName: String, + childInfo: List> + /** Name, auto-join, canonical*/ + ): TestSpaceCreationResult { + var spaceId = "" + commonTestHelper.waitWithLatch { + GlobalScope.launch { + spaceId = session.spaceService().createSpace(spaceName, "Test Topic", null, true) + it.countDown() + } + } + + val syncedSpace = session.spaceService().getSpace(spaceId) + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + + val roomIds = + childInfo.map { entry -> + var roomId = "" + commonTestHelper.waitWithLatch { + GlobalScope.launch { + roomId = session.createRoom(CreateRoomParams().apply { name = entry.first }) + it.countDown() + } + } + roomId + } + + roomIds.forEachIndexed { index, roomId -> + runBlocking { + syncedSpace!!.addChildren(roomId, viaServers, null, childInfo[index].second) + val canonical = childInfo[index].third + if (canonical != null) { + session.spaceService().setSpaceParent(roomId, spaceId, canonical, viaServers) + } + } + } + return TestSpaceCreationResult(spaceId, roomIds) + } + + @Test + fun testRootSpaces() { + val session = commonTestHelper.createAccount("John", SessionTestParams(true)) + + val spaceAInfo = createPublicSpace(session, "SpaceA", listOf( + Triple("A1", true /*auto-join*/, true/*canonical*/), + Triple("A2", true, true) + )) + + val spaceBInfo = createPublicSpace(session, "SpaceB", listOf( + Triple("B1", true /*auto-join*/, true/*canonical*/), + Triple("B2", true, true), + Triple("B3", true, true) + )) + + val spaceCInfo = createPublicSpace(session, "SpaceC", listOf( + Triple("C1", true /*auto-join*/, true/*canonical*/), + Triple("C2", true, true) + )) + + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + + // add C as subspace of B + runBlocking { + val spaceB = session.spaceService().getSpace(spaceBInfo.spaceId) + spaceB!!.addChildren(spaceCInfo.spaceId, viaServers, null, true) + } + + Thread.sleep(2000) + // + A + // a1, a2 + // + B + // b1, b2, b3 + // + C + // + c1, c2 + + val rootSpaces = session.spaceService().getRootSpaceSummaries() + + assertEquals("Unexpected number of root spaces ${rootSpaces.map { it.name }}", 2, rootSpaces.size) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt index f1f9ba3916..7d1407c0d8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt @@ -16,12 +16,10 @@ package org.matrix.android.sdk.api.auth.data -sealed class LoginFlowResult { - data class Success( - val supportedLoginTypes: List, - val ssoIdentityProviders: List?, - val isLoginAndRegistrationSupported: Boolean, - val homeServerUrl: String, - val isOutdatedHomeserver: Boolean - ) : LoginFlowResult() -} +data class LoginFlowResult( + val supportedLoginTypes: List, + val ssoIdentityProviders: List?, + val isLoginAndRegistrationSupported: Boolean, + val homeServerUrl: String, + val isOutdatedHomeserver: Boolean +) 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 38a5a77291..f059bf26c4 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 @@ -20,7 +20,9 @@ interface RegistrationWizard { suspend fun getRegistrationFlow(): RegistrationResult - suspend fun createAccount(userName: String, password: String, initialDeviceDisplayName: String?): RegistrationResult + suspend fun createAccount(userName: String?, + password: String?, + initialDeviceDisplayName: String?): RegistrationResult suspend fun performReCaptcha(response: String): RegistrationResult diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Failure.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Failure.kt index b241903364..8f1bbb6941 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Failure.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Failure.kt @@ -32,7 +32,6 @@ import java.io.IOException */ sealed class Failure(cause: Throwable? = null) : Throwable(cause = cause) { data class Unknown(val throwable: Throwable? = null) : Failure(throwable) - data class Cancelled(val throwable: Throwable? = null) : Failure(throwable) data class UnrecognizedCertificateFailure(val url: String, val fingerprint: Fingerprint) : Failure() data class NetworkConnection(val ioException: IOException? = null) : Failure(ioException) data class ServerError(val error: MatrixError, val httpCode: Int) : Failure(RuntimeException(error.toString())) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/MatrixError.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/MatrixError.kt index 3820a442aa..73b0fe0a7c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/MatrixError.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/MatrixError.kt @@ -41,7 +41,7 @@ data class MatrixError( // For M_LIMIT_EXCEEDED @Json(name = "retry_after_ms") val retryAfterMillis: Long? = null, // For M_UNKNOWN_TOKEN - @Json(name = "soft_logout") val isSoftLogout: Boolean = false, + @Json(name = "soft_logout") val isSoftLogout: Boolean? = null, // For M_INVALID_PEPPER // {"error": "pepper does not match 'erZvr'", "lookup_pepper": "pQgMS", "algorithm": "sha256", "errcode": "M_INVALID_PEPPER"} @Json(name = "lookup_pepper") val newLookupPepper: String? = null, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/query/ActiveSpaceFilter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/query/ActiveSpaceFilter.kt new file mode 100644 index 0000000000..48619b9394 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/query/ActiveSpaceFilter.kt @@ -0,0 +1,23 @@ +/* + * 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.query + +sealed class ActiveSpaceFilter { + object None : ActiveSpaceFilter() + data class ActiveSpace(val currentSpaceId: String?) : ActiveSpaceFilter() + data class ExcludeSpace(val spaceId: String) : ActiveSpaceFilter() +} 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 a15799d862..86252665a6 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.search.SearchService import org.matrix.android.sdk.api.session.securestorage.SecureStorageService import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageService import org.matrix.android.sdk.api.session.signout.SignOutService +import org.matrix.android.sdk.api.session.space.SpaceService 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 @@ -227,6 +228,11 @@ interface Session : */ fun thirdPartyService(): ThirdPartyService + /** + * Returns the space service associated with the session + */ + fun spaceService(): SpaceService + /** * Add a listener to the session. * @param listener the listener to add. @@ -249,13 +255,13 @@ interface Session : /** * A global session listener to get notified for some events. */ - interface Listener { + interface Listener : SessionLifecycleObserver { /** * Possible cases: * - The access token is not valid anymore, * - a M_CONSENT_NOT_GIVEN error has been received from the homeserver */ - fun onGlobalError(globalError: GlobalError) + fun onGlobalError(session: Session, globalError: GlobalError) } val sharedSecretStorageService: SharedSecretStorageService diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionLifecycleObserver.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/SessionLifecycleObserver.kt similarity index 80% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionLifecycleObserver.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/SessionLifecycleObserver.kt index cb37fbec75..b76e454e4b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionLifecycleObserver.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/SessionLifecycleObserver.kt @@ -14,20 +14,19 @@ * limitations under the License. */ -package org.matrix.android.sdk.internal.session +package org.matrix.android.sdk.api.session import androidx.annotation.MainThread /** * This defines methods associated with some lifecycle events of a session. - * A list of SessionLifecycle will be injected into [DefaultSession] */ -internal interface SessionLifecycleObserver { +interface SessionLifecycleObserver { /* Called when the session is opened */ @MainThread - fun onSessionStarted() { + fun onSessionStarted(session: Session) { // noop } @@ -35,7 +34,7 @@ internal interface SessionLifecycleObserver { Called when the session is cleared */ @MainThread - fun onClearCache() { + fun onClearCache(session: Session) { // noop } @@ -43,7 +42,7 @@ internal interface SessionLifecycleObserver { Called when the session is closed */ @MainThread - fun onSessionStopped() { + fun onSessionStopped(session: Session) { // noop } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentUploadStateTracker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentUploadStateTracker.kt index 924da6c19b..ec63eb0be2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentUploadStateTracker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentUploadStateTracker.kt @@ -31,6 +31,8 @@ interface ContentUploadStateTracker { sealed class State { object Idle : State() object EncryptingThumbnail : State() + object CompressingImage : State() + data class CompressingVideo(val percent: Float) : State() data class UploadingThumbnail(val current: Long, val total: Long) : State() data class Encrypting(val current: Long, val total: Long) : State() data class Uploading(val current: Long, val total: Long) : State() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt index 89b873febb..6400dd6444 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt @@ -28,6 +28,8 @@ import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent import org.matrix.android.sdk.internal.di.MoshiProvider import org.json.JSONObject +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.failure.MatrixError import timber.log.Timber typealias Content = JsonDict @@ -90,6 +92,16 @@ data class Event( @Transient var sendState: SendState = SendState.UNKNOWN + @Transient + var sendStateDetails: String? = null + + fun sendStateError(): MatrixError? { + return sendStateDetails?.let { + val matrixErrorAdapter = MoshiProvider.providesMoshi().adapter(MatrixError::class.java) + tryOrNull { matrixErrorAdapter.fromJson(it) } + } + } + /** * The `age` value transcoded in a timestamp based on the device clock when the SDK received * the event from the home server. 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 905e18b8e8..d2befca1ee 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 @@ -52,6 +52,10 @@ object EventType { const val STATE_ROOM_GUEST_ACCESS = "m.room.guest_access" const val STATE_ROOM_POWER_LEVELS = "m.room.power_levels" + const val STATE_SPACE_CHILD = "m.space.child" + + const val STATE_SPACE_PARENT = "m.space.parent" + /** * Note that this Event has been deprecated, see * - https://matrix.org/docs/spec/client_server/r0.6.1#historical-events @@ -74,6 +78,7 @@ object EventType { 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" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/file/FileService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/file/FileService.kt index adfdc2498e..23dc1e0ba8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/file/FileService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/file/FileService.kt @@ -29,14 +29,19 @@ import java.io.File */ interface FileService { - enum class FileState { - IN_CACHE, - DOWNLOADING, - UNKNOWN + sealed class FileState { + /** + * The original file is in cache, but the decrypted files can be deleted for security reason. + * To decrypt the file again, call [downloadFile], the encrypted file will not be downloaded again + * @param decryptedFileInCache true if the decrypted file is available. Always true for clear files. + */ + data class InCache(val decryptedFileInCache: Boolean) : FileState() + object Downloading : FileState() + object Unknown : FileState() } /** - * Download a file. + * Download a file if necessary and ensure that if the file is encrypted, the file is decrypted. * Result will be a decrypted file, stored in the cache folder. url parameter will be used to create unique filename to avoid name collision. */ suspend fun downloadFile(fileName: String, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/PushersService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/PushersService.kt index 9ea820f5b3..a5ec100f64 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/PushersService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushers/PushersService.kt @@ -66,12 +66,13 @@ interface PushersService { /** * Directly ask the push gateway to send a push to this device + * If successful, the push gateway has accepted the request. In this case, the app should receive a Push with the provided eventId. + * In case of error, PusherRejected will be thrown. In this case it means that the pushkey is not valid. + * * @param url the push gateway url (full path) * @param appId the application id * @param pushkey the FCM token * @param eventId the eventId which will be sent in the Push message. Use a fake eventId. - * @param callback callback to know if the push gateway has accepted the request. In this case, the app should receive a Push with the provided eventId. - * In case of error, PusherRejected failure can happen. In this case it means that the pushkey is not valid. */ suspend fun testPush(url: String, appId: String, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt index 257c83564e..f3eeb902a8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt @@ -34,6 +34,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.uploads.UploadsService import org.matrix.android.sdk.api.session.search.SearchResult +import org.matrix.android.sdk.api.session.space.Space import org.matrix.android.sdk.api.util.Optional /** @@ -91,4 +92,9 @@ interface Room : beforeLimit: Int, afterLimit: Int, includeProfile: Boolean): SearchResult + + /** + * Use this room as a Space, if the type is correct. + */ + fun asSpace(): Space? } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt index 22045366cb..871c5378a6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData import androidx.paging.PagedList import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState +import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams @@ -177,13 +178,15 @@ interface RoomService { * TODO Doc */ fun getPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, - pagedListConfig: PagedList.Config = defaultPagedListConfig): LiveData> + pagedListConfig: PagedList.Config = defaultPagedListConfig, + sortOrder: RoomSortOrder = RoomSortOrder.ACTIVITY): LiveData> /** * TODO Doc */ fun getFilteredPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, - pagedListConfig: PagedList.Config = defaultPagedListConfig): UpdatableFilterLivePageResult + pagedListConfig: PagedList.Config = defaultPagedListConfig, + sortOrder: RoomSortOrder = RoomSortOrder.ACTIVITY): UpdatableLivePageResult /** * TODO Doc @@ -197,4 +200,12 @@ interface RoomService { .setEnablePlaceholders(false) .setPrefetchDistance(10) .build() + + fun getFlattenRoomSummaryChildrenOf(spaceId: String?, memberships: List = Membership.activeMemberships()) : List + + /** + * Returns all the children of this space, as LiveData + */ + fun getFlattenRoomSummaryChildrenOfLive(spaceId: String?, + memberships: List = Membership.activeMemberships()): LiveData> } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSortOrder.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSortOrder.kt new file mode 100644 index 0000000000..36da242527 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSortOrder.kt @@ -0,0 +1,23 @@ +/* + * Copyright 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.room + +enum class RoomSortOrder { + NAME, + ACTIVITY, + NONE +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSummaryQueryParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSummaryQueryParams.kt index 7e04ebb5f2..88ec2de768 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSummaryQueryParams.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSummaryQueryParams.kt @@ -16,15 +16,35 @@ package org.matrix.android.sdk.api.session.room +import org.matrix.android.sdk.api.query.ActiveSpaceFilter import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.query.RoomCategoryFilter import org.matrix.android.sdk.api.query.RoomTagQueryFilter import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams fun roomSummaryQueryParams(init: (RoomSummaryQueryParams.Builder.() -> Unit) = {}): RoomSummaryQueryParams { return RoomSummaryQueryParams.Builder().apply(init).build() } +fun spaceSummaryQueryParams(init: (RoomSummaryQueryParams.Builder.() -> Unit) = {}): SpaceSummaryQueryParams { + return RoomSummaryQueryParams.Builder() + .apply(init) + .apply { + includeType = listOf(RoomType.SPACE) + excludeType = null + roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + } + .build() +} + +enum class RoomCategoryFilter { + ONLY_DM, + ONLY_ROOMS, + ALL +} + /** * This class can be used to filter room summaries to use with: * [org.matrix.android.sdk.api.session.room.Room] and [org.matrix.android.sdk.api.session.room.RoomService] @@ -35,7 +55,11 @@ data class RoomSummaryQueryParams( val canonicalAlias: QueryStringValue, val memberships: List, val roomCategoryFilter: RoomCategoryFilter?, - val roomTagQueryFilter: RoomTagQueryFilter? + val roomTagQueryFilter: RoomTagQueryFilter?, + val excludeType: List?, + val includeType: List?, + val activeSpaceFilter: ActiveSpaceFilter?, + var activeGroupId: String? = null ) { class Builder { @@ -46,6 +70,10 @@ data class RoomSummaryQueryParams( var memberships: List = Membership.all() var roomCategoryFilter: RoomCategoryFilter? = RoomCategoryFilter.ALL var roomTagQueryFilter: RoomTagQueryFilter? = null + var excludeType: List? = listOf(RoomType.SPACE) + var includeType: List? = null + var activeSpaceFilter: ActiveSpaceFilter = ActiveSpaceFilter.None + var activeGroupId: String? = null fun build() = RoomSummaryQueryParams( roomId = roomId, @@ -53,7 +81,11 @@ data class RoomSummaryQueryParams( canonicalAlias = canonicalAlias, memberships = memberships, roomCategoryFilter = roomCategoryFilter, - roomTagQueryFilter = roomTagQueryFilter + roomTagQueryFilter = roomTagQueryFilter, + excludeType = excludeType, + includeType = includeType, + activeSpaceFilter = activeSpaceFilter, + activeGroupId = activeGroupId ) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableFilterLivePageResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt similarity index 72% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableFilterLivePageResult.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt index 71b3c665e7..b83f57f5ef 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableFilterLivePageResult.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt @@ -20,8 +20,16 @@ import androidx.lifecycle.LiveData import androidx.paging.PagedList import org.matrix.android.sdk.api.session.room.model.RoomSummary -interface UpdatableFilterLivePageResult { +interface UpdatableLivePageResult { val livePagedList: LiveData> - fun updateQuery(queryParams: RoomSummaryQueryParams) + fun updateQuery(builder: (RoomSummaryQueryParams) -> RoomSummaryQueryParams) + + val liveBoundaries: LiveData } + +data class ResultBoundaries( + val frontLoaded: Boolean = false, + val endLoaded: Boolean = false, + val zeroItemLoaded: Boolean = false +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/alias/RoomAliasError.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/alias/RoomAliasError.kt index d2cb7c58a9..1102eda11c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/alias/RoomAliasError.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/alias/RoomAliasError.kt @@ -17,7 +17,7 @@ package org.matrix.android.sdk.api.session.room.alias sealed class RoomAliasError : Throwable() { - object AliasEmpty : RoomAliasError() + object AliasIsBlank : RoomAliasError() object AliasNotAvailable : RoomAliasError() object AliasInvalid : RoomAliasError() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/failure/CreateRoomFailure.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/failure/CreateRoomFailure.kt index 208cdd4556..deab0ca3e7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/failure/CreateRoomFailure.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/failure/CreateRoomFailure.kt @@ -21,7 +21,7 @@ import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.session.room.alias.RoomAliasError sealed class CreateRoomFailure : Failure.FeatureFailure() { - object CreatedWithTimeout : CreateRoomFailure() + data class CreatedWithTimeout(val roomID: String) : CreateRoomFailure() data class CreatedWithFederationFailure(val matrixError: MatrixError) : CreateRoomFailure() data class AliasError(val aliasError: RoomAliasError) : CreateRoomFailure() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PowerLevelsContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PowerLevelsContent.kt index e778f5740d..5c46db7166 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PowerLevelsContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PowerLevelsContent.kt @@ -28,43 +28,43 @@ data class PowerLevelsContent( /** * The level required to ban a user. Defaults to 50 if unspecified. */ - @Json(name = "ban") val ban: Int = Role.Moderator.value, + @Json(name = "ban") val ban: Int? = null, /** * The level required to kick a user. Defaults to 50 if unspecified. */ - @Json(name = "kick") val kick: Int = Role.Moderator.value, + @Json(name = "kick") val kick: Int? = null, /** * The level required to invite a user. Defaults to 50 if unspecified. */ - @Json(name = "invite") val invite: Int = Role.Moderator.value, + @Json(name = "invite") val invite: Int? = null, /** * The level required to redact an event. Defaults to 50 if unspecified. */ - @Json(name = "redact") val redact: Int = Role.Moderator.value, + @Json(name = "redact") val redact: Int? = null, /** * The default level required to send message events. Can be overridden by the events key. Defaults to 0 if unspecified. */ - @Json(name = "events_default") val eventsDefault: Int = Role.Default.value, + @Json(name = "events_default") val eventsDefault: Int? = null, /** * The level required to send specific event types. This is a mapping from event type to power level required. */ - @Json(name = "events") val events: Map = emptyMap(), + @Json(name = "events") val events: Map? = null, /** * The default power level for every user in the room, unless their user_id is mentioned in the users key. Defaults to 0 if unspecified. */ - @Json(name = "users_default") val usersDefault: Int = Role.Default.value, + @Json(name = "users_default") val usersDefault: Int? = null, /** * The power levels for specific users. This is a mapping from user_id to power level for that user. */ - @Json(name = "users") val users: Map = emptyMap(), + @Json(name = "users") val users: Map? = null, /** * The default level required to send state events. Can be overridden by the events key. Defaults to 50 if unspecified. */ - @Json(name = "state_default") val stateDefault: Int = Role.Moderator.value, + @Json(name = "state_default") val stateDefault: Int? = null, /** * The power level requirements for specific notification types. This is a mapping from key to power level for that notifications key. */ - @Json(name = "notifications") val notifications: Map = emptyMap() + @Json(name = "notifications") val notifications: Map? = null ) { /** * Return a copy of this content with a new power level for the specified user @@ -74,7 +74,7 @@ data class PowerLevelsContent( */ fun setUserPowerLevel(userId: String, powerLevel: Int?): PowerLevelsContent { return copy( - users = users.toMutableMap().apply { + users = users.orEmpty().toMutableMap().apply { if (powerLevel == null || powerLevel == usersDefault) { remove(userId) } else { @@ -91,7 +91,7 @@ data class PowerLevelsContent( * @return the level, default to Moderator if the key is not found */ fun notificationLevel(key: String): Int { - return when (val value = notifications[key]) { + return when (val value = notifications.orEmpty()[key]) { // the first implementation was a string value is String -> value.toInt() is Double -> value.toInt() @@ -107,3 +107,12 @@ data class PowerLevelsContent( const val NOTIFICATIONS_ROOM_KEY = "room" } } + +// Fallback to default value, defined in the Matrix specification +fun PowerLevelsContent.banOrDefault() = ban ?: Role.Moderator.value +fun PowerLevelsContent.kickOrDefault() = kick ?: Role.Moderator.value +fun PowerLevelsContent.inviteOrDefault() = invite ?: Role.Moderator.value +fun PowerLevelsContent.redactOrDefault() = redact ?: Role.Moderator.value +fun PowerLevelsContent.eventsDefaultOrDefault() = eventsDefault ?: Role.Default.value +fun PowerLevelsContent.usersDefaultOrDefault() = usersDefault ?: Role.Default.value +fun PowerLevelsContent.stateDefaultOrDefault() = stateDefault ?: Role.Moderator.value diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomGuestAccessContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomGuestAccessContent.kt index 0760c6f1b4..020e7ed39e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomGuestAccessContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomGuestAccessContent.kt @@ -40,7 +40,7 @@ data class RoomGuestAccessContent( } @JsonClass(generateAdapter = false) -enum class GuestAccess { - @Json(name = "can_join") CanJoin, - @Json(name = "forbidden") Forbidden +enum class GuestAccess(val value: String) { + @Json(name = "can_join") CanJoin("can_join"), + @Json(name = "forbidden") Forbidden("forbidden") } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRules.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRules.kt index f3e8d357f3..a86301a276 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRules.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRules.kt @@ -24,9 +24,10 @@ import com.squareup.moshi.JsonClass * Enum for [RoomJoinRulesContent] : https://matrix.org/docs/spec/client_server/r0.4.0#m-room-join-rules */ @JsonClass(generateAdapter = false) -enum class RoomJoinRules { - @Json(name = "public") PUBLIC, - @Json(name = "invite") INVITE, - @Json(name = "knock") KNOCK, - @Json(name = "private") PRIVATE +enum class RoomJoinRules(val value: String) { + @Json(name = "public") PUBLIC("public"), + @Json(name = "invite") INVITE("invite"), + @Json(name = "knock") KNOCK("knock"), + @Json(name = "private") PRIVATE("private"), + @Json(name = "restricted") RESTRICTED("restricted") } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesAllowEntry.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesAllowEntry.kt new file mode 100644 index 0000000000..7b87bc34d2 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesAllowEntry.kt @@ -0,0 +1,33 @@ +/* + * Copyright 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.room.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class RoomJoinRulesAllowEntry( + /** + * space: The room ID of the space to check the membership of. + */ + @Json(name = "space") val spaceID: String, + /** + * via: A list of servers which may be used to peek for membership of the space. + */ + @Json(name = "via") val via: List +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesContent.kt index 8082486b22..33f402cad3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesContent.kt @@ -1,5 +1,6 @@ /* * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 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. @@ -26,14 +27,19 @@ import timber.log.Timber */ @JsonClass(generateAdapter = true) data class RoomJoinRulesContent( - @Json(name = "join_rule") val _joinRules: String? = null + @Json(name = "join_rule") val _joinRules: String? = null, + /** + * If the allow key is an empty list (or not a list at all), then the room reverts to standard public join rules + */ + @Json(name = "allow") val allowList: List? = null ) { val joinRules: RoomJoinRules? = when (_joinRules) { - "public" -> RoomJoinRules.PUBLIC - "invite" -> RoomJoinRules.INVITE - "knock" -> RoomJoinRules.KNOCK + "public" -> RoomJoinRules.PUBLIC + "invite" -> RoomJoinRules.INVITE + "knock" -> RoomJoinRules.KNOCK "private" -> RoomJoinRules.PRIVATE - else -> { + "restricted" -> RoomJoinRules.RESTRICTED + else -> { Timber.w("Invalid value for RoomJoinRules: `$_joinRules`") null } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt index 9455a83aff..d324cff246 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt @@ -36,6 +36,7 @@ data class RoomSummary constructor( val canonicalAlias: String? = null, val aliases: List = emptyList(), val isDirect: Boolean = false, + val directUserId: String? = null, val joinedMembersCount: Int? = 0, val invitedMembersCount: Int? = 0, val latestPreviewableEvent: TimelineEvent? = null, @@ -54,7 +55,11 @@ data class RoomSummary constructor( val inviterId: String? = null, val breadcrumbsIndex: Int = NOT_IN_BREADCRUMBS, val roomEncryptionTrustLevel: RoomEncryptionTrustLevel? = null, - val hasFailedSending: Boolean = false + val hasFailedSending: Boolean = false, + val roomType: String? = null, + val spaceParents: List? = null, + val spaceChildren: List? = null, + val flattenParentIds: List = emptyList() ) { val isVersioned: Boolean 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 56503e3e35..a8a2cfb68b 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 @@ -47,7 +47,7 @@ data class RoomThirdPartyInviteContent( /** * Keys with which the token may be signed. */ - @Json(name = "public_keys") val publicKeys: List? = emptyList() + @Json(name = "public_keys") val publicKeys: List? ) @JsonClass(generateAdapter = true) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomType.kt new file mode 100644 index 0000000000..b0f3a56d67 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomType.kt @@ -0,0 +1,22 @@ +/* + * 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 + +object RoomType { + + const val SPACE = "m.space" +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/SpaceChildInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/SpaceChildInfo.kt new file mode 100644 index 0000000000..fd5fbf7bb0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/SpaceChildInfo.kt @@ -0,0 +1,33 @@ +/* + * 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 + +data class SpaceChildInfo( + val childRoomId: String, + // We might not know this child at all, + // i.e we just know it exists but no info on type/name/etc.. + val isKnown: Boolean, + val roomType: String?, + val name: String?, + val topic: String?, + val avatarUrl: String?, + val order: String?, + val activeMemberCount: Int?, + val autoJoin: Boolean, + val viaServers: List, + val parentRoomId: String? +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/SpaceParentInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/SpaceParentInfo.kt new file mode 100644 index 0000000000..5ed81b0646 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/SpaceParentInfo.kt @@ -0,0 +1,24 @@ +/* + * 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 + +data class SpaceParentInfo( + val parentId: String?, + val roomSummary: RoomSummary?, + val canonical: Boolean?, + val viaServers: List +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomParams.kt index 80e3741a0c..ca8c66bb3b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomParams.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomParams.kt @@ -18,13 +18,15 @@ package org.matrix.android.sdk.api.session.room.model.create import android.net.Uri import org.matrix.android.sdk.api.session.identity.ThreePid +import org.matrix.android.sdk.api.session.room.model.GuestAccess import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesAllowEntry import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM // TODO Give a way to include other initial states -class CreateRoomParams { +open class CreateRoomParams { /** * A public visibility indicates that the room will be shown in the published room list. * A private visibility will hide the room from the published room list. @@ -68,6 +70,11 @@ class CreateRoomParams { */ val invite3pids = mutableListOf() + /** + * Initial Guest Access + */ + var guestAccess: GuestAccess? = null + /** * If set to true, when the room will be created, if cross-signing is enabled and we can get keys for every invited users, * the encryption will be enabled on the created room @@ -111,6 +118,17 @@ class CreateRoomParams { } } + var roomType: String? = null // RoomType.MESSAGING + set(value) { + field = value + if (value != null) { + creationContent[CREATION_CONTENT_KEY_ROOM_TYPE] = value + } else { + // This is the default value, we remove the field + creationContent.remove(CREATION_CONTENT_KEY_ROOM_TYPE) + } + } + /** * The power level content to override in the default power level event */ @@ -136,7 +154,12 @@ class CreateRoomParams { algorithm = MXCRYPTO_ALGORITHM_MEGOLM } + var roomVersion: String? = null + + var joinRuleRestricted: List? = null + companion object { private const val CREATION_CONTENT_KEY_M_FEDERATE = "m.federate" + private const val CREATION_CONTENT_KEY_ROOM_TYPE = "type" } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/RoomCreateContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/RoomCreateContent.kt index 0b595b1b2b..52e5c0e9c7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/RoomCreateContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/RoomCreateContent.kt @@ -26,5 +26,7 @@ import com.squareup.moshi.JsonClass data class RoomCreateContent( @Json(name = "creator") val creator: String? = null, @Json(name = "room_version") val roomVersion: String? = null, - @Json(name = "predecessor") val predecessor: Predecessor? = null + @Json(name = "predecessor") val predecessor: Predecessor? = null, + // Defines the room type, see #RoomType (user extensible) + @Json(name = "type") val type: String? = null ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/FileInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/FileInfo.kt index e85bb0800a..f21074096e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/FileInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/FileInfo.kt @@ -47,3 +47,10 @@ data class FileInfo( */ @Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null ) + +/** + * Get the url of the encrypted thumbnail or of the thumbnail + */ +fun FileInfo.getThumbnailUrl(): String? { + return thumbnailFile?.url ?: thumbnailUrl +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/ImageInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/ImageInfo.kt index 048febec39..c38ef5bc27 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/ImageInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/ImageInfo.kt @@ -40,7 +40,7 @@ data class ImageInfo( /** * Size of the image in bytes. */ - @Json(name = "size") val size: Int = 0, + @Json(name = "size") val size: Long = 0, /** * Metadata about the image referred to in thumbnail_url. @@ -57,3 +57,10 @@ data class ImageInfo( */ @Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null ) + +/** + * Get the url of the encrypted thumbnail or of the thumbnail + */ +fun ImageInfo.getThumbnailUrl(): String? { + return thumbnailFile?.url ?: thumbnailUrl +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationInfo.kt index a6908dce5b..a76c3c5b64 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationInfo.kt @@ -37,3 +37,10 @@ data class LocationInfo( */ @Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null ) + +/** + * Get the url of the encrypted thumbnail or of the thumbnail + */ +fun LocationInfo.getThumbnailUrl(): String? { + return thumbnailFile?.url ?: thumbnailUrl +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/VideoInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/VideoInfo.kt index 8379ee9338..8a36c26313 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/VideoInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/VideoInfo.kt @@ -62,3 +62,10 @@ data class VideoInfo( */ @Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null ) + +/** + * Get the url of the encrypted thumbnail or of the thumbnail + */ +fun VideoInfo.getThumbnailUrl(): String? { + return thumbnailFile?.url ?: thumbnailUrl +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/peeking/PeekResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/peeking/PeekResult.kt index db70dadef3..888950dc12 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/peeking/PeekResult.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/peeking/PeekResult.kt @@ -16,6 +16,8 @@ package org.matrix.android.sdk.api.session.room.peeking +import org.matrix.android.sdk.api.util.MatrixItem + sealed class PeekResult { data class Success( val roomId: String, @@ -24,7 +26,9 @@ sealed class PeekResult { val topic: String?, val avatarUrl: String?, val numJoinedMembers: Int?, - val viaServers: List + val roomType: String?, + val viaServers: List, + val someMembers: List? ) : PeekResult() data class PeekingNotAllowed( @@ -34,4 +38,6 @@ sealed class PeekResult { ) : PeekResult() object UnknownAlias : PeekResult() + + fun isSuccess() = this is Success } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/powerlevels/PowerLevelsHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/powerlevels/PowerLevelsHelper.kt index 4f1253c6df..99139723a8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/powerlevels/PowerLevelsHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/powerlevels/PowerLevelsHelper.kt @@ -18,6 +18,13 @@ package org.matrix.android.sdk.api.session.room.powerlevels import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent +import org.matrix.android.sdk.api.session.room.model.banOrDefault +import org.matrix.android.sdk.api.session.room.model.eventsDefaultOrDefault +import org.matrix.android.sdk.api.session.room.model.inviteOrDefault +import org.matrix.android.sdk.api.session.room.model.kickOrDefault +import org.matrix.android.sdk.api.session.room.model.redactOrDefault +import org.matrix.android.sdk.api.session.room.model.stateDefaultOrDefault +import org.matrix.android.sdk.api.session.room.model.usersDefaultOrDefault /** * This class is an helper around PowerLevelsContent. @@ -31,9 +38,9 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { * @return the power level */ fun getUserPowerLevelValue(userId: String): Int { - return powerLevelsContent.users.getOrElse(userId) { - powerLevelsContent.usersDefault - } + return powerLevelsContent.users + ?.get(userId) + ?: powerLevelsContent.usersDefaultOrDefault() } /** @@ -45,7 +52,7 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { fun getUserRole(userId: String): Role { val value = getUserPowerLevelValue(userId) // I think we should use powerLevelsContent.usersDefault, but Ganfra told me that it was like that on riot-Web - return Role.fromValue(value, powerLevelsContent.eventsDefault) + return Role.fromValue(value, powerLevelsContent.eventsDefaultOrDefault()) } /** @@ -59,11 +66,11 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { fun isUserAllowedToSend(userId: String, isState: Boolean, eventType: String?): Boolean { return if (userId.isNotEmpty()) { val powerLevel = getUserPowerLevelValue(userId) - val minimumPowerLevel = powerLevelsContent.events[eventType] + val minimumPowerLevel = powerLevelsContent.events?.get(eventType) ?: if (isState) { - powerLevelsContent.stateDefault + powerLevelsContent.stateDefaultOrDefault() } else { - powerLevelsContent.eventsDefault + powerLevelsContent.eventsDefaultOrDefault() } powerLevel >= minimumPowerLevel } else false @@ -76,7 +83,7 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { */ fun isUserAbleToInvite(userId: String): Boolean { val powerLevel = getUserPowerLevelValue(userId) - return powerLevel >= powerLevelsContent.invite + return powerLevel >= powerLevelsContent.inviteOrDefault() } /** @@ -86,7 +93,7 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { */ fun isUserAbleToBan(userId: String): Boolean { val powerLevel = getUserPowerLevelValue(userId) - return powerLevel >= powerLevelsContent.ban + return powerLevel >= powerLevelsContent.banOrDefault() } /** @@ -96,7 +103,7 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { */ fun isUserAbleToKick(userId: String): Boolean { val powerLevel = getUserPowerLevelValue(userId) - return powerLevel >= powerLevelsContent.kick + return powerLevel >= powerLevelsContent.kickOrDefault() } /** @@ -106,6 +113,6 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { */ fun isUserAbleToRedact(userId: String): Boolean { val powerLevel = getUserPowerLevelValue(userId) - return powerLevel >= powerLevelsContent.redact + return powerLevel >= powerLevelsContent.redactOrDefault() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomAggregateNotificationCount.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomAggregateNotificationCount.kt index 066178b1ec..b3440059e8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomAggregateNotificationCount.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomAggregateNotificationCount.kt @@ -20,6 +20,6 @@ data class RoomAggregateNotificationCount( val notificationCount: Int, val highlightCount: Int ) { - val totalCount = notificationCount + highlightCount + val totalCount = notificationCount val isHighlight = highlightCount > 0 } 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 32f6b94cd8..4a6462477d 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 @@ -21,6 +21,7 @@ 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.RelationType import org.matrix.android.sdk.api.session.events.model.getRelationContent +import org.matrix.android.sdk.api.session.events.model.isEdition import org.matrix.android.sdk.api.session.events.model.isReply import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary @@ -151,6 +152,10 @@ fun TimelineEvent.isReply(): Boolean { return root.isReply() } +fun TimelineEvent.isEdition(): Boolean { + return root.isEdition() +} + fun TimelineEvent.getTextEditableContent(): String? { val lastContent = getLastMessageContent() return if (isReply()) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/CreateSpaceParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/CreateSpaceParams.kt new file mode 100644 index 0000000000..42e6584838 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/CreateSpaceParams.kt @@ -0,0 +1,35 @@ +/* + * 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.space + +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams + +class CreateSpaceParams : CreateRoomParams() { + + init { + // Space-rooms are distinguished from regular messaging rooms by the m.room.type of m.space + roomType = RoomType.SPACE + + // Space-rooms should be created with a power level for events_default of 100, + // to prevent the rooms accidentally/maliciously clogging up with messages from random members of the space. + powerLevelContentOverride = PowerLevelsContent( + eventsDefault = 100 + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/JoinSpaceResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/JoinSpaceResult.kt new file mode 100644 index 0000000000..e8c69977c6 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/JoinSpaceResult.kt @@ -0,0 +1,27 @@ +/* + * Copyright 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.space + +sealed class JoinSpaceResult { + object Success : JoinSpaceResult() + data class Fail(val error: Throwable) : JoinSpaceResult() + + /** Success fully joined the space, but failed to join all or some of it's rooms */ + data class PartialSuccess(val failedRooms: Map) : JoinSpaceResult() + + fun isSuccess() = this is Success || this is PartialSuccess +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt new file mode 100644 index 0000000000..9dba4f90af --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt @@ -0,0 +1,50 @@ +/* + * 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.space + +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +interface Space { + + fun asRoom(): Room + + val spaceId: String + + suspend fun leave(reason: String? = null) + + /** + * A current snapshot of [RoomSummary] associated with the space + */ + fun spaceSummary(): RoomSummary? + + suspend fun addChildren(roomId: String, + viaServers: List, + order: String?, + autoJoin: Boolean = false, + suggested: Boolean? = false) + + suspend fun removeChildren(roomId: String) + + @Throws + suspend fun setChildrenOrder(roomId: String, order: String?) + + @Throws + suspend fun setChildrenAutoJoin(roomId: String, autoJoin: Boolean) + +// fun getChildren() : List +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/SpaceService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/SpaceService.kt new file mode 100644 index 0000000000..fedf38fe06 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/SpaceService.kt @@ -0,0 +1,87 @@ +/* + * 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.space + +import android.net.Uri +import androidx.lifecycle.LiveData +import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo +import org.matrix.android.sdk.internal.session.space.peeking.SpacePeekResult + +typealias SpaceSummaryQueryParams = RoomSummaryQueryParams + +interface SpaceService { + + /** + * Create a space asynchronously + * @return the spaceId of the created space + */ + suspend fun createSpace(params: CreateSpaceParams): String + + /** + * Just a shortcut for space creation for ease of use + */ + suspend fun createSpace(name: String, topic: String?, avatarUri: Uri?, isPublic: Boolean): String + + /** + * Get a space from a roomId + * @param spaceId the roomId to look for. + * @return a space with spaceId or null if room type is not space + */ + fun getSpace(spaceId: String): Space? + + /** + * Try to resolve (peek) rooms and subspace in this space. + * Use this call get preview of children of this space, particularly useful to get a + * preview of rooms that you did not join yet. + */ + suspend fun peekSpace(spaceId: String): SpacePeekResult + + /** + * Get's information of a space by querying the server + */ + suspend fun querySpaceChildren(spaceId: String, + suggestedOnly: Boolean? = null, + autoJoinedOnly: Boolean? = null): Pair> + + /** + * Get a live list of space summaries. This list is refreshed as soon as the data changes. + * @return the [LiveData] of List[SpaceSummary] + */ + fun getSpaceSummariesLive(queryParams: SpaceSummaryQueryParams): LiveData> + + fun getSpaceSummaries(spaceSummaryQueryParams: SpaceSummaryQueryParams): List + + suspend fun joinSpace(spaceIdOrAlias: String, + reason: String? = null, + viaServers: List = emptyList()): JoinSpaceResult + + suspend fun rejectInvite(spaceId: String, reason: String?) + +// fun getSpaceParentsOfRoom(roomId: String) : List + + /** + * Let this room declare that it has a parent. + * @param canonical true if it should be the main parent of this room + * In practice, well behaved rooms should only have one canonical parent, but given this is not enforced: + * if multiple are present the client should select the one with the lowest room ID, as determined via a lexicographic utf-8 ordering. + */ + suspend fun setSpaceParent(childRoomId: String, parentSpaceId: String, canonical: Boolean, viaServers: List) + + fun getRootSpaceSummaries(): List +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/model/SpaceChildContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/model/SpaceChildContent.kt new file mode 100644 index 0000000000..0c33cfa1e6 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/model/SpaceChildContent.kt @@ -0,0 +1,70 @@ +/* + * 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.space.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * "content": { + * "via": ["example.com"], + * "order": "abcd", + * "default": true + * } + */ +@JsonClass(generateAdapter = true) +data class SpaceChildContent( + /** + * Key which gives a list of candidate servers that can be used to join the room + * Children where via is not present are ignored. + */ + @Json(name = "via") val via: List? = null, + /** + * The order key is a string which is used to provide a default ordering of siblings in the room list. + * (Rooms are sorted based on a lexicographic ordering of order values; rooms with no order come last. + * orders which are not strings, or do not consist solely of ascii characters in the range \x20 (space) to \x7F (~), + * or consist of more than 50 characters, are forbidden and should be ignored if received.) + */ + @Json(name = "order") val order: String? = null, + /** + * The auto_join flag on a child listing allows a space admin to list the sub-spaces and rooms in that space which should + * be automatically joined by members of that space. + * (This is not a force-join, which are descoped for a future MSC; the user can subsequently part these room if they desire.) + */ + @Json(name = "auto_join") val autoJoin: Boolean? = false, + + /** + * If `suggested` is set to `true`, that indicates that the child should be advertised to + * members of the space by the client. This could be done by showing them eagerly + * in the room list. This is should be ignored if `auto_join` is set to `true`. + */ + @Json(name = "suggested") val suggested: Boolean? = false +) { + /** + * Orders which are not strings, or do not consist solely of ascii characters in the range \x20 (space) to \x7F (~), + * or consist of more than 50 characters, are forbidden and should be ignored if received.) + */ + fun validOrder(): String? { + return order + ?.takeIf { it.length <= 50 } + ?.takeIf { ORDER_VALID_CHAR_REGEX.matches(it) } + } + + companion object { + private val ORDER_VALID_CHAR_REGEX = "[ -~]+".toRegex() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/model/SpaceParentContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/model/SpaceParentContent.kt new file mode 100644 index 0000000000..871a494914 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/model/SpaceParentContent.kt @@ -0,0 +1,48 @@ +/* + * 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.space.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Rooms can claim parents via the m.space.parent state event. + * { + * "type": "m.space.parent", + * "state_key": "!space:example.com", + * "content": { + * "via": ["example.com"], + * "canonical": true, + * } + * } + */ +@JsonClass(generateAdapter = true) +data class SpaceParentContent( + /** + * Key which gives a list of candidate servers that can be used to join the parent. + * Parents where via is not present are ignored. + */ + @Json(name = "via") val via: List? = null, + /** + * Canonical determines whether this is the main parent for the space. + * When a user joins a room with a canonical parent, clients may switch to view the room + * in the context of that space, peeking into it in order to find other rooms and group them together. + * In practice, well behaved rooms should only have one canonical parent, but given this is not enforced: + * if multiple are present the client should select the one with the lowest room ID, as determined via a lexicographic utf-8 ordering. + */ + @Json(name = "canonical") val canonical: Boolean? = false +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt index db229a6453..7b2fae86ef 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt @@ -20,6 +20,7 @@ import org.matrix.android.sdk.BuildConfig import org.matrix.android.sdk.api.session.group.model.GroupSummary import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoom import org.matrix.android.sdk.api.session.room.sender.SenderInfo import org.matrix.android.sdk.api.session.user.model.User @@ -157,3 +158,5 @@ fun PublicRoom.toMatrixItem() = MatrixItem.RoomItem(roomId, name ?: getPrimaryAl fun RoomMemberSummary.toMatrixItem() = MatrixItem.UserItem(userId, displayName, avatarUrl) fun SenderInfo.toMatrixItem() = MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl) + +fun SpaceChildInfo.toMatrixItem() = MatrixItem.RoomItem(childRoomId, name, avatarUrl) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MimeTypes.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MimeTypes.kt index c74999b4ab..182b37f2ad 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MimeTypes.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MimeTypes.kt @@ -22,6 +22,7 @@ import org.matrix.android.sdk.api.extensions.orFalse object MimeTypes { const val Any: String = "*/*" const val OctetStream = "application/octet-stream" + const val Apk = "application/vnd.android.package-archive" const val Images = "image/*" 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 e26286ad2f..46256f4b81 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 @@ -144,16 +144,14 @@ internal class DefaultAuthenticationService @Inject constructor( } 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) - ) + // 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) } - } + pendingSessionData = PendingSessionData(alteredHomeServerConnectionConfig) + .also { data -> pendingSessionStore.savePendingSessionData(data) } it }, { @@ -307,12 +305,12 @@ internal class DefaultAuthenticationService @Inject constructor( val loginFlowResponse = executeRequest(null) { authAPI.getLoginFlows() } - return LoginFlowResult.Success( - loginFlowResponse.flows.orEmpty().mapNotNull { it.type }, - loginFlowResponse.flows.orEmpty().firstOrNull { it.type == LoginFlowTypes.SSO }?.ssoIdentityProvider, - versions.isLoginAndRegistrationSupportedBySdk(), - homeServerUrl, - !versions.isSupportedBySdk() + return LoginFlowResult( + supportedLoginTypes = loginFlowResponse.flows.orEmpty().mapNotNull { it.type }, + ssoIdentityProviders = loginFlowResponse.flows.orEmpty().firstOrNull { it.type == LoginFlowTypes.SSO }?.ssoIdentityProvider, + isLoginAndRegistrationSupported = versions.isLoginAndRegistrationSupportedBySdk(), + homeServerUrl = homeServerUrl, + isOutdatedHomeserver = !versions.isSupportedBySdk() ) } 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 4a3d53a8fc..4a156e74cd 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 @@ -66,8 +66,8 @@ internal class DefaultRegistrationWizard( return performRegistrationRequest(params) } - override suspend fun createAccount(userName: String, - password: String, + override suspend fun createAccount(userName: String?, + password: String?, initialDeviceDisplayName: String?): RegistrationResult { val params = RegistrationParams( username = userName, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/CryptoDeviceInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/CryptoDeviceInfo.kt index 7eebbd9b2c..4004294d97 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/CryptoDeviceInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/CryptoDeviceInfo.kt @@ -44,7 +44,7 @@ data class CryptoDeviceInfo( */ fun fingerprint(): String? { return keys - ?.takeIf { !deviceId.isBlank() } + ?.takeIf { deviceId.isNotBlank() } ?.get("ed25519:$deviceId") } @@ -53,7 +53,7 @@ data class CryptoDeviceInfo( */ fun identityKey(): String? { return keys - ?.takeIf { !deviceId.isBlank() } + ?.takeIf { deviceId.isNotBlank() } ?.get("curve25519:$deviceId") } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/MXDeviceInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/MXDeviceInfo.kt index 3c651c27a0..00b8bde5d9 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/MXDeviceInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/MXDeviceInfo.kt @@ -103,7 +103,7 @@ data class MXDeviceInfo( */ fun fingerprint(): String? { return keys - ?.takeIf { !deviceId.isBlank() } + ?.takeIf { deviceId.isNotBlank() } ?.get("ed25519:$deviceId") } @@ -112,7 +112,7 @@ data class MXDeviceInfo( */ fun identityKey(): String? { return keys - ?.takeIf { !deviceId.isBlank() } + ?.takeIf { deviceId.isNotBlank() } ?.get("curve25519:$deviceId") } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendVerificationMessageTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendVerificationMessageTask.kt index d8b9d3cd86..7fa48c3da1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendVerificationMessageTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendVerificationMessageTask.kt @@ -23,6 +23,7 @@ import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.session.room.RoomAPI import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.toMatrixErrorStr import javax.inject.Inject internal interface SendVerificationMessageTask : Task { @@ -55,7 +56,7 @@ internal class DefaultSendVerificationMessageTask @Inject constructor( localEchoRepository.updateSendState(localId, event.roomId, SendState.SENT) return response.eventId } catch (e: Throwable) { - localEchoRepository.updateSendState(localId, event.roomId, SendState.UNDELIVERED) + localEchoRepository.updateSendState(localId, event.roomId, SendState.UNDELIVERED, e.toMatrixErrorStr()) throw e } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/DatabaseCleaner.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/DatabaseCleaner.kt index f11ecc5d75..ee58880eb8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/DatabaseCleaner.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/DatabaseCleaner.kt @@ -20,6 +20,7 @@ import io.realm.Realm import io.realm.RealmConfiguration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.internal.database.helper.nextDisplayIndex import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.ChunkEntityFields @@ -29,7 +30,7 @@ import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields import org.matrix.android.sdk.internal.database.model.deleteOnCascade import org.matrix.android.sdk.internal.di.SessionDatabase -import org.matrix.android.sdk.internal.session.SessionLifecycleObserver +import org.matrix.android.sdk.api.session.SessionLifecycleObserver import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection import org.matrix.android.sdk.internal.task.TaskExecutor import timber.log.Timber @@ -47,7 +48,7 @@ private const val MIN_NUMBER_OF_EVENTS_BY_CHUNK = 300 internal class DatabaseCleaner @Inject constructor(@SessionDatabase private val realmConfiguration: RealmConfiguration, private val taskExecutor: TaskExecutor) : SessionLifecycleObserver { - override fun onSessionStarted() { + override fun onSessionStarted(session: Session) { taskExecutor.executorScope.launch(Dispatchers.Default) { awaitTransaction(realmConfiguration) { realm -> val allRooms = realm.where(RoomEntity::class.java).findAll() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmLiveEntityObserver.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmLiveEntityObserver.kt index 2a0cd963b2..c602ed7075 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmLiveEntityObserver.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmLiveEntityObserver.kt @@ -17,7 +17,7 @@ package org.matrix.android.sdk.internal.database import com.zhuinden.monarchy.Monarchy -import org.matrix.android.sdk.internal.session.SessionLifecycleObserver +import org.matrix.android.sdk.api.session.SessionLifecycleObserver import org.matrix.android.sdk.internal.util.createBackgroundHandler import io.realm.Realm import io.realm.RealmChangeListener @@ -28,6 +28,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.android.asCoroutineDispatcher import kotlinx.coroutines.cancelChildren +import org.matrix.android.sdk.api.session.Session import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -46,7 +47,7 @@ internal abstract class RealmLiveEntityObserver(protected val r private val backgroundRealm = AtomicReference() private lateinit var results: AtomicReference> - override fun onSessionStarted() { + override fun onSessionStarted(session: Session) { if (isStarted.compareAndSet(false, true)) { BACKGROUND_HANDLER.post { val realm = Realm.getInstance(realmConfiguration) @@ -58,7 +59,7 @@ internal abstract class RealmLiveEntityObserver(protected val r } } - override fun onSessionStopped() { + override fun onSessionStopped(session: Session) { if (isStarted.compareAndSet(true, false)) { BACKGROUND_HANDLER.post { results.getAndSet(null).removeAllChangeListeners() @@ -70,7 +71,7 @@ internal abstract class RealmLiveEntityObserver(protected val r } } - override fun onClearCache() { + override fun onClearCache(session: Session) { observerScope.coroutineContext.cancelChildren() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionProvider.kt index f8d5d323a5..52fbabb49f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionProvider.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionProvider.kt @@ -20,8 +20,9 @@ import android.os.Looper import androidx.annotation.MainThread import com.zhuinden.monarchy.Monarchy import io.realm.Realm +import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.internal.di.SessionDatabase -import org.matrix.android.sdk.internal.session.SessionLifecycleObserver +import org.matrix.android.sdk.api.session.SessionLifecycleObserver import org.matrix.android.sdk.internal.session.SessionScope import javax.inject.Inject import kotlin.concurrent.getOrSet @@ -44,14 +45,14 @@ internal class RealmSessionProvider @Inject constructor(@SessionDatabase private } @MainThread - override fun onSessionStarted() { + override fun onSessionStarted(session: Session) { realmThreadLocal.getOrSet { Realm.getInstance(monarchy.realmConfiguration) } } @MainThread - override fun onSessionStopped() { + override fun onSessionStopped(session: Session) { realmThreadLocal.get()?.close() realmThreadLocal.remove() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index 1daae906f2..05213b40e5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt @@ -19,7 +19,10 @@ package org.matrix.android.sdk.internal.database import io.realm.DynamicRealm import io.realm.FieldAttribute import io.realm.RealmMigration +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent import org.matrix.android.sdk.api.session.room.model.tag.RoomTag +import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntityFields import org.matrix.android.sdk.internal.database.model.EditionOfEventFields import org.matrix.android.sdk.internal.database.model.EventEntityFields @@ -31,13 +34,16 @@ import org.matrix.android.sdk.internal.database.model.RoomMembersLoadStatusType import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields import org.matrix.android.sdk.internal.database.model.RoomTagEntityFields import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields +import org.matrix.android.sdk.internal.database.model.SpaceChildSummaryEntityFields +import org.matrix.android.sdk.internal.database.model.SpaceParentSummaryEntityFields +import org.matrix.android.sdk.internal.di.MoshiProvider import timber.log.Timber import javax.inject.Inject class RealmSessionStoreMigration @Inject constructor() : RealmMigration { companion object { - const val SESSION_STORE_SCHEMA_VERSION = 9L + const val SESSION_STORE_SCHEMA_VERSION = 11L } override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { @@ -52,6 +58,8 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration { if (oldVersion <= 6) migrateTo7(realm) if (oldVersion <= 7) migrateTo8(realm) if (oldVersion <= 8) migrateTo9(realm) + if (oldVersion <= 9) migrateTo10(realm) + if (oldVersion <= 10) migrateTo11(realm) } private fun migrateTo1(realm: DynamicRealm) { @@ -156,7 +164,7 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration { ?.addRealmListField(EditAggregatedSummaryEntityFields.EDITIONS.`$`, editionOfEventSchema) } - fun migrateTo9(realm: DynamicRealm) { + private fun migrateTo9(realm: DynamicRealm) { Timber.d("Step 8 -> 9") realm.schema.get("RoomSummaryEntity") @@ -174,7 +182,6 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration { ?.addIndex(RoomSummaryEntityFields.IS_SERVER_NOTICE) ?.transform { obj -> - val isFavorite = obj.getList(RoomSummaryEntityFields.TAGS.`$`).any { it.getString(RoomTagEntityFields.TAG_NAME) == RoomTag.ROOM_TAG_FAVOURITE } @@ -194,4 +201,50 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration { } } } + + private fun migrateTo10(realm: DynamicRealm) { + Timber.d("Step 9 -> 10") + realm.schema.create("SpaceChildSummaryEntity") + ?.addField(SpaceChildSummaryEntityFields.ORDER, String::class.java) + ?.addField(SpaceChildSummaryEntityFields.CHILD_ROOM_ID, String::class.java) + ?.addField(SpaceChildSummaryEntityFields.AUTO_JOIN, Boolean::class.java) + ?.setNullable(SpaceChildSummaryEntityFields.AUTO_JOIN, true) + ?.addRealmObjectField(SpaceChildSummaryEntityFields.CHILD_SUMMARY_ENTITY.`$`, realm.schema.get("RoomSummaryEntity")!!) + ?.addRealmListField(SpaceChildSummaryEntityFields.VIA_SERVERS.`$`, String::class.java) + + realm.schema.create("SpaceParentSummaryEntity") + ?.addField(SpaceParentSummaryEntityFields.PARENT_ROOM_ID, String::class.java) + ?.addField(SpaceParentSummaryEntityFields.CANONICAL, Boolean::class.java) + ?.setNullable(SpaceParentSummaryEntityFields.CANONICAL, true) + ?.addRealmObjectField(SpaceParentSummaryEntityFields.PARENT_SUMMARY_ENTITY.`$`, realm.schema.get("RoomSummaryEntity")!!) + ?.addRealmListField(SpaceParentSummaryEntityFields.VIA_SERVERS.`$`, String::class.java) + + val creationContentAdapter = MoshiProvider.providesMoshi().adapter(RoomCreateContent::class.java) + realm.schema.get("RoomSummaryEntity") + ?.addField(RoomSummaryEntityFields.ROOM_TYPE, String::class.java) + ?.addField(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, String::class.java) + ?.addField(RoomSummaryEntityFields.GROUP_IDS, String::class.java) + ?.transform { obj -> + + val creationEvent = realm.where("CurrentStateEventEntity") + .equalTo(CurrentStateEventEntityFields.ROOM_ID, obj.getString(RoomSummaryEntityFields.ROOM_ID)) + .equalTo(CurrentStateEventEntityFields.TYPE, EventType.STATE_ROOM_CREATE) + .findFirst() + + val roomType = creationEvent?.getObject(CurrentStateEventEntityFields.ROOT.`$`) + ?.getString(EventEntityFields.CONTENT)?.let { + creationContentAdapter.fromJson(it)?.type + } + + obj.setString(RoomSummaryEntityFields.ROOM_TYPE, roomType) + } + ?.addRealmListField(RoomSummaryEntityFields.PARENTS.`$`, realm.schema.get("SpaceParentSummaryEntity")!!) + ?.addRealmListField(RoomSummaryEntityFields.CHILDREN.`$`, realm.schema.get("SpaceChildSummaryEntity")!!) + } + + private fun migrateTo11(realm: DynamicRealm) { + Timber.d("Step 10 -> 11") + realm.schema.get("EventEntity") + ?.addField(EventEntityFields.SEND_STATE_DETAILS, String::class.java) + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt index a4a2fadd21..613b38e340 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt @@ -80,6 +80,7 @@ internal object EventMapper { ).also { it.ageLocalTs = eventEntity.ageLocalTs it.sendState = eventEntity.sendState + it.sendStateDetails = eventEntity.sendStateDetails eventEntity.decryptionResultJson?.let { json -> try { it.mxDecryptionResult = MoshiProvider.providesMoshi().adapter(OlmDecryptionResult::class.java).fromJson(json) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt index 6dc70b60fc..92aff0a140 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt @@ -17,6 +17,8 @@ package org.matrix.android.sdk.internal.database.mapper import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo +import org.matrix.android.sdk.api.session.room.model.SpaceParentInfo import org.matrix.android.sdk.api.session.room.model.tag.RoomTag import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity import org.matrix.android.sdk.internal.session.typing.DefaultTypingUsersTracker @@ -43,6 +45,7 @@ internal class RoomSummaryMapper @Inject constructor(private val timelineEventMa topic = roomSummaryEntity.topic ?: "", avatarUrl = roomSummaryEntity.avatarUrl ?: "", isDirect = roomSummaryEntity.isDirect, + directUserId = roomSummaryEntity.directUserId, latestPreviewableEvent = latestEvent, joinedMembersCount = roomSummaryEntity.joinedMembersCount, invitedMembersCount = roomSummaryEntity.invitedMembersCount, @@ -63,7 +66,32 @@ internal class RoomSummaryMapper @Inject constructor(private val timelineEventMa breadcrumbsIndex = roomSummaryEntity.breadcrumbsIndex, roomEncryptionTrustLevel = roomSummaryEntity.roomEncryptionTrustLevel, inviterId = roomSummaryEntity.inviterId, - hasFailedSending = roomSummaryEntity.hasFailedSending + hasFailedSending = roomSummaryEntity.hasFailedSending, + roomType = roomSummaryEntity.roomType, + spaceParents = roomSummaryEntity.parents.map { relationInfoEntity -> + SpaceParentInfo( + parentId = relationInfoEntity.parentRoomId, + roomSummary = relationInfoEntity.parentSummaryEntity?.let { map(it) }, + canonical = relationInfoEntity.canonical ?: false, + viaServers = relationInfoEntity.viaServers.toList() + ) + }, + spaceChildren = roomSummaryEntity.children.map { + SpaceChildInfo( + childRoomId = it.childRoomId ?: "", + isKnown = it.childSummaryEntity != null, + roomType = it.childSummaryEntity?.roomType, + name = it.childSummaryEntity?.name, + topic = it.childSummaryEntity?.topic, + avatarUrl = it.childSummaryEntity?.avatarUrl, + activeMemberCount = it.childSummaryEntity?.joinedMembersCount, + order = it.order, + autoJoin = it.autoJoin ?: false, + viaServers = it.viaServers.toList(), + parentRoomId = roomSummaryEntity.roomId + ) + }, + flattenParentIds = roomSummaryEntity.flattenParentIds?.split("|") ?: emptyList() ) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt index fe59f4fceb..c9edbcd889 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt @@ -32,6 +32,8 @@ internal open class EventEntity(@Index var eventId: String = "", @Index var stateKey: String? = null, var originServerTs: Long? = null, @Index var sender: String? = null, + // Can contain a serialized MatrixError + var sendStateDetails: String? = null, var age: Long? = 0, var unsignedData: String? = null, var redacts: String? = null, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt index 3ff2532604..58297776f0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt @@ -43,6 +43,5 @@ internal open class RoomEntity(@PrimaryKey var roomId: String = "", set(value) { membersLoadStatusStr = value.name } - companion object } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt index c87ac15a78..4f47032c4d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt @@ -27,7 +27,10 @@ import org.matrix.android.sdk.api.session.room.model.VersioningState import org.matrix.android.sdk.api.session.room.model.tag.RoomTag internal open class RoomSummaryEntity( - @PrimaryKey var roomId: String = "" + @PrimaryKey var roomId: String = "", + var roomType: String? = null, + var parents: RealmList = RealmList(), + var children: RealmList = RealmList() ) : RealmObject() { var displayName: String? = "" @@ -204,6 +207,16 @@ internal open class RoomSummaryEntity( if (value != field) field = value } + var flattenParentIds: String? = null + set(value) { + if (value != field) field = value + } + + var groupIds: String? = null + set(value) { + if (value != field) field = value + } + @Index private var membershipStr: String = Membership.NONE.name @@ -244,6 +257,5 @@ internal open class RoomSummaryEntity( roomEncryptionTrustLevelStr = value?.name } } - companion object } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt index 6e6096cf8a..72ae512fa5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt @@ -61,6 +61,8 @@ import io.realm.annotations.RealmModule CurrentStateEventEntity::class, UserAccountDataEntity::class, ScalarTokenEntity::class, - WellknownIntegrationManagerConfigEntity::class + WellknownIntegrationManagerConfigEntity::class, + SpaceChildSummaryEntity::class, + SpaceParentSummaryEntity::class ]) internal class SessionRealmModule diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SpaceChildSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SpaceChildSummaryEntity.kt new file mode 100644 index 0000000000..982c9ece6a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SpaceChildSummaryEntity.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.database.model + +import io.realm.RealmList +import io.realm.RealmObject + +/** + * Decorates room summary with space related information. + */ +internal open class SpaceChildSummaryEntity( +// var isSpace: Boolean = false, + + var order: String? = null, + + var autoJoin: Boolean? = null, + + var childRoomId: String? = null, + // Link to the actual space summary if it is known locally + var childSummaryEntity: RoomSummaryEntity? = null, + + var viaServers: RealmList = RealmList() +// var owner: RoomSummaryEntity? = null, + +// var level: Int = 0 + +) : RealmObject() { + + companion object +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SpaceParentSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SpaceParentSummaryEntity.kt new file mode 100644 index 0000000000..30517717f4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SpaceParentSummaryEntity.kt @@ -0,0 +1,45 @@ +/* + * 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.database.model + +import io.realm.RealmList +import io.realm.RealmObject + +/** + * Decorates room summary with space related information. + */ +internal open class SpaceParentSummaryEntity( + /** + * Determines whether this is the main parent for the space + * When a user joins a room with a canonical parent, clients may switch to view the room in the context of that space, + * peeking into it in order to find other rooms and group them together. + * In practice, well behaved rooms should only have one canonical parent, but given this is not enforced: + * if multiple are present the client should select the one with the lowest room ID, + * as determined via a lexicographic utf-8 ordering. + */ + var canonical: Boolean? = null, + + var parentRoomId: String? = null, + // Link to the actual space summary if it is known locally + var parentSummaryEntity: RoomSummaryEntity? = null, + + var viaServers: RealmList = RealmList() + +) : RealmObject() { + + companion object +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/Request.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/Request.kt index 0246bae024..e045cebd3e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/Request.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/Request.kt @@ -88,8 +88,8 @@ internal suspend inline fun executeRequest(globalErrorReceiver: GlobalErr throw when (exception) { is IOException -> Failure.NetworkConnection(exception) is Failure.ServerError, - is Failure.OtherServerError -> exception - is CancellationException -> Failure.Cancelled(exception) + is Failure.OtherServerError, + is CancellationException -> exception else -> Failure.Unknown(exception) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/RetrofitExtensions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/RetrofitExtensions.kt index 7132b4ff7a..2116063626 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/RetrofitExtensions.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/RetrofitExtensions.kt @@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.internal.di.MoshiProvider import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.ResponseBody +import org.matrix.android.sdk.api.extensions.orFalse import retrofit2.HttpException import retrofit2.Response import timber.log.Timber @@ -91,7 +92,7 @@ private fun toFailure(errorBody: ResponseBody?, httpCode: Int, globalErrorReceiv } else if (httpCode == HttpURLConnection.HTTP_UNAUTHORIZED /* 401 */ && matrixError.code == MatrixError.M_UNKNOWN_TOKEN) { // Also send this error to the globalErrorReceiver, for a global management - globalErrorReceiver?.handleGlobalError(GlobalError.InvalidToken(matrixError.isSoftLogout)) + globalErrorReceiver?.handleGlobalError(GlobalError.InvalidToken(matrixError.isSoftLogout.orFalse())) } return Failure.ServerError(matrixError, httpCode) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryRoomOrderProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryRoomOrderProcessor.kt new file mode 100644 index 0000000000..7a06c2129c --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryRoomOrderProcessor.kt @@ -0,0 +1,37 @@ +/* + * Copyright 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.query + +import io.realm.RealmQuery +import io.realm.Sort +import org.matrix.android.sdk.api.session.room.RoomSortOrder +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields + +internal fun RealmQuery.process(sortOrder: RoomSortOrder): RealmQuery { + when (sortOrder) { + RoomSortOrder.NAME -> { + sort(RoomSummaryEntityFields.DISPLAY_NAME, Sort.ASCENDING) + } + RoomSortOrder.ACTIVITY -> { + sort(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Sort.DESCENDING) + } + RoomSortOrder.NONE -> { + } + } + return this +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryStringValueProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryStringValueProcessor.kt index 899024458a..fd33682231 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryStringValueProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryStringValueProcessor.kt @@ -16,10 +16,10 @@ package org.matrix.android.sdk.internal.query -import org.matrix.android.sdk.api.query.QueryStringValue import io.realm.Case import io.realm.RealmObject import io.realm.RealmQuery +import org.matrix.android.sdk.api.query.QueryStringValue import timber.log.Timber fun RealmQuery.process(field: String, queryStringValue: QueryStringValue): RealmQuery { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultFileService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultFileService.kt index d05ee48c1b..891858d857 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultFileService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultFileService.kt @@ -219,7 +219,7 @@ internal class DefaultFileService @Inject constructor( fileName: String, mimeType: String?, elementToDecrypt: ElementToDecrypt?): Boolean { - return fileState(mxcUrl, fileName, mimeType, elementToDecrypt) == FileService.FileState.IN_CACHE + return fileState(mxcUrl, fileName, mimeType, elementToDecrypt) is FileService.FileState.InCache } internal data class CachedFiles( @@ -256,12 +256,17 @@ internal class DefaultFileService @Inject constructor( fileName: String, mimeType: String?, elementToDecrypt: ElementToDecrypt?): FileService.FileState { - mxcUrl ?: return FileService.FileState.UNKNOWN - if (getFiles(mxcUrl, fileName, mimeType, elementToDecrypt != null).file.exists()) return FileService.FileState.IN_CACHE + mxcUrl ?: return FileService.FileState.Unknown + val files = getFiles(mxcUrl, fileName, mimeType, elementToDecrypt != null) + if (files.file.exists()) { + return FileService.FileState.InCache( + decryptedFileInCache = files.getClearFile().exists() + ) + } val isDownloading = synchronized(ongoing) { ongoing[mxcUrl] != null } - return if (isDownloading) FileService.FileState.DOWNLOADING else FileService.FileState.UNKNOWN + return if (isDownloading) FileService.FileState.Downloading else FileService.FileState.Unknown } /** 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 821a9cba8c..53e13c14ec 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 @@ -19,13 +19,15 @@ package org.matrix.android.sdk.internal.session import androidx.annotation.MainThread import dagger.Lazy import io.realm.RealmConfiguration +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.matrix.android.sdk.api.auth.data.SessionParams import org.matrix.android.sdk.api.failure.GlobalError import org.matrix.android.sdk.api.federation.FederationService import org.matrix.android.sdk.api.pushrules.PushRuleService -import org.matrix.android.sdk.api.session.initsync.InitialSyncProgressService import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.SessionLifecycleObserver import org.matrix.android.sdk.api.session.account.AccountService import org.matrix.android.sdk.api.session.accountdata.AccountDataService import org.matrix.android.sdk.api.session.cache.CacheService @@ -38,6 +40,7 @@ import org.matrix.android.sdk.api.session.file.ContentDownloadStateTracker import org.matrix.android.sdk.api.session.file.FileService import org.matrix.android.sdk.api.session.group.GroupService import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService +import org.matrix.android.sdk.api.session.initsync.InitialSyncProgressService import org.matrix.android.sdk.api.session.integrationmanager.IntegrationManagerService import org.matrix.android.sdk.api.session.media.MediaService import org.matrix.android.sdk.api.session.permalinks.PermalinkService @@ -49,6 +52,7 @@ import org.matrix.android.sdk.api.session.search.SearchService import org.matrix.android.sdk.api.session.securestorage.SecureStorageService import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageService import org.matrix.android.sdk.api.session.signout.SignOutService +import org.matrix.android.sdk.api.session.space.SpaceService 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 @@ -120,6 +124,7 @@ internal class DefaultSession @Inject constructor( private val integrationManagerService: IntegrationManagerService, private val thirdPartyService: Lazy, private val callSignalingService: Lazy, + private val spaceService: Lazy, @UnauthenticatedWithCertificate private val unauthenticatedWithCertificateOkHttpClient: Lazy ) : Session, @@ -159,7 +164,12 @@ internal class DefaultSession @Inject constructor( isOpen = true cryptoService.get().ensureDevice() uiHandler.post { - lifecycleObservers.forEach { it.onSessionStarted() } + lifecycleObservers.forEach { + it.onSessionStarted(this) + } + sessionListeners.dispatch { + it.onSessionStarted(this) + } } globalErrorHandler.listener = this } @@ -200,7 +210,10 @@ internal class DefaultSession @Inject constructor( stopSync() // timelineEventDecryptor.destroy() uiHandler.post { - lifecycleObservers.forEach { it.onSessionStopped() } + lifecycleObservers.forEach { it.onSessionStopped(this) } + sessionListeners.dispatch { + it.onSessionStopped(this) + } } cryptoService.get().close() isOpen = false @@ -225,14 +238,23 @@ internal class DefaultSession @Inject constructor( stopSync() stopAnyBackgroundSync() uiHandler.post { - lifecycleObservers.forEach { it.onClearCache() } + lifecycleObservers.forEach { + it.onClearCache(this) + } + sessionListeners.dispatch { + it.onClearCache(this) + } + } + withContext(NonCancellable) { + cacheService.get().clearCache() } - cacheService.get().clearCache() workManagerProvider.cancelAllWorks() } override fun onGlobalError(globalError: GlobalError) { - sessionListeners.dispatchGlobalError(globalError) + sessionListeners.dispatch { + it.onGlobalError(this, globalError) + } } override fun contentUrlResolver() = contentUrlResolver @@ -265,6 +287,8 @@ internal class DefaultSession @Inject constructor( override fun thirdPartyService(): ThirdPartyService = thirdPartyService.get() + override fun spaceService(): SpaceService = spaceService.get() + override fun getOkHttpClient(): OkHttpClient { return unauthenticatedWithCertificateOkHttpClient.get() } 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 7e1e3d0f70..541c877b1d 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 @@ -52,6 +52,7 @@ import org.matrix.android.sdk.internal.session.room.send.RedactEventWorker import org.matrix.android.sdk.internal.session.room.send.SendEventWorker import org.matrix.android.sdk.internal.session.search.SearchModule import org.matrix.android.sdk.internal.session.signout.SignOutModule +import org.matrix.android.sdk.internal.session.space.SpaceModule import org.matrix.android.sdk.internal.session.sync.SyncModule import org.matrix.android.sdk.internal.session.sync.SyncTask import org.matrix.android.sdk.internal.session.sync.SyncTokenStore @@ -91,7 +92,8 @@ import org.matrix.android.sdk.internal.util.MatrixCoroutineDispatchers FederationModule::class, CallModule::class, SearchModule::class, - ThirdPartyModule::class + ThirdPartyModule::class, + SpaceModule::class ] ) @SessionScope diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionCoroutineScopeHolder.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionCoroutineScopeHolder.kt new file mode 100644 index 0000000000..82a8f79fd5 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionCoroutineScopeHolder.kt @@ -0,0 +1,43 @@ +/* + * 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 + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import javax.inject.Inject +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.SessionLifecycleObserver + +@SessionScope +internal class SessionCoroutineScopeHolder @Inject constructor(): SessionLifecycleObserver { + + val scope: CoroutineScope = CoroutineScope(SupervisorJob()) + + override fun onSessionStopped(session: Session) { + scope.cancelChildren() + } + + override fun onClearCache(session: Session) { + scope.cancelChildren() + } + + private fun CoroutineScope.cancelChildren() { + coroutineContext.cancelChildren(CancellationException("Closing session")) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionListeners.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionListeners.kt index 64f2d249f3..563ff4ada3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionListeners.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionListeners.kt @@ -16,7 +16,6 @@ package org.matrix.android.sdk.internal.session -import org.matrix.android.sdk.api.failure.GlobalError import org.matrix.android.sdk.api.session.Session import javax.inject.Inject @@ -36,10 +35,10 @@ internal class SessionListeners @Inject constructor() { } } - fun dispatchGlobalError(globalError: GlobalError) { + fun dispatch(block: (Session.Listener) -> Unit) { synchronized(listeners) { listeners.forEach { - it.onGlobalError(globalError) + block(it) } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt index e61e4ecd89..63423b72c6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt @@ -33,6 +33,7 @@ import org.matrix.android.sdk.api.auth.data.SessionParams import org.matrix.android.sdk.api.auth.data.sessionId import org.matrix.android.sdk.api.crypto.MXCryptoConfig import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.SessionLifecycleObserver import org.matrix.android.sdk.api.session.accountdata.AccountDataService import org.matrix.android.sdk.api.session.events.EventService import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService @@ -343,6 +344,10 @@ internal abstract class SessionModule { @IntoSet abstract fun bindRealmSessionProvider(provider: RealmSessionProvider): SessionLifecycleObserver + @Binds + @IntoSet + abstract fun bindSessionCoroutineScopeHolder(holder: SessionCoroutineScopeHolder): SessionLifecycleObserver + @Binds @IntoSet abstract fun bindEventSenderProcessorAsSessionLifecycleObserver(processor: EventSenderProcessorCoroutine): SessionLifecycleObserver diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/DefaultContentUploadStateTracker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/DefaultContentUploadStateTracker.kt index 754f12bd68..17e0a930c1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/DefaultContentUploadStateTracker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/DefaultContentUploadStateTracker.kt @@ -78,6 +78,16 @@ internal class DefaultContentUploadStateTracker @Inject constructor() : ContentU updateState(key, progressData) } + internal fun setCompressingImage(key: String) { + val progressData = ContentUploadStateTracker.State.CompressingImage + updateState(key, progressData) + } + + internal fun setCompressingVideo(key: String, percent: Float) { + val progressData = ContentUploadStateTracker.State.CompressingVideo(percent) + updateState(key, progressData) + } + internal fun setProgress(key: String, current: Long, total: Long) { val progressData = ContentUploadStateTracker.State.Uploading(current, total) updateState(key, progressData) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt index 8fa595db30..6bb43d599c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt @@ -31,22 +31,28 @@ import okhttp3.RequestBody.Companion.toRequestBody import okio.BufferedSink import okio.source import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.session.content.ContentUrlResolver +import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities import org.matrix.android.sdk.internal.di.Authenticated import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.ProgressRequestBody import org.matrix.android.sdk.internal.network.awaitResponse import org.matrix.android.sdk.internal.network.toFailure +import org.matrix.android.sdk.internal.session.homeserver.DefaultHomeServerCapabilitiesService +import org.matrix.android.sdk.internal.util.TemporaryFileCreator import java.io.File import java.io.FileNotFoundException import java.io.IOException -import java.util.UUID import javax.inject.Inject internal class FileUploader @Inject constructor(@Authenticated private val okHttpClient: OkHttpClient, private val globalErrorReceiver: GlobalErrorReceiver, + private val homeServerCapabilitiesService: DefaultHomeServerCapabilitiesService, private val context: Context, + private val temporaryFileCreator: TemporaryFileCreator, contentUrlResolver: ContentUrlResolver, moshi: Moshi) { @@ -57,6 +63,21 @@ internal class FileUploader @Inject constructor(@Authenticated filename: String?, mimeType: String?, progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse { + // Check size limit + val maxUploadFileSize = homeServerCapabilitiesService.getHomeServerCapabilities().maxUploadFileSize + + if (maxUploadFileSize != HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN + && file.length() > maxUploadFileSize) { + // Known limitation and file too big for the server, save the pain to upload it + throw Failure.ServerError( + error = MatrixError( + code = MatrixError.M_TOO_LARGE, + message = "Cannot upload files larger than ${maxUploadFileSize / 1048576L}mb" + ), + httpCode = 413 + ) + } + val uploadBody = object : RequestBody() { override fun contentLength() = file.length() @@ -90,7 +111,7 @@ internal class FileUploader @Inject constructor(@Authenticated val inputStream = withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) } ?: throw FileNotFoundException() - val workingFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir) + val workingFile = temporaryFileCreator.create() workingFile.outputStream().use { inputStream.copyTo(it) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageCompressor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageCompressor.kt index 1d6cd61060..9b01d0a00e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageCompressor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ImageCompressor.kt @@ -16,19 +16,20 @@ package org.matrix.android.sdk.internal.session.content -import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import androidx.exifinterface.media.ExifInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.matrix.android.sdk.internal.util.TemporaryFileCreator import timber.log.Timber import java.io.File -import java.util.UUID import javax.inject.Inject -internal class ImageCompressor @Inject constructor(private val context: Context) { +internal class ImageCompressor @Inject constructor( + private val temporaryFileCreator: TemporaryFileCreator +) { suspend fun compress( imageFile: File, desiredWidth: Int, @@ -45,7 +46,7 @@ internal class ImageCompressor @Inject constructor(private val context: Context) } } ?: return@withContext imageFile - val destinationFile = createDestinationFile() + val destinationFile = temporaryFileCreator.create() runCatching { destinationFile.outputStream().use { @@ -53,7 +54,7 @@ internal class ImageCompressor @Inject constructor(private val context: Context) } } - return@withContext destinationFile + destinationFile } } @@ -64,16 +65,16 @@ internal class ImageCompressor @Inject constructor(private val context: Context) val orientation = exifInfo.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) val matrix = Matrix() when (orientation) { - ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) - ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) - ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f) - ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f) - ExifInterface.ORIENTATION_TRANSPOSE -> { + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f) + ExifInterface.ORIENTATION_TRANSPOSE -> { matrix.preRotate(-90f) matrix.preScale(-1f, 1f) } - ExifInterface.ORIENTATION_TRANSVERSE -> { + ExifInterface.ORIENTATION_TRANSVERSE -> { matrix.preRotate(90f) matrix.preScale(-1f, 1f) } @@ -116,8 +117,4 @@ internal class ImageCompressor @Inject constructor(private val context: Context) null } } - - private fun createDestinationFile(): File { - return File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir) - } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt index 3b727690bf..d5c3deeec6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt @@ -18,9 +18,12 @@ package org.matrix.android.sdk.internal.session.content import android.content.Context import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import androidx.core.net.toUri import androidx.work.WorkerParameters import com.squareup.moshi.JsonClass import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.listeners.ProgressListener import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel @@ -41,12 +44,13 @@ import org.matrix.android.sdk.internal.session.room.send.CancelSendTracker import org.matrix.android.sdk.internal.session.room.send.LocalEchoIdentifiers import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository import org.matrix.android.sdk.internal.session.room.send.MultipleEventSendingDispatcherWorker +import org.matrix.android.sdk.internal.util.TemporaryFileCreator +import org.matrix.android.sdk.internal.util.toMatrixErrorStr import org.matrix.android.sdk.internal.worker.SessionSafeCoroutineWorker import org.matrix.android.sdk.internal.worker.SessionWorkerParams import org.matrix.android.sdk.internal.worker.WorkerParamsFactory import timber.log.Timber import java.io.File -import java.util.UUID import javax.inject.Inject private data class NewAttachmentAttributes( @@ -77,7 +81,9 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter @Inject lateinit var fileService: DefaultFileService @Inject lateinit var cancelSendTracker: CancelSendTracker @Inject lateinit var imageCompressor: ImageCompressor + @Inject lateinit var videoCompressor: VideoCompressor @Inject lateinit var localEchoRepository: LocalEchoRepository + @Inject lateinit var temporaryFileCreator: TemporaryFileCreator override fun injectWith(injector: SessionComponent) { injector.inject(this) @@ -109,7 +115,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter val attachment = params.attachment val filesToDelete = mutableListOf() - try { + return try { val inputStream = context.contentResolver.openInputStream(attachment.queryUri) ?: return Result.success( WorkerParamsFactory.toData( @@ -120,7 +126,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter ) // always use a temporary file, it guaranties that we could report progress on upload and simplifies the flows - val workingFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir) + val workingFile = temporaryFileCreator.create() .also { filesToDelete.add(it) } workingFile.outputStream().use { outputStream -> inputStream.use { inputStream -> @@ -128,8 +134,6 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter } } - val uploadThumbnailResult = dealWithThumbnail(params) - val progressListener = object : ProgressRequestBody.Listener { override fun onProgress(current: Long, total: Long) { notifyTracker(params) { @@ -144,7 +148,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null - return try { + try { val fileToUpload: File var newAttachmentAttributes = NewAttachmentAttributes( params.attachment.width?.toInt(), @@ -156,6 +160,8 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter // Do not compress gif && attachment.mimeType != MimeTypes.Gif && params.compressBeforeSending) { + notifyTracker(params) { contentUploadStateTracker.setCompressingImage(it) } + fileToUpload = imageCompressor.compress(workingFile, MAX_IMAGE_SIZE, MAX_IMAGE_SIZE) .also { compressedFile -> // Get new Bitmap size @@ -170,6 +176,48 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter } } .also { filesToDelete.add(it) } + } else if (attachment.type == ContentAttachmentData.Type.VIDEO + // Do not compress gif + && attachment.mimeType != MimeTypes.Gif + && params.compressBeforeSending) { + fileToUpload = videoCompressor.compress(workingFile, object : ProgressListener { + override fun onProgress(progress: Int, total: Int) { + notifyTracker(params) { contentUploadStateTracker.setCompressingVideo(it, progress.toFloat()) } + } + }) + .let { videoCompressionResult -> + when (videoCompressionResult) { + is VideoCompressionResult.Success -> { + val compressedFile = videoCompressionResult.compressedFile + var compressedWidth: Int? = null + var compressedHeight: Int? = null + + tryOrNull { + context.contentResolver.openFileDescriptor(compressedFile.toUri(), "r")?.use { pfd -> + MediaMetadataRetriever().let { + it.setDataSource(pfd.fileDescriptor) + compressedWidth = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() + compressedHeight = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() + } + } + } + + // Get new Video file size and dimensions + newAttachmentAttributes = newAttachmentAttributes.copy( + newFileSize = compressedFile.length(), + newWidth = compressedWidth ?: newAttachmentAttributes.newWidth, + newHeight = compressedHeight ?: newAttachmentAttributes.newHeight + ) + compressedFile + .also { filesToDelete.add(it) } + } + VideoCompressionResult.CompressionNotNeeded, + VideoCompressionResult.CompressionCancelled, + is VideoCompressionResult.CompressionFailed -> { + workingFile + } + } + } } else { fileToUpload = workingFile // Fix: OpenableColumns.SIZE may return -1 or 0 @@ -180,9 +228,9 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter val encryptedFile: File? val contentUploadResponse = if (params.isEncrypted) { - Timber.v("## FileService: Encrypt file") + Timber.v("## Encrypt file") - encryptedFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir) + encryptedFile = temporaryFileCreator.create() .also { filesToDelete.add(it) } uploadedFileEncryptedFileInfo = @@ -192,18 +240,18 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter } } - Timber.v("## FileService: Uploading file") + Timber.v("## Uploading file") fileUploader .uploadFile(encryptedFile, attachment.name, MimeTypes.OctetStream, progressListener) } else { - Timber.v("## FileService: Clear file") + Timber.v("## Clear file") encryptedFile = null fileUploader .uploadFile(fileToUpload, attachment.name, attachment.getSafeMimeType(), progressListener) } - Timber.v("## FileService: Update cache storage for ${contentUploadResponse.contentUri}") + Timber.v("## Update cache storage for ${contentUploadResponse.contentUri}") try { fileService.storeDataFor( mxcUrl = contentUploadResponse.contentUri, @@ -212,11 +260,13 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter originalFile = workingFile, encryptedFile = encryptedFile ) - Timber.v("## FileService: cache storage updated") + Timber.v("## cache storage updated") } catch (failure: Throwable) { - Timber.e(failure, "## FileService: Failed to update file cache") + Timber.e(failure, "## Failed to update file cache") } + val uploadThumbnailResult = dealWithThumbnail(params) + handleSuccess(params, contentUploadResponse.contentUri, uploadedFileEncryptedFileInfo, @@ -224,12 +274,12 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter uploadThumbnailResult?.uploadedThumbnailEncryptedFileInfo, newAttachmentAttributes) } catch (t: Throwable) { - Timber.e(t, "## FileService: ERROR ${t.localizedMessage}") + Timber.e(t, "## ERROR ${t.localizedMessage}") handleFailure(params, t) } } catch (e: Exception) { - Timber.e(e, "## FileService: ERROR") - return handleFailure(params, e) + Timber.e(e, "## ERROR") + handleFailure(params, e) } finally { // Delete all temporary files filesToDelete.forEach { @@ -260,19 +310,23 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter Timber.v("Encrypt thumbnail") notifyTracker(params) { contentUploadStateTracker.setEncryptingThumbnail(it) } val encryptionResult = MXEncryptedAttachments.encryptAttachment(thumbnailData.bytes.inputStream(), thumbnailData.mimeType) - val contentUploadResponse = fileUploader.uploadByteArray(encryptionResult.encryptedByteArray, - "thumb_${params.attachment.name}", - MimeTypes.OctetStream, - thumbnailProgressListener) + val contentUploadResponse = fileUploader.uploadByteArray( + byteArray = encryptionResult.encryptedByteArray, + filename = "thumb_${params.attachment.name}", + mimeType = MimeTypes.OctetStream, + progressListener = thumbnailProgressListener + ) UploadThumbnailResult( contentUploadResponse.contentUri, encryptionResult.encryptedFileInfo ) } else { - val contentUploadResponse = fileUploader.uploadByteArray(thumbnailData.bytes, - "thumb_${params.attachment.name}", - thumbnailData.mimeType, - thumbnailProgressListener) + val contentUploadResponse = fileUploader.uploadByteArray( + byteArray = thumbnailData.bytes, + filename = "thumb_${params.attachment.name}", + mimeType = thumbnailData.mimeType, + progressListener = thumbnailProgressListener + ) UploadThumbnailResult( contentUploadResponse.contentUri, null @@ -291,7 +345,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter return Result.success( WorkerParamsFactory.toData( params.copy( - lastFailureMessage = failure.localizedMessage + lastFailureMessage = failure.toMatrixErrorStr() ) ) ) @@ -328,8 +382,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter val messageContent: MessageContent? = event.asDomain().content.toModel() val updatedContent = when (messageContent) { is MessageImageContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes) - is MessageVideoContent -> messageContent.update(url, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo, - newAttachmentAttributes.newFileSize) + is MessageVideoContent -> messageContent.update(url, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo, newAttachmentAttributes) is MessageFileContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes.newFileSize) is MessageAudioContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes.newFileSize) else -> messageContent @@ -351,7 +404,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter info = info?.copy( width = newAttachmentAttributes?.newWidth ?: info.width, height = newAttachmentAttributes?.newHeight ?: info.height, - size = newAttachmentAttributes?.newFileSize?.toInt() ?: info.size + size = newAttachmentAttributes?.newFileSize ?: info.size ) ) } @@ -360,14 +413,16 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter encryptedFileInfo: EncryptedFileInfo?, thumbnailUrl: String?, thumbnailEncryptedFileInfo: EncryptedFileInfo?, - size: Long): MessageVideoContent { + newAttachmentAttributes: NewAttachmentAttributes?): MessageVideoContent { return copy( url = if (encryptedFileInfo == null) url else null, encryptedFileInfo = encryptedFileInfo?.copy(url = url), videoInfo = videoInfo?.copy( thumbnailUrl = if (thumbnailEncryptedFileInfo == null) thumbnailUrl else null, thumbnailFile = thumbnailEncryptedFileInfo?.copy(url = thumbnailUrl), - size = size + width = newAttachmentAttributes?.newWidth ?: videoInfo.width, + height = newAttachmentAttributes?.newHeight ?: videoInfo.height, + size = newAttachmentAttributes?.newFileSize ?: videoInfo.size ) ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/VideoCompressionResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/VideoCompressionResult.kt new file mode 100644 index 0000000000..87d5c7e6a3 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/VideoCompressionResult.kt @@ -0,0 +1,26 @@ +/* + * 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.content + +import java.io.File + +internal sealed class VideoCompressionResult { + data class Success(val compressedFile: File) : VideoCompressionResult() + object CompressionNotNeeded : VideoCompressionResult() + object CompressionCancelled : VideoCompressionResult() + data class CompressionFailed(val failure: Throwable) : VideoCompressionResult() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/VideoCompressor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/VideoCompressor.kt new file mode 100644 index 0000000000..05aaf4e9f1 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/VideoCompressor.kt @@ -0,0 +1,119 @@ +/* + * Copyright 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.content + +import com.otaliastudios.transcoder.Transcoder +import com.otaliastudios.transcoder.TranscoderListener +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.withContext +import org.matrix.android.sdk.api.listeners.ProgressListener +import org.matrix.android.sdk.internal.util.TemporaryFileCreator +import timber.log.Timber +import java.io.File +import javax.inject.Inject + +internal class VideoCompressor @Inject constructor( + private val temporaryFileCreator: TemporaryFileCreator +) { + + suspend fun compress(videoFile: File, + progressListener: ProgressListener?): VideoCompressionResult { + val destinationFile = temporaryFileCreator.create() + + val job = Job() + + Timber.d("Compressing: start") + progressListener?.onProgress(0, 100) + + var result: Int = -1 + var failure: Throwable? = null + Transcoder.into(destinationFile.path) + .addDataSource(videoFile.path) + .setListener(object : TranscoderListener { + override fun onTranscodeProgress(progress: Double) { + Timber.d("Compressing: $progress%") + progressListener?.onProgress((progress * 100).toInt(), 100) + } + + override fun onTranscodeCompleted(successCode: Int) { + Timber.d("Compressing: success: $successCode") + result = successCode + job.complete() + } + + override fun onTranscodeCanceled() { + Timber.d("Compressing: cancel") + job.cancel() + } + + override fun onTranscodeFailed(exception: Throwable) { + Timber.w(exception, "Compressing: failure") + failure = exception + job.completeExceptionally(exception) + } + }) + .transcode() + + job.join() + + // Note: job is also cancelled if completeExceptionally() was called + if (job.isCancelled) { + // Delete now the temporary file + deleteFile(destinationFile) + return when (val finalFailure = failure) { + null -> { + // We do not throw a CancellationException, because it's not critical, we will try to send the original file + // Anyway this should never occurs, since we never cancel the return value of transcode() + Timber.w("Compressing: A failure occurred") + VideoCompressionResult.CompressionCancelled + } + else -> { + // Compression failure can also be considered as not critical, but let the caller decide + Timber.w("Compressing: Job cancelled") + VideoCompressionResult.CompressionFailed(finalFailure) + } + } + } + + progressListener?.onProgress(100, 100) + + return when (result) { + Transcoder.SUCCESS_TRANSCODED -> { + VideoCompressionResult.Success(destinationFile) + } + Transcoder.SUCCESS_NOT_NEEDED -> { + // Delete now the temporary file + deleteFile(destinationFile) + VideoCompressionResult.CompressionNotNeeded + } + else -> { + // Should not happen... + // Delete now the temporary file + deleteFile(destinationFile) + Timber.w("Unknown result: $result") + VideoCompressionResult.CompressionFailed(IllegalStateException("Unknown result: $result")) + } + } + } + + private suspend fun deleteFile(file: File) { + withContext(Dispatchers.IO) { + file.delete() + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt index f5391d6cdb..475781ef01 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt @@ -36,7 +36,7 @@ import org.matrix.android.sdk.internal.di.AuthenticatedIdentity import org.matrix.android.sdk.internal.di.UnauthenticatedWithCertificate import org.matrix.android.sdk.internal.extensions.observeNotNull import org.matrix.android.sdk.internal.network.RetrofitFactory -import org.matrix.android.sdk.internal.session.SessionLifecycleObserver +import org.matrix.android.sdk.api.session.SessionLifecycleObserver import org.matrix.android.sdk.internal.session.SessionScope import org.matrix.android.sdk.internal.session.identity.data.IdentityStore import org.matrix.android.sdk.internal.session.openid.GetOpenIdTokenTask @@ -51,6 +51,7 @@ import org.matrix.android.sdk.internal.util.ensureProtocol import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.Session import timber.log.Timber import javax.inject.Inject import javax.net.ssl.HttpsURLConnection @@ -86,7 +87,7 @@ internal class DefaultIdentityService @Inject constructor( private val listeners = mutableSetOf() - override fun onSessionStarted() { + override fun onSessionStarted(session: Session) { lifecycleRegistry.currentState = Lifecycle.State.STARTED // Observe the account data change accountDataDataSource @@ -111,7 +112,7 @@ internal class DefaultIdentityService @Inject constructor( } } - override fun onSessionStopped() { + override fun onSessionStopped(session: Session) { lifecycleRegistry.currentState = Lifecycle.State.DESTROYED } 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 e34615d269..3df9a00cc1 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 @@ -21,6 +21,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.api.MatrixConfiguration +import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.integrationmanager.IntegrationManagerConfig import org.matrix.android.sdk.api.session.integrationmanager.IntegrationManagerService @@ -29,7 +30,7 @@ import org.matrix.android.sdk.api.session.widgets.model.WidgetType import org.matrix.android.sdk.internal.database.model.WellknownIntegrationManagerConfigEntity import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.extensions.observeNotNull -import org.matrix.android.sdk.internal.session.SessionLifecycleObserver +import org.matrix.android.sdk.api.session.SessionLifecycleObserver import org.matrix.android.sdk.internal.session.SessionScope import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes import org.matrix.android.sdk.api.session.accountdata.UserAccountDataEvent @@ -77,7 +78,7 @@ internal class IntegrationManager @Inject constructor(matrixConfiguration: Matri currentConfigs.add(defaultConfig) } - override fun onSessionStarted() { + override fun onSessionStarted(session: Session) { lifecycleRegistry.currentState = Lifecycle.State.STARTED observeWellknownConfig() accountDataDataSource @@ -105,7 +106,7 @@ internal class IntegrationManager @Inject constructor(matrixConfiguration: Matri } } - override fun onSessionStopped() { + override fun onSessionStopped(session: Session) { lifecycleRegistry.currentState = Lifecycle.State.DESTROYED } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt index 1d8eb6c95e..c6059f84ea 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt @@ -24,6 +24,7 @@ import org.matrix.android.sdk.api.session.room.alias.AliasService import org.matrix.android.sdk.api.session.room.call.RoomCallService import org.matrix.android.sdk.api.session.room.members.MembershipService import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.RoomType import org.matrix.android.sdk.api.session.room.model.relation.RelationService import org.matrix.android.sdk.api.session.room.notification.RoomPushRuleService import org.matrix.android.sdk.api.session.room.read.ReadService @@ -36,11 +37,13 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.uploads.UploadsService import org.matrix.android.sdk.api.session.search.SearchResult +import org.matrix.android.sdk.api.session.space.Space import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM import org.matrix.android.sdk.internal.session.room.state.SendStateTask import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource import org.matrix.android.sdk.internal.session.search.SearchTask +import org.matrix.android.sdk.internal.session.space.DefaultSpace import org.matrix.android.sdk.internal.util.awaitCallback import java.security.InvalidParameterException import javax.inject.Inject @@ -148,4 +151,9 @@ internal class DefaultRoom @Inject constructor(override val roomId: String, ) ) } + + override fun asSpace(): Space? { + if (roomSummary()?.roomType != RoomType.SPACE) return null + return DefaultSpace(this, roomSummaryDataSource) + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt index 22f61bc517..d9fe1288e2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt @@ -23,9 +23,11 @@ import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.Room import org.matrix.android.sdk.api.session.room.RoomService +import org.matrix.android.sdk.api.session.room.RoomSortOrder import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams -import org.matrix.android.sdk.api.session.room.UpdatableFilterLivePageResult +import org.matrix.android.sdk.api.session.room.UpdatableLivePageResult import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState +import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams @@ -67,7 +69,7 @@ internal class DefaultRoomService @Inject constructor( ) : RoomService { override suspend fun createRoom(createRoomParams: CreateRoomParams): String { - return createRoomTask.execute(createRoomParams) + return createRoomTask.executeRetry(createRoomParams, 3) } override fun getRoom(roomId: String): Room? { @@ -90,14 +92,14 @@ internal class DefaultRoomService @Inject constructor( return roomSummaryDataSource.getRoomSummariesLive(queryParams) } - override fun getPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, pagedListConfig: PagedList.Config) + override fun getPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, pagedListConfig: PagedList.Config, sortOrder: RoomSortOrder) : LiveData> { - return roomSummaryDataSource.getSortedPagedRoomSummariesLive(queryParams, pagedListConfig) + return roomSummaryDataSource.getSortedPagedRoomSummariesLive(queryParams, pagedListConfig, sortOrder) } - override fun getFilteredPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, pagedListConfig: PagedList.Config) - : UpdatableFilterLivePageResult { - return roomSummaryDataSource.getFilteredPagedRoomSummariesLive(queryParams, pagedListConfig) + override fun getFilteredPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, pagedListConfig: PagedList.Config, sortOrder: RoomSortOrder) + : UpdatableLivePageResult { + return roomSummaryDataSource.getUpdatablePagedRoomSummariesLive(queryParams, pagedListConfig, sortOrder) } override fun getNotificationCountForRooms(queryParams: RoomSummaryQueryParams): RoomAggregateNotificationCount { @@ -163,4 +165,18 @@ internal class DefaultRoomService @Inject constructor( override suspend fun peekRoom(roomIdOrAlias: String): PeekResult { return peekRoomTask.execute(PeekRoomTask.Params(roomIdOrAlias)) } + + override fun getFlattenRoomSummaryChildrenOf(spaceId: String?, memberships: List): List { + if (spaceId == null) { + return roomSummaryDataSource.getFlattenOrphanRooms() + } + return roomSummaryDataSource.getAllRoomSummaryChildOf(spaceId, memberships) + } + + override fun getFlattenRoomSummaryChildrenOfLive(spaceId: String?, memberships: List): LiveData> { + if (spaceId == null) { + return roomSummaryDataSource.getFlattenOrphanRoomsLive() + } + return roomSummaryDataSource.getAllRoomSummaryChildOfLive(spaceId, memberships) + } } 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 5133f72932..8f3445bec3 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 @@ -24,6 +24,7 @@ import org.commonmark.renderer.html.HtmlRenderer import org.matrix.android.sdk.api.session.file.FileService import org.matrix.android.sdk.api.session.room.RoomDirectoryService import org.matrix.android.sdk.api.session.room.RoomService +import org.matrix.android.sdk.api.session.space.SpaceService import org.matrix.android.sdk.internal.session.DefaultFileService import org.matrix.android.sdk.internal.session.SessionScope import org.matrix.android.sdk.internal.session.directory.DirectoryAPI @@ -89,6 +90,7 @@ import org.matrix.android.sdk.internal.session.room.typing.DefaultSendTypingTask import org.matrix.android.sdk.internal.session.room.typing.SendTypingTask import org.matrix.android.sdk.internal.session.room.uploads.DefaultGetUploadsTask import org.matrix.android.sdk.internal.session.room.uploads.GetUploadsTask +import org.matrix.android.sdk.internal.session.space.DefaultSpaceService import retrofit2.Retrofit @Module @@ -135,6 +137,9 @@ internal abstract class RoomModule { @Binds abstract fun bindRoomService(service: DefaultRoomService): RoomService + @Binds + abstract fun bindSpaceService(service: DefaultSpaceService): SpaceService + @Binds abstract fun bindRoomDirectoryService(service: DefaultRoomDirectoryService): RoomDirectoryService diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/SpaceGetter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/SpaceGetter.kt new file mode 100644 index 0000000000..fed3ff542b --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/SpaceGetter.kt @@ -0,0 +1,33 @@ +/* + * Copyright 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.room + +import org.matrix.android.sdk.api.session.space.Space +import javax.inject.Inject + +internal interface SpaceGetter { + fun get(spaceId: String): Space? +} + +internal class DefaultSpaceGetter @Inject constructor( + private val roomGetter: RoomGetter +) : SpaceGetter { + + override fun get(spaceId: String): Space? { + return roomGetter.getRoom(spaceId)?.asSpace() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/alias/RoomAliasAvailabilityChecker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/alias/RoomAliasAvailabilityChecker.kt index 9faf50dd8b..b39cbaa582 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/alias/RoomAliasAvailabilityChecker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/alias/RoomAliasAvailabilityChecker.kt @@ -36,7 +36,11 @@ internal class RoomAliasAvailabilityChecker @Inject constructor( @Throws(RoomAliasError::class) suspend fun check(aliasLocalPart: String?) { if (aliasLocalPart.isNullOrEmpty()) { - throw RoomAliasError.AliasEmpty + // don't check empty or not provided alias + return + } + if (aliasLocalPart.isBlank()) { + throw RoomAliasError.AliasIsBlank } // Check alias availability val fullAlias = aliasLocalPart.toFullLocalAlias(userId) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt index 13d403e2e4..69352688e3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt @@ -111,5 +111,12 @@ internal data class CreateRoomBody( * The power level content to override in the default power level event */ @Json(name = "power_level_content_override") - val powerLevelContentOverride: PowerLevelsContent? + val powerLevelContentOverride: PowerLevelsContent?, + + /** + * The room version to set for the room. If not provided, the homeserver is to use its configured default. + * If provided, the homeserver will return a 400 error with the errcode M_UNSUPPORTED_ROOM_VERSION if it does not support the room version. + */ + @Json(name = "room_version") + val roomVersion: String? ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBodyBuilder.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBodyBuilder.kt index 5e823fc87f..018b865388 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBodyBuilder.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBodyBuilder.kt @@ -20,13 +20,19 @@ import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.crypto.crosssigning.CrossSigningService 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.toContent import org.matrix.android.sdk.api.session.identity.IdentityServiceError import org.matrix.android.sdk.api.session.identity.toMedium +import org.matrix.android.sdk.api.session.room.model.GuestAccess +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomJoinRules +import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesContent import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.internal.crypto.DeviceListManager import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM import org.matrix.android.sdk.internal.di.AuthenticatedIdentity +import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.network.token.AccessTokenProvider import org.matrix.android.sdk.internal.session.content.FileUploader import org.matrix.android.sdk.internal.session.identity.EnsureIdentityTokenTask @@ -43,6 +49,8 @@ internal class CreateRoomBodyBuilder @Inject constructor( private val deviceListManager: DeviceListManager, private val identityStore: IdentityStore, private val fileUploader: FileUploader, + @UserId + private val userId: String, @AuthenticatedIdentity private val accessTokenProvider: AccessTokenProvider ) { @@ -68,10 +76,17 @@ internal class CreateRoomBodyBuilder @Inject constructor( } } + if (params.joinRuleRestricted != null) { + params.roomVersion = "org.matrix.msc3083" + params.historyVisibility = params.historyVisibility ?: RoomHistoryVisibility.SHARED + params.guestAccess = params.guestAccess ?: GuestAccess.Forbidden + } val initialStates = listOfNotNull( buildEncryptionWithAlgorithmEvent(params), buildHistoryVisibilityEvent(params), - buildAvatarEvent(params) + buildAvatarEvent(params), + buildGuestAccess(params), + buildJoinRulesRestricted(params) ) .takeIf { it.isNotEmpty() } @@ -80,13 +95,15 @@ internal class CreateRoomBodyBuilder @Inject constructor( roomAliasName = params.roomAliasName, name = params.name, topic = params.topic, - invitedUserIds = params.invitedUserIds, + invitedUserIds = params.invitedUserIds.filter { it != userId }, invite3pids = invite3pids, creationContent = params.creationContent.takeIf { it.isNotEmpty() }, initialStates = initialStates, preset = params.preset, isDirect = params.isDirect, - powerLevelContentOverride = params.powerLevelContentOverride + powerLevelContentOverride = params.powerLevelContentOverride, + roomVersion = params.roomVersion + ) } @@ -120,6 +137,31 @@ internal class CreateRoomBodyBuilder @Inject constructor( } } + private fun buildGuestAccess(params: CreateRoomParams): Event? { + return params.guestAccess + ?.let { + Event( + type = EventType.STATE_ROOM_GUEST_ACCESS, + stateKey = "", + content = mapOf("guest_access" to it.value) + ) + } + } + + private fun buildJoinRulesRestricted(params: CreateRoomParams): Event? { + return params.joinRuleRestricted + ?.let { allowList -> + Event( + type = EventType.STATE_ROOM_JOIN_RULES, + stateKey = "", + content = RoomJoinRulesContent( + _joinRules = RoomJoinRules.RESTRICTED.value, + allowList = allowList + ).toContent() + ) + } + } + /** * Add the crypto algorithm to the room creation parameters. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt index bafe2b90ae..de6a71e581 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt @@ -102,7 +102,7 @@ internal class DefaultCreateRoomTask @Inject constructor( .equalTo(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.JOIN.name) } } catch (exception: TimeoutCancellationException) { - throw CreateRoomFailure.CreatedWithTimeout + throw CreateRoomFailure.CreatedWithTimeout(roomId) } Realm.getInstance(realmConfiguration).executeTransactionAsync { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/PeekRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/PeekRoomTask.kt index 5b211c505f..c6f4bbb4e1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/PeekRoomTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/PeekRoomTask.kt @@ -23,11 +23,14 @@ import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.RoomAvatarContent import org.matrix.android.sdk.api.session.room.model.RoomCanonicalAliasContent import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.api.session.room.model.RoomNameContent import org.matrix.android.sdk.api.session.room.model.RoomTopicContent +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsFilter import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams import org.matrix.android.sdk.api.session.room.peeking.PeekResult +import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.internal.session.room.alias.GetRoomIdByAliasTask import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask @@ -100,7 +103,9 @@ internal class DefaultPeekRoomTask @Inject constructor( name = publicRepoResult.name, topic = publicRepoResult.topic, numJoinedMembers = publicRepoResult.numJoinedMembers, - viaServers = serverList + viaServers = serverList, + roomType = null, // would be nice to get that from directory... + someMembers = null ) } @@ -125,11 +130,25 @@ internal class DefaultPeekRoomTask @Inject constructor( ?.let { it.content?.toModel()?.canonicalAlias } // not sure if it's the right way to do that :/ - val memberCount = stateEvents + val membersEvent = stateEvents .filter { it.type == EventType.STATE_ROOM_MEMBER && it.stateKey?.isNotEmpty() == true } + + val memberCount = membersEvent .distinctBy { it.stateKey } .count() + val someMembers = membersEvent.mapNotNull { ev -> + ev.content?.toModel()?.let { + MatrixItem.UserItem(ev.stateKey ?: "", it.displayName, it.avatarUrl) + } + } + + val roomType = stateEvents + .lastOrNull { it.type == EventType.STATE_ROOM_CREATE } + ?.content + ?.toModel() + ?.type + return PeekResult.Success( roomId = roomId, alias = alias, @@ -137,7 +156,9 @@ internal class DefaultPeekRoomTask @Inject constructor( name = name, topic = topic, numJoinedMembers = memberCount, - viaServers = serverList + roomType = roomType, + viaServers = serverList, + someMembers = someMembers ) } catch (failure: Throwable) { // Would be M_FORBIDDEN if cannot peek :/ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt index 5fe06287d2..a666d40fc3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt @@ -99,6 +99,7 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor: entity.age = editedEventEntity.age entity.originServerTs = editedEventEntity.originServerTs entity.sendState = editedEventEntity.sendState + entity.sendStateDetails = editedEventEntity.sendStateDetails } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relationship/RoomChildRelationInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relationship/RoomChildRelationInfo.kt new file mode 100644 index 0000000000..2efea7f118 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relationship/RoomChildRelationInfo.kt @@ -0,0 +1,104 @@ +/* + * 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.room.relationship + +import io.realm.Realm +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.space.model.SpaceChildContent +import org.matrix.android.sdk.api.session.space.model.SpaceParentContent +import org.matrix.android.sdk.internal.database.mapper.ContentMapper +import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity +import org.matrix.android.sdk.internal.database.query.whereType + +/** + * Relationship between rooms and spaces + * The intention is that rooms and spaces form a hierarchy, which clients can use to structure the user's room list into a tree view. + * The parent/child relationship can be expressed in one of two ways: + * - The admins of a space can advertise rooms and subspaces for their space by setting m.space.child state events. + * The state_key is the ID of a child room or space, and the content should contain a via key which gives + * a list of candidate servers that can be used to join the room. present: true key is included to distinguish from a deleted state event. + * + * - Separately, rooms can claim parents via the m.room.parent state event. + */ +internal class RoomChildRelationInfo( + private val realm: Realm, + private val roomId: String +) { + + data class SpaceChildInfo( + val roomId: String, + val order: String?, + val autoJoin: Boolean, + val viaServers: List + ) + + data class SpaceParentInfo( + val roomId: String, + val canonical: Boolean, + val viaServers: List, + val stateEventSender: String + ) + + /** + * Gets the ordered list of valid child description. + */ + fun getDirectChildrenDescriptions(): List { + return CurrentStateEventEntity.whereType(realm, roomId, EventType.STATE_SPACE_CHILD) + .findAll() +// .also { +// Timber.v("## Space: Found ${it.count()} m.space.child state events for $roomId") +// } + .mapNotNull { + ContentMapper.map(it.root?.content).toModel()?.let { scc -> +// Timber.v("## Space child desc state event $scc") + // Children where via is not present are ignored. + scc.via?.let { via -> + SpaceChildInfo( + roomId = it.stateKey, + order = scc.validOrder(), + autoJoin = scc.autoJoin ?: false, + viaServers = via + ) + } + } + } + .sortedBy { it.order } + } + + fun getParentDescriptions(): List { + return CurrentStateEventEntity.whereType(realm, roomId, EventType.STATE_SPACE_PARENT) + .findAll() +// .also { +// Timber.v("## Space: Found ${it.count()} m.space.parent state events for $roomId") +// } + .mapNotNull { + ContentMapper.map(it.root?.content).toModel()?.let { scc -> +// Timber.v("## Space parent desc state event $scc") + // Parent where via is not present are ignored. + scc.via?.let { via -> + SpaceParentInfo( + roomId = it.stateKey, + canonical = scc.canonical ?: false, + viaServers = via, + stateEventSender = it.root?.sender ?: "" + ) + } + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt index 26a87557ff..449189e6b5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt @@ -109,14 +109,6 @@ internal class DefaultSendService @AssistedInject constructor( .let { sendEvent(it) } } - override fun sendMedias(attachments: List, - compressBeforeSending: Boolean, - roomIds: Set): Cancelable { - return attachments.mapTo(CancelableBag()) { - sendMedia(it, compressBeforeSending, roomIds) - } - } - override fun redactEvent(event: Event, reason: String?): Cancelable { // TODO manage media/attachements? val redactionEcho = localEchoEventFactory.createRedactEvent(roomId, event.eventId!!, reason) @@ -149,7 +141,7 @@ internal class DefaultSendService @AssistedInject constructor( is MessageImageContent -> { // The image has not yet been sent val attachmentData = ContentAttachmentData( - size = messageContent.info!!.size.toLong(), + size = messageContent.info!!.size, mimeType = messageContent.info.mimeType!!, width = messageContent.info.width.toLong(), height = messageContent.info.height.toLong(), @@ -240,6 +232,14 @@ internal class DefaultSendService @AssistedInject constructor( } } + override fun sendMedias(attachments: List, + compressBeforeSending: Boolean, + roomIds: Set): Cancelable { + return attachments.mapTo(CancelableBag()) { + sendMedia(it, compressBeforeSending, roomIds) + } + } + override fun sendMedia(attachment: ContentAttachmentData, compressBeforeSending: Boolean, roomIds: Set): Cancelable { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt index 432a4af062..c1ad6205c3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt @@ -244,7 +244,7 @@ internal class LocalEchoEventFactory @Inject constructor( mimeType = attachment.getSafeMimeType(), width = width?.toInt() ?: 0, height = height?.toInt() ?: 0, - size = attachment.size.toInt() + size = attachment.size ), url = attachment.queryUri.toString() ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt index 70245cbd5e..e98e5646af 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt @@ -87,7 +87,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private } } - fun updateSendState(eventId: String, roomId: String?, sendState: SendState) { + fun updateSendState(eventId: String, roomId: String?, sendState: SendState, sendStateDetails: String? = null) { Timber.v("## SendEvent: [${System.currentTimeMillis()}] Update local state of $eventId to ${sendState.name}") timelineInput.onLocalEchoUpdated(roomId = roomId ?: "", eventId = eventId, sendState = sendState) updateEchoAsync(eventId) { realm, sendingEventEntity -> @@ -96,6 +96,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private } else { sendingEventEntity.sendState = sendState } + sendingEventEntity.sendStateDetails = sendStateDetails roomSummaryUpdater.updateSendingInformation(realm, sendingEventEntity.roomId) } } @@ -161,6 +162,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private val timelineEvents = TimelineEventEntity.where(realm, roomId, eventIds).findAll() timelineEvents.forEach { it.root?.sendState = sendState + it.root?.sendStateDetails = null } roomSummaryUpdater.updateSendingInformation(realm, roomId) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MultipleEventSendingDispatcherWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MultipleEventSendingDispatcherWorker.kt index bc307bc74f..e889f1a61b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MultipleEventSendingDispatcherWorker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MultipleEventSendingDispatcherWorker.kt @@ -55,7 +55,12 @@ internal class MultipleEventSendingDispatcherWorker(context: Context, params: Wo override fun doOnError(params: Params): Result { params.localEchoIds.forEach { localEchoIds -> - localEchoRepository.updateSendState(localEchoIds.eventId, localEchoIds.roomId, SendState.UNDELIVERED) + localEchoRepository.updateSendState( + eventId = localEchoIds.eventId, + roomId = localEchoIds.roomId, + sendState = SendState.UNDELIVERED, + sendStateDetails = params.lastFailureMessage + ) } return super.doOnError(params) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/SendEventWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/SendEventWorker.kt index d55dce57af..cd7911910d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/SendEventWorker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/SendEventWorker.kt @@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.internal.crypto.tasks.SendEventTask import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.session.SessionComponent +import org.matrix.android.sdk.internal.util.toMatrixErrorStr import org.matrix.android.sdk.internal.worker.SessionSafeCoroutineWorker import org.matrix.android.sdk.internal.worker.SessionWorkerParams import timber.log.Timber @@ -77,7 +78,12 @@ internal class SendEventWorker(context: Context, } if (params.lastFailureMessage != null) { - localEchoRepository.updateSendState(event.eventId, event.roomId, SendState.UNDELIVERED) + localEchoRepository.updateSendState( + eventId = event.eventId, + roomId = event.roomId, + sendState = SendState.UNDELIVERED, + sendStateDetails = params.lastFailureMessage + ) // Transmit the error return Result.success(inputData) .also { Timber.e("Work cancelled due to input error from parent") } @@ -90,7 +96,12 @@ internal class SendEventWorker(context: Context, } catch (exception: Throwable) { if (/*currentAttemptCount >= MAX_NUMBER_OF_RETRY_BEFORE_FAILING ||**/ !exception.shouldBeRetried()) { Timber.e("## SendEvent: [${System.currentTimeMillis()}] Send event Failed cannot retry ${params.eventId} > ${exception.localizedMessage}") - localEchoRepository.updateSendState(event.eventId, event.roomId, SendState.UNDELIVERED) + localEchoRepository.updateSendState( + eventId = event.eventId, + roomId = event.roomId, + sendState = SendState.UNDELIVERED, + sendStateDetails = exception.toMatrixErrorStr() + ) Result.success() } else { Timber.e("## SendEvent: [${System.currentTimeMillis()}] Send event Failed schedule retry ${params.eventId} > ${exception.localizedMessage}") 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 8bafa5f882..cd5bf575db 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 @@ -18,7 +18,7 @@ package org.matrix.android.sdk.internal.session.room.send.queue import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.util.Cancelable -import org.matrix.android.sdk.internal.session.SessionLifecycleObserver +import org.matrix.android.sdk.api.session.SessionLifecycleObserver internal interface EventSenderProcessor: SessionLifecycleObserver { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorCoroutine.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorCoroutine.kt index a5c09f5ff6..80bfd02b0e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorCoroutine.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorCoroutine.kt @@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.auth.data.SessionParams import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.failure.getRetryDelay +import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.CryptoService import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.util.Cancelable @@ -72,7 +73,7 @@ internal class EventSenderProcessorCoroutine @Inject constructor( */ private val cancelableBag = ConcurrentHashMap() - override fun onSessionStarted() { + override fun onSessionStarted(session: Session) { // We should check for sending events not handled because app was killed // But we should be careful of only took those that was submitted to us, because if it's // for example it's a media event it is handled by some worker and he will handle it diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorThread.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorThread.kt index b79a86dd7e..9db7cc9039 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorThread.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorThread.kt @@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.failure.isTokenError +import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.CryptoService import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.sync.SyncState @@ -64,11 +65,11 @@ internal class EventSenderProcessorThread @Inject constructor( memento.unTrack(task) } - override fun onSessionStarted() { + override fun onSessionStarted(session: Session) { start() } - override fun onSessionStopped() { + override fun onSessionStopped(session: Session) { interrupt() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt index 615bc99096..ff2afb5d61 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt @@ -34,6 +34,7 @@ import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.internal.session.content.FileUploader +import java.lang.UnsupportedOperationException internal class DefaultStateService @AssistedInject constructor(@Assisted private val roomId: String, private val stateEventDataSource: StateEventDataSource, @@ -73,7 +74,7 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private eventType = eventType, body = body.toSafeJson(eventType) ) - sendStateTask.execute(params) + sendStateTask.executeRetry(params, 3) } private fun JsonDict.toSafeJson(eventType: String): JsonDict { @@ -127,6 +128,7 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private override suspend fun updateJoinRule(joinRules: RoomJoinRules?, guestAccess: GuestAccess?) { if (joinRules != null) { + if (joinRules == RoomJoinRules.RESTRICTED) throw UnsupportedOperationException("No yet supported") sendStateEvent( eventType = EventType.STATE_ROOM_JOIN_RULES, body = mapOf("join_rule" to joinRules), diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/SafePowerLevelContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/SafePowerLevelContent.kt index a97709e38b..197b4f8688 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/SafePowerLevelContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/SafePowerLevelContent.kt @@ -21,22 +21,21 @@ import com.squareup.moshi.JsonClass import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent -import org.matrix.android.sdk.api.session.room.powerlevels.Role import org.matrix.android.sdk.api.util.JsonDict @JsonClass(generateAdapter = true) internal data class SerializablePowerLevelsContent( - @Json(name = "ban") val ban: Int = Role.Moderator.value, - @Json(name = "kick") val kick: Int = Role.Moderator.value, - @Json(name = "invite") val invite: Int = Role.Moderator.value, - @Json(name = "redact") val redact: Int = Role.Moderator.value, - @Json(name = "events_default") val eventsDefault: Int = Role.Default.value, - @Json(name = "events") val events: Map = emptyMap(), - @Json(name = "users_default") val usersDefault: Int = Role.Default.value, - @Json(name = "users") val users: Map = emptyMap(), - @Json(name = "state_default") val stateDefault: Int = Role.Moderator.value, + @Json(name = "ban") val ban: Int?, + @Json(name = "kick") val kick: Int?, + @Json(name = "invite") val invite: Int?, + @Json(name = "redact") val redact: Int?, + @Json(name = "events_default") val eventsDefault: Int?, + @Json(name = "events") val events: Map?, + @Json(name = "users_default") val usersDefault: Int?, + @Json(name = "users") val users: Map?, + @Json(name = "state_default") val stateDefault: Int?, // `Int` is the diff here (instead of `Any`) - @Json(name = "notifications") val notifications: Map = emptyMap() + @Json(name = "notifications") val notifications: Map? ) internal fun JsonDict.toSafePowerLevelsContentDict(): JsonDict { @@ -52,7 +51,7 @@ internal fun JsonDict.toSafePowerLevelsContentDict(): JsonDict { usersDefault = content.usersDefault, users = content.users, stateDefault = content.stateDefault, - notifications = content.notifications.mapValues { content.notificationLevel(it.key) } + notifications = content.notifications?.mapValues { content.notificationLevel(it.key) } ) } ?.toContent() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/GraphUtils.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/GraphUtils.kt new file mode 100644 index 0000000000..b7e6548b54 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/GraphUtils.kt @@ -0,0 +1,166 @@ +/* + * 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.room.summary + +import java.util.LinkedList + +data class GraphNode( + val name: String +) + +data class GraphEdge( + val source: GraphNode, + val destination: GraphNode +) + +class Graph { + + private val adjacencyList: HashMap> = HashMap() + + fun getOrCreateNode(name: String): GraphNode { + return adjacencyList.entries.firstOrNull { it.key.name == name }?.key + ?: GraphNode(name).also { + adjacencyList[it] = ArrayList() + } + } + + fun addEdge(sourceName: String, destinationName: String) { + val source = getOrCreateNode(sourceName) + val destination = getOrCreateNode(destinationName) + adjacencyList.getOrPut(source) { ArrayList() }.add( + GraphEdge(source, destination) + ) + } + + fun addEdge(source: GraphNode, destination: GraphNode) { + adjacencyList.getOrPut(source) { ArrayList() }.add( + GraphEdge(source, destination) + ) + } + + fun edgesOf(node: GraphNode): List { + return adjacencyList[node]?.toList() ?: emptyList() + } + + fun withoutEdges(edgesToPrune: List): Graph { + val output = Graph() + this.adjacencyList.forEach { (vertex, edges) -> + output.getOrCreateNode(vertex.name) + edges.forEach { + if (!edgesToPrune.contains(it)) { + // add it + output.addEdge(it.source, it.destination) + } + } + } + return output + } + + /** + * Depending on the chosen starting point the background edge might change + */ + fun findBackwardEdges(startFrom: GraphNode? = null): List { + val backwardEdges = mutableSetOf() + val visited = mutableMapOf() + val notVisited = -1 + val inPath = 0 + val completed = 1 + adjacencyList.keys.forEach { + visited[it] = notVisited + } + val stack = LinkedList() + + (startFrom ?: adjacencyList.entries.firstOrNull { visited[it.key] == notVisited }?.key) + ?.let { + stack.push(it) + visited[it] = inPath + } + + while (stack.isNotEmpty()) { +// Timber.w("VAL: current stack: ${stack.reversed().joinToString { it.name }}") + val vertex = stack.peek() ?: break + // peek a path to follow + var destination: GraphNode? = null + edgesOf(vertex).forEach { + when (visited[it.destination]) { + notVisited -> { + // it's a candidate + destination = it.destination + } + inPath -> { + // Cycle!! + backwardEdges.add(it) + } + completed -> { + // dead end + } + } + } + if (destination == null) { + // dead end, pop + stack.pop().let { + visited[it] = completed + } + } else { + // go down this path + stack.push(destination) + visited[destination!!] = inPath + } + + if (stack.isEmpty()) { + // try to get another graph of forest? + adjacencyList.entries.firstOrNull { visited[it.key] == notVisited }?.key?.let { + stack.push(it) + visited[it] = inPath + } + } + } + + return backwardEdges.toList() + } + + /** + * Only call that on acyclic graph! + */ + fun flattenDestination(): Map> { + val result = HashMap>() + adjacencyList.keys.forEach { vertex -> + result[vertex] = flattenOf(vertex) + } + return result + } + + private fun flattenOf(node: GraphNode): Set { + val result = mutableSetOf() + val edgesOf = edgesOf(node) + result.addAll(edgesOf.map { it.destination }) + edgesOf.forEach { + result.addAll(flattenOf(it.destination)) + } + return result + } + + override fun toString(): String { + return buildString { + adjacencyList.forEach { (node, edges) -> + append("${node.name} : [") + append(edges.joinToString(" ") { it.destination.name }) + append("]\n") + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/HierarchyLiveDataHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/HierarchyLiveDataHelper.kt new file mode 100644 index 0000000000..29db8431fd --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/HierarchyLiveDataHelper.kt @@ -0,0 +1,67 @@ +/* + * Copyright 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.room.summary + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.util.Optional + +internal class HierarchyLiveDataHelper( + val spaceId: String, + val memberships: List, + val roomSummaryDataSource: RoomSummaryDataSource) { + + private val sources = HashMap>>() + private val mediatorLiveData = MediatorLiveData>() + + fun liveData(): LiveData> = mediatorLiveData + + init { + onChange() + } + + private fun parentsToCheck(): List { + val spaces = ArrayList() + roomSummaryDataSource.getSpaceSummary(spaceId)?.let { + roomSummaryDataSource.flattenSubSpace(it, emptyList(), spaces, memberships) + } + return spaces + } + + private fun onChange() { + val existingSources = sources.keys.toList() + val newSources = parentsToCheck().map { it.roomId } + val addedSources = newSources.filter { !existingSources.contains(it) } + val removedSource = existingSources.filter { !newSources.contains(it) } + addedSources.forEach { + val liveData = roomSummaryDataSource.getSpaceSummaryLive(it) + mediatorLiveData.addSource(liveData) { onChange() } + sources[it] = liveData + } + + removedSource.forEach { + sources[it]?.let { mediatorLiveData.removeSource(it) } + } + + sources[spaceId]?.value?.getOrNull()?.let { spaceSummary -> + val results = ArrayList() + roomSummaryDataSource.flattenChild(spaceSummary, emptyList(), results, memberships) + mediatorLiveData.postValue(results.map { it.roomId }) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt index dd3fbe04b2..126458b082 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt @@ -1,5 +1,6 @@ /* * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 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. @@ -17,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room.summary import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations import androidx.paging.LivePagedListBuilder import androidx.paging.PagedList @@ -24,12 +26,21 @@ import com.zhuinden.monarchy.Monarchy import io.realm.Realm import io.realm.RealmQuery import io.realm.Sort +import io.realm.kotlin.where +import org.matrix.android.sdk.api.query.ActiveSpaceFilter import org.matrix.android.sdk.api.query.RoomCategoryFilter +import org.matrix.android.sdk.api.session.room.ResultBoundaries +import org.matrix.android.sdk.api.session.room.RoomSortOrder import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams -import org.matrix.android.sdk.api.session.room.UpdatableFilterLivePageResult +import org.matrix.android.sdk.api.session.room.UpdatableLivePageResult +import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.RoomType import org.matrix.android.sdk.api.session.room.model.VersioningState +import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams +import org.matrix.android.sdk.api.session.room.spaceSummaryQueryParams import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount +import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.internal.database.mapper.RoomSummaryMapper @@ -79,11 +90,60 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat fun getRoomSummariesLive(queryParams: RoomSummaryQueryParams): LiveData> { return monarchy.findAllMappedWithChanges( - { roomSummariesQuery(it, queryParams) }, + { + roomSummariesQuery(it, queryParams) + .sort(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Sort.DESCENDING) + }, { roomSummaryMapper.map(it) } ) } + fun getSpaceSummariesLive(queryParams: SpaceSummaryQueryParams): LiveData> { + return getRoomSummariesLive(queryParams) + } + + fun getSpaceSummary(roomIdOrAlias: String): RoomSummary? { + return getRoomSummary(roomIdOrAlias) + ?.takeIf { it.roomType == RoomType.SPACE } + } + + fun getSpaceSummaryLive(roomId: String): LiveData> { + val liveData = monarchy.findAllMappedWithChanges( + { realm -> + RoomSummaryEntity.where(realm, roomId) + .isNotEmpty(RoomSummaryEntityFields.DISPLAY_NAME) + .equalTo(RoomSummaryEntityFields.ROOM_TYPE, RoomType.SPACE) + }, + { + roomSummaryMapper.map(it) + } + ) + return Transformations.map(liveData) { results -> + results.firstOrNull().toOptional() + } + } + + fun getSpaceSummaries(spaceSummaryQueryParams: SpaceSummaryQueryParams): List { + return getRoomSummaries(spaceSummaryQueryParams) + } + + fun getRootSpaceSummaries(): List { + return getRoomSummaries(spaceSummaryQueryParams { + memberships = listOf(Membership.JOIN) + }) + .let { allJoinedSpace -> + val allFlattenChildren = arrayListOf() + allJoinedSpace.forEach { + flattenSubSpace(it, emptyList(), allFlattenChildren, listOf(Membership.JOIN), false) + } + val knownNonOrphan = allFlattenChildren.map { it.roomId }.distinct() + // keep only root rooms + allJoinedSpace.filter { candidate -> + !knownNonOrphan.contains(candidate.roomId) + } + } + } + fun getBreadcrumbs(queryParams: RoomSummaryQueryParams): List { return monarchy.fetchAllMappedSync( { breadcrumbsQuery(it, queryParams) }, @@ -105,10 +165,10 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat } fun getSortedPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, - pagedListConfig: PagedList.Config): LiveData> { + pagedListConfig: PagedList.Config, + sortOrder: RoomSortOrder): LiveData> { val realmDataSourceFactory = monarchy.createDataSourceFactory { realm -> - roomSummariesQuery(realm, queryParams) - .sort(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Sort.DESCENDING) + roomSummariesQuery(realm, queryParams).process(sortOrder) } val dataSourceFactory = realmDataSourceFactory.map { roomSummaryMapper.map(it) @@ -119,30 +179,48 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat ) } - fun getFilteredPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, - pagedListConfig: PagedList.Config): UpdatableFilterLivePageResult { + fun getUpdatablePagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, + pagedListConfig: PagedList.Config, + sortOrder: RoomSortOrder): UpdatableLivePageResult { val realmDataSourceFactory = monarchy.createDataSourceFactory { realm -> - roomSummariesQuery(realm, queryParams) - .sort(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Sort.DESCENDING) + roomSummariesQuery(realm, queryParams).process(sortOrder) } val dataSourceFactory = realmDataSourceFactory.map { roomSummaryMapper.map(it) } + val boundaries = MutableLiveData(ResultBoundaries()) + val mapped = monarchy.findAllPagedWithChanges( realmDataSourceFactory, - LivePagedListBuilder(dataSourceFactory, pagedListConfig) + LivePagedListBuilder(dataSourceFactory, pagedListConfig).also { + it.setBoundaryCallback(object : PagedList.BoundaryCallback() { + override fun onItemAtEndLoaded(itemAtEnd: RoomSummary) { + boundaries.postValue(boundaries.value?.copy(frontLoaded = true)) + } + + override fun onItemAtFrontLoaded(itemAtFront: RoomSummary) { + boundaries.postValue(boundaries.value?.copy(endLoaded = true)) + } + + override fun onZeroItemsLoaded() { + boundaries.postValue(boundaries.value?.copy(zeroItemLoaded = true)) + } + }) + } ) - return object : UpdatableFilterLivePageResult { + return object : UpdatableLivePageResult { override val livePagedList: LiveData> = mapped - override fun updateQuery(queryParams: RoomSummaryQueryParams) { + override fun updateQuery(builder: (RoomSummaryQueryParams) -> RoomSummaryQueryParams) { realmDataSourceFactory.updateQuery { - roomSummariesQuery(it, queryParams) - .sort(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Sort.DESCENDING) + roomSummariesQuery(it, builder.invoke(queryParams)).process(sortOrder) } } + + override val liveBoundaries: LiveData + get() = boundaries } } @@ -170,10 +248,10 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat queryParams.roomCategoryFilter?.let { when (it) { - RoomCategoryFilter.ONLY_DM -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, true) - RoomCategoryFilter.ONLY_ROOMS -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, false) + RoomCategoryFilter.ONLY_DM -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, true) + RoomCategoryFilter.ONLY_ROOMS -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, false) RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS -> query.greaterThan(RoomSummaryEntityFields.NOTIFICATION_COUNT, 0) - RoomCategoryFilter.ALL -> { + RoomCategoryFilter.ALL -> { // nop } } @@ -189,6 +267,160 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat query.equalTo(RoomSummaryEntityFields.IS_SERVER_NOTICE, sn) } } + + queryParams.excludeType?.forEach { + query.notEqualTo(RoomSummaryEntityFields.ROOM_TYPE, it) + } + queryParams.includeType?.forEach { + query.equalTo(RoomSummaryEntityFields.ROOM_TYPE, it) + } + when (queryParams.roomCategoryFilter) { + RoomCategoryFilter.ONLY_DM -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, true) + RoomCategoryFilter.ONLY_ROOMS -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, false) + RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS -> query.greaterThan(RoomSummaryEntityFields.NOTIFICATION_COUNT, 0) + RoomCategoryFilter.ALL -> Unit // nop + } + + // Timber.w("VAL: activeSpaceId : ${queryParams.activeSpaceId}") + when (queryParams.activeSpaceFilter) { + is ActiveSpaceFilter.ActiveSpace -> { + // It's annoying but for now realm java does not support querying in primitive list :/ + // https://github.com/realm/realm-java/issues/5361 + if (queryParams.activeSpaceFilter.currentSpaceId == null) { + // orphan rooms + query.isNull(RoomSummaryEntityFields.FLATTEN_PARENT_IDS) + } else { + query.contains(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, queryParams.activeSpaceFilter.currentSpaceId) + } + } + is ActiveSpaceFilter.ExcludeSpace -> { + query.not().contains(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, queryParams.activeSpaceFilter.spaceId) + } + else -> { + // nop + } + } + + if (queryParams.activeGroupId != null) { + query.contains(RoomSummaryEntityFields.GROUP_IDS, queryParams.activeGroupId!!) + } return query } + + fun getAllRoomSummaryChildOf(spaceAliasOrId: String, memberShips: List): List { + val space = getSpaceSummary(spaceAliasOrId) ?: return emptyList() + val result = ArrayList() + flattenChild(space, emptyList(), result, memberShips) + return result + } + + fun getAllRoomSummaryChildOfLive(spaceId: String, memberShips: List): LiveData> { + // we want to listen to all spaces in hierarchy and on change compute back all childs + // and switch map to listen those? + val mediatorLiveData = HierarchyLiveDataHelper(spaceId, memberShips, this).liveData() + + return Transformations.switchMap(mediatorLiveData) { allIds -> + monarchy.findAllMappedWithChanges( + { + it.where() + .`in`(RoomSummaryEntityFields.ROOM_ID, allIds.toTypedArray()) + .`in`(RoomSummaryEntityFields.MEMBERSHIP_STR, memberShips.map { it.name }.toTypedArray()) + .equalTo(RoomSummaryEntityFields.IS_DIRECT, false) + }, + { + roomSummaryMapper.map(it) + }) + } + } + + fun getFlattenOrphanRooms(): List { + return getRoomSummaries( + roomSummaryQueryParams { + memberships = Membership.activeMemberships() + excludeType = listOf(RoomType.SPACE) + roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + } + ).filter { isOrphan(it) } + } + + fun getFlattenOrphanRoomsLive(): LiveData> { + return Transformations.map( + getRoomSummariesLive(roomSummaryQueryParams { + memberships = Membership.activeMemberships() + excludeType = listOf(RoomType.SPACE) + roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + }) + ) { + it.filter { isOrphan(it) } + } + } + + private fun isOrphan(roomSummary: RoomSummary): Boolean { + if (roomSummary.roomType == RoomType.SPACE && roomSummary.membership.isActive()) { + return false + } + // all parents line should be orphan + roomSummary.spaceParents?.forEach { info -> + if (info.roomSummary != null && !info.roomSummary.membership.isLeft()) { + if (!isOrphan(info.roomSummary)) { + return false + } + } + } + + // it may not have a parent relation but could be a child of some other.... + for (spaceSummary in getSpaceSummaries(spaceSummaryQueryParams { memberships = Membership.activeMemberships() })) { + if (spaceSummary.spaceChildren?.any { it.childRoomId == roomSummary.roomId } == true) { + return false + } + } + + return true + } + + fun flattenChild(current: RoomSummary, parenting: List, output: MutableList, memberShips: List) { + current.spaceChildren?.sortedBy { it.order ?: it.name }?.forEach { childInfo -> + if (childInfo.roomType == RoomType.SPACE) { + // Add recursive + if (!parenting.contains(childInfo.childRoomId)) { // avoid cycles! + getSpaceSummary(childInfo.childRoomId)?.let { subSpace -> + if (memberShips.isEmpty() || memberShips.contains(subSpace.membership)) { + flattenChild(subSpace, parenting + listOf(current.roomId), output, memberShips) + } + } + } + } else if (childInfo.isKnown) { + getRoomSummary(childInfo.childRoomId)?.let { + if (memberShips.isEmpty() || memberShips.contains(it.membership)) { + if (!it.isDirect) { + output.add(it) + } + } + } + } + } + } + + fun flattenSubSpace(current: RoomSummary, + parenting: List, + output: MutableList, + memberShips: List, + includeCurrent: Boolean = true) { + if (includeCurrent) { + output.add(current) + } + current.spaceChildren?.sortedBy { it.order ?: it.name }?.forEach { + if (it.roomType == RoomType.SPACE) { + // Add recursive + if (!parenting.contains(it.childRoomId)) { // avoid cycles! + getSpaceSummary(it.childRoomId)?.let { subSpace -> + if (memberShips.isEmpty() || memberShips.contains(subSpace.membership)) { + output.add(subSpace) + flattenSubSpace(subSpace, parenting + listOf(current.roomId), output, memberShips) + } + } + } + } + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt index 7913bf71a2..f580a7f354 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.session.room.summary import io.realm.Realm +import io.realm.kotlin.createObject import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.toModel @@ -25,6 +26,8 @@ import org.matrix.android.sdk.api.session.room.model.RoomAliasesContent import org.matrix.android.sdk.api.session.room.model.RoomCanonicalAliasContent import org.matrix.android.sdk.api.session.room.model.RoomNameContent import org.matrix.android.sdk.api.session.room.model.RoomTopicContent +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.internal.crypto.EventDecryptor import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM @@ -34,29 +37,40 @@ import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventEntityFields +import org.matrix.android.sdk.internal.database.model.GroupSummaryEntity import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntityFields import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.database.model.SpaceChildSummaryEntity +import org.matrix.android.sdk.internal.database.model.SpaceParentSummaryEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.query.findAllInRoomWithSendStates import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.getOrNull import org.matrix.android.sdk.internal.database.query.isEventRead +import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.database.query.whereType import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.extensions.clearWith +import org.matrix.android.sdk.internal.query.process import org.matrix.android.sdk.internal.session.room.RoomAvatarResolver import org.matrix.android.sdk.internal.session.room.membership.RoomDisplayNameResolver import org.matrix.android.sdk.internal.session.room.membership.RoomMemberHelper +import org.matrix.android.sdk.internal.session.room.relationship.RoomChildRelationInfo +import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource import org.matrix.android.sdk.internal.session.sync.model.RoomSyncSummary import org.matrix.android.sdk.internal.session.sync.model.RoomSyncUnreadNotifications import timber.log.Timber import javax.inject.Inject +import kotlin.system.measureTimeMillis internal class RoomSummaryUpdater @Inject constructor( @UserId private val userId: String, private val roomDisplayNameResolver: RoomDisplayNameResolver, private val roomAvatarResolver: RoomAvatarResolver, private val eventDecryptor: EventDecryptor, - private val crossSigningService: DefaultCrossSigningService) { + private val crossSigningService: DefaultCrossSigningService, + private val stateEventDataSource: StateEventDataSource) { fun update(realm: Realm, roomId: String, @@ -89,6 +103,11 @@ internal class RoomSummaryUpdater @Inject constructor( val lastTopicEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_TOPIC, stateKey = "")?.root val lastCanonicalAliasEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_CANONICAL_ALIAS, stateKey = "")?.root val lastAliasesEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_ALIASES, stateKey = "")?.root + val roomCreateEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_CREATE, stateKey = "")?.root + + val roomType = ContentMapper.map(roomCreateEvent?.content).toModel()?.type + roomSummaryEntity.roomType = roomType + Timber.v("## Space: Updating summary room [$roomId] roomType: [$roomType]") // Don't use current state for this one as we are only interested in having MXCRYPTO_ALGORITHM_MEGOLM event in the room val encryptionEvent = EventEntity.whereType(realm, roomId = roomId, type = EventType.STATE_ROOM_ENCRYPTION) @@ -163,4 +182,233 @@ internal class RoomSummaryUpdater @Inject constructor( roomSummaryEntity.updateHasFailedSending() roomSummaryEntity.latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) } + + /** + * Should be called at the end of the room sync, to check and validate all parent/child relations + */ + fun validateSpaceRelationship(realm: Realm) { + measureTimeMillis { + val lookupMap = realm.where(RoomSummaryEntity::class.java) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .equalTo(RoomSummaryEntityFields.IS_DIRECT, false) + // we order by roomID to be consistent when breaking parent/child cycles + .sort(RoomSummaryEntityFields.ROOM_ID) + .findAll().map { + it.flattenParentIds = null + it to emptyList().toMutableSet() + } + .toMap() + + lookupMap.keys.forEach { lookedUp -> + if (lookedUp.roomType == RoomType.SPACE) { + // get childrens + + lookedUp.children.clearWith { it.deleteFromRealm() } + + RoomChildRelationInfo(realm, lookedUp.roomId).getDirectChildrenDescriptions().forEach { child -> + + lookedUp.children.add( + realm.createObject().apply { + this.childRoomId = child.roomId + this.childSummaryEntity = RoomSummaryEntity.where(realm, child.roomId).findFirst() + this.order = child.order + this.autoJoin = child.autoJoin + this.viaServers.addAll(child.viaServers) + } + ) + + RoomSummaryEntity.where(realm, child.roomId) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .findFirst() + ?.let { childSum -> + lookupMap.entries.firstOrNull { it.key.roomId == lookedUp.roomId }?.let { entry -> + if (entry.value.indexOfFirst { it.roomId == childSum.roomId } == -1) { + // add looked up as a parent + entry.value.add(childSum) + } + } + } + } + } else { + lookedUp.parents.clearWith { it.deleteFromRealm() } + // can we check parent relations here?? + RoomChildRelationInfo(realm, lookedUp.roomId).getParentDescriptions() + .map { parentInfo -> + + lookedUp.parents.add( + realm.createObject().apply { + this.parentRoomId = parentInfo.roomId + this.parentSummaryEntity = RoomSummaryEntity.where(realm, parentInfo.roomId).findFirst() + this.canonical = parentInfo.canonical + this.viaServers.addAll(parentInfo.viaServers) + } + ) + + RoomSummaryEntity.where(realm, parentInfo.roomId) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .findFirst() + ?.let { parentSum -> + if (lookupMap[parentSum]?.indexOfFirst { it.roomId == lookedUp.roomId } == -1) { + // add lookedup as a parent + lookupMap[parentSum]?.add(lookedUp) + } + } + } + } + } + + // Simple algorithm to break cycles + // Need more work to decide how to break, probably need to be as consistent as possible + // and also find best way to root the tree + + val graph = Graph() + lookupMap + // focus only on joined spaces, as room are just leaf + .filter { it.key.roomType == RoomType.SPACE && it.key.membership == Membership.JOIN } + .forEach { (sum, children) -> + graph.getOrCreateNode(sum.roomId) + children.forEach { + graph.addEdge(it.roomId, sum.roomId) + } + } + + val backEdges = graph.findBackwardEdges() + Timber.v("## SPACES: Cycle detected = ${backEdges.isNotEmpty()}") + + // break cycles + backEdges.forEach { edge -> + lookupMap.entries.find { it.key.roomId == edge.source.name }?.let { + it.value.removeAll { it.roomId == edge.destination.name } + } + } + + val acyclicGraph = graph.withoutEdges(backEdges) +// Timber.v("## SPACES: acyclicGraph $acyclicGraph") + val flattenSpaceParents = acyclicGraph.flattenDestination().map { + it.key.name to it.value.map { it.name } + }.toMap() +// Timber.v("## SPACES: flattenSpaceParents ${flattenSpaceParents.map { it.key.name to it.value.map { it.name } }.joinToString("\n") { +// it.first + ": [" + it.second.joinToString(",") + "]" +// }}") + +// Timber.v("## SPACES: lookup map ${lookupMap.map { it.key.name to it.value.map { it.name } }.toMap()}") + + lookupMap.entries + .filter { it.key.roomType == RoomType.SPACE && it.key.membership == Membership.JOIN } + .forEach { entry -> + val parent = RoomSummaryEntity.where(realm, entry.key.roomId).findFirst() + if (parent != null) { +// Timber.v("## SPACES: check hierarchy of ${parent.name} id ${parent.roomId}") +// Timber.v("## SPACES: flat known parents of ${parent.name} are ${flattenSpaceParents[parent.roomId]}") + val flattenParentsIds = (flattenSpaceParents[parent.roomId] ?: emptyList()) + listOf(parent.roomId) +// Timber.v("## SPACES: flatten known parents of children of ${parent.name} are ${flattenParentsIds}") + entry.value.forEach { child -> + RoomSummaryEntity.where(realm, child.roomId).findFirst()?.let { childSum -> + +// Timber.w("## SPACES: ${childSum.name} is ${childSum.roomId} fc: ${childSum.flattenParentIds}") +// var allParents = childSum.flattenParentIds ?: "" + if (childSum.flattenParentIds == null) childSum.flattenParentIds = "" + flattenParentsIds.forEach { + if (childSum.flattenParentIds?.contains(it) != true) { + childSum.flattenParentIds += "|$it" + } + } +// childSum.flattenParentIds = "$allParents|" + +// Timber.v("## SPACES: flatten of ${childSum.name} is ${childSum.flattenParentIds}") + } + } + } + } + + // we need also to filter DMs... + // it's more annoying as based on if the other members belong the space or not + RoomSummaryEntity.where(realm) + .equalTo(RoomSummaryEntityFields.IS_DIRECT, true) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .findAll() + .forEach { dmRoom -> + val relatedSpaces = lookupMap.keys + .filter { it.roomType == RoomType.SPACE } + .filter { + dmRoom.otherMemberIds.toList().intersect(it.otherMemberIds.toList()).isNotEmpty() + } + .map { it.roomId } + .distinct() + val flattenRelated = mutableListOf().apply { + addAll(relatedSpaces) + relatedSpaces.map { flattenSpaceParents[it] }.forEach { + if (it != null) addAll(it) + } + }.distinct() + if (flattenRelated.isEmpty()) { + dmRoom.flattenParentIds = null + } else { + dmRoom.flattenParentIds = "|${flattenRelated.joinToString("|")}|" + } +// Timber.v("## SPACES: flatten of ${dmRoom.otherMemberIds.joinToString(",")} is ${dmRoom.flattenParentIds}") + } + + // Maybe a good place to count the number of notifications for spaces? + + realm.where(RoomSummaryEntity::class.java) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .equalTo(RoomSummaryEntityFields.ROOM_TYPE, RoomType.SPACE) + .findAll().forEach { space -> + // get all children + var highlightCount = 0 + var notificationCount = 0 + realm.where(RoomSummaryEntity::class.java) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, listOf(Membership.JOIN)) + .notEqualTo(RoomSummaryEntityFields.ROOM_TYPE, RoomType.SPACE) + .contains(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, space.roomId) + .findAll().forEach { + highlightCount += it.highlightCount + notificationCount += it.notificationCount + } + + space.highlightCount = highlightCount + space.notificationCount = notificationCount + } + // xxx invites?? + + // LEGACY GROUPS + // lets mark rooms that belongs to groups + val existingGroups = GroupSummaryEntity.where(realm).findAll() + + // For rooms + realm.where(RoomSummaryEntity::class.java) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .equalTo(RoomSummaryEntityFields.IS_DIRECT, false) + .findAll().forEach { room -> + val belongsTo = existingGroups.filter { it.roomIds.contains(room.roomId) } + room.groupIds = if (belongsTo.isEmpty()) { + null + } else { + "|${belongsTo.joinToString("|")}|" + } + } + + // For DMS + realm.where(RoomSummaryEntity::class.java) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .equalTo(RoomSummaryEntityFields.IS_DIRECT, true) + .findAll().forEach { room -> + val belongsTo = existingGroups.filter { + it.userIds.intersect(room.otherMemberIds).isNotEmpty() + } + room.groupIds = if (belongsTo.isEmpty()) { + null + } else { + "|${belongsTo.joinToString("|")}|" + } + } + }.also { + Timber.v("## SPACES: Finish checking room hierarchy in $it ms") + } + } + +// private fun isValidCanonical() : Boolean { +// +// } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt index c38dcd00a7..a7cba2fe99 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -149,7 +149,7 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri } ?: ChunkEntity.create(realm, prevToken, nextToken) - if (receivedChunk.events.isEmpty() && !receivedChunk.hasMore()) { + if (receivedChunk.events.isNullOrEmpty() && !receivedChunk.hasMore()) { handleReachEnd(realm, roomId, direction, currentChunk) } else { handlePagination(realm, roomId, direction, receivedChunk, currentChunk) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt new file mode 100644 index 0000000000..93cb9d9d34 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt @@ -0,0 +1,119 @@ +/* + * 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.space + +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.space.Space +import org.matrix.android.sdk.api.session.space.model.SpaceChildContent +import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource + +internal class DefaultSpace( + private val room: Room, + private val spaceSummaryDataSource: RoomSummaryDataSource +) : Space { + + override fun asRoom(): Room { + return room + } + + override val spaceId = room.roomId + + override suspend fun leave(reason: String?) { + return room.leave(reason) + } + + override fun spaceSummary(): RoomSummary? { + return spaceSummaryDataSource.getSpaceSummary(room.roomId) + } + + override suspend fun addChildren(roomId: String, + viaServers: List, + order: String?, + autoJoin: Boolean, + suggested: Boolean?) { + room.sendStateEvent( + eventType = EventType.STATE_SPACE_CHILD, + stateKey = roomId, + body = SpaceChildContent( + via = viaServers, + autoJoin = autoJoin, + order = order, + suggested = suggested + ).toContent() + ) + } + + override suspend fun removeChildren(roomId: String) { + val existing = room.getStateEvents(setOf(EventType.STATE_SPACE_CHILD), QueryStringValue.Equals(roomId)) + .firstOrNull() + ?.content.toModel() + ?: // should we throw here? + return + + // edit state event and set via to null + room.sendStateEvent( + eventType = EventType.STATE_SPACE_CHILD, + stateKey = roomId, + body = SpaceChildContent( + order = existing.order, + via = null, + autoJoin = existing.autoJoin + ).toContent() + ) + } + + override suspend fun setChildrenOrder(roomId: String, order: String?) { + val existing = room.getStateEvents(setOf(EventType.STATE_SPACE_CHILD), QueryStringValue.Equals(roomId)) + .firstOrNull() + ?.content.toModel() + ?: throw IllegalArgumentException("$roomId is not a child of this space") + + // edit state event and set via to null + room.sendStateEvent( + eventType = EventType.STATE_SPACE_CHILD, + stateKey = roomId, + body = SpaceChildContent( + order = order, + via = existing.via, + autoJoin = existing.autoJoin + ).toContent() + ) + } + + override suspend fun setChildrenAutoJoin(roomId: String, autoJoin: Boolean) { + val existing = room.getStateEvents(setOf(EventType.STATE_SPACE_CHILD), QueryStringValue.Equals(roomId)) + .firstOrNull() + ?.content.toModel() + ?: throw IllegalArgumentException("$roomId is not a child of this space") + + // edit state event and set via to null + room.sendStateEvent( + eventType = EventType.STATE_SPACE_CHILD, + stateKey = roomId, + body = SpaceChildContent( + order = existing.order, + via = existing.via, + autoJoin = autoJoin + ).toContent() + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt new file mode 100644 index 0000000000..8fdc563edb --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt @@ -0,0 +1,199 @@ +/* + * 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.space + +import android.net.Uri +import androidx.lifecycle.LiveData +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.GuestAccess +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent +import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomPreset +import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper +import org.matrix.android.sdk.api.session.space.CreateSpaceParams +import org.matrix.android.sdk.api.session.space.JoinSpaceResult +import org.matrix.android.sdk.api.session.space.Space +import org.matrix.android.sdk.api.session.space.SpaceService +import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams +import org.matrix.android.sdk.api.session.space.model.SpaceChildContent +import org.matrix.android.sdk.api.session.space.model.SpaceParentContent +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.session.room.RoomGetter +import org.matrix.android.sdk.internal.session.room.SpaceGetter +import org.matrix.android.sdk.internal.session.room.create.CreateRoomTask +import org.matrix.android.sdk.internal.session.room.membership.leaving.LeaveRoomTask +import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource +import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource +import org.matrix.android.sdk.internal.session.space.peeking.PeekSpaceTask +import org.matrix.android.sdk.internal.session.space.peeking.SpacePeekResult +import javax.inject.Inject + +internal class DefaultSpaceService @Inject constructor( + @UserId private val userId: String, + private val createRoomTask: CreateRoomTask, + private val joinSpaceTask: JoinSpaceTask, + private val spaceGetter: SpaceGetter, + private val roomGetter: RoomGetter, + private val roomSummaryDataSource: RoomSummaryDataSource, + private val stateEventDataSource: StateEventDataSource, + private val peekSpaceTask: PeekSpaceTask, + private val resolveSpaceInfoTask: ResolveSpaceInfoTask, + private val leaveRoomTask: LeaveRoomTask +) : SpaceService { + + override suspend fun createSpace(params: CreateSpaceParams): String { + return createRoomTask.executeRetry(params, 3) + } + + override suspend fun createSpace(name: String, topic: String?, avatarUri: Uri?, isPublic: Boolean): String { + return createSpace(CreateSpaceParams().apply { + this.name = name + this.topic = topic + this.avatarUri = avatarUri + if (isPublic) { + this.powerLevelContentOverride = (powerLevelContentOverride ?: PowerLevelsContent()).copy( + invite = 0 + ) + this.preset = CreateRoomPreset.PRESET_PUBLIC_CHAT + this.historyVisibility = RoomHistoryVisibility.WORLD_READABLE + this.guestAccess = GuestAccess.CanJoin + } else { + this.preset = CreateRoomPreset.PRESET_PRIVATE_CHAT + visibility = RoomDirectoryVisibility.PRIVATE + enableEncryption() + } + }) + } + + override fun getSpace(spaceId: String): Space? { + return spaceGetter.get(spaceId) + } + + override fun getSpaceSummariesLive(queryParams: SpaceSummaryQueryParams): LiveData> { + return roomSummaryDataSource.getSpaceSummariesLive(queryParams) + } + + override fun getSpaceSummaries(spaceSummaryQueryParams: SpaceSummaryQueryParams): List { + return roomSummaryDataSource.getSpaceSummaries(spaceSummaryQueryParams) + } + + override fun getRootSpaceSummaries(): List { + return roomSummaryDataSource.getRootSpaceSummaries() + } + + override suspend fun peekSpace(spaceId: String): SpacePeekResult { + return peekSpaceTask.execute(PeekSpaceTask.Params(spaceId)) + } + + override suspend fun querySpaceChildren(spaceId: String, + suggestedOnly: Boolean?, + autoJoinedOnly: Boolean?): Pair> { + return resolveSpaceInfoTask.execute(ResolveSpaceInfoTask.Params.withId(spaceId, suggestedOnly, autoJoinedOnly)).let { response -> + val spaceDesc = response.rooms?.firstOrNull { it.roomId == spaceId } + Pair( + first = RoomSummary( + roomId = spaceDesc?.roomId ?: spaceId, + roomType = spaceDesc?.roomType, + name = spaceDesc?.name ?: "", + displayName = spaceDesc?.name ?: "", + topic = spaceDesc?.topic ?: "", + joinedMembersCount = spaceDesc?.numJoinedMembers, + avatarUrl = spaceDesc?.avatarUrl ?: "", + encryptionEventTs = null, + typingUsers = emptyList(), + isEncrypted = false, + flattenParentIds = emptyList() + ), + second = response.rooms + ?.filter { it.roomId != spaceId } + ?.map { childSummary -> + val childStateEv = response.events + ?.firstOrNull { it.stateKey == childSummary.roomId && it.type == EventType.STATE_SPACE_CHILD } + val childStateEvContent = childStateEv?.content.toModel() + SpaceChildInfo( + childRoomId = childSummary.roomId, + isKnown = true, + roomType = childSummary.roomType, + name = childSummary.name, + topic = childSummary.topic, + avatarUrl = childSummary.avatarUrl, + order = childStateEvContent?.order, + autoJoin = childStateEvContent?.autoJoin ?: false, + viaServers = childStateEvContent?.via ?: emptyList(), + activeMemberCount = childSummary.numJoinedMembers, + parentRoomId = childStateEv?.roomId + ) + }.orEmpty() + ) + } + } + + override suspend fun joinSpace(spaceIdOrAlias: String, + reason: String?, + viaServers: List): JoinSpaceResult { + return joinSpaceTask.execute(JoinSpaceTask.Params(spaceIdOrAlias, reason, viaServers)) + } + + override suspend fun rejectInvite(spaceId: String, reason: String?) { + leaveRoomTask.execute(LeaveRoomTask.Params(spaceId, reason)) + } + +// override fun getSpaceParentsOfRoom(roomId: String): List { +// return spaceSummaryDataSource.getParentsOfRoom(roomId) +// } + + override suspend fun setSpaceParent(childRoomId: String, parentSpaceId: String, canonical: Boolean, viaServers: List) { + // Should we perform some validation here?, + // and if client want to bypass, it could use sendStateEvent directly? + if (canonical) { + // check that we can send m.child in the parent room + if (roomSummaryDataSource.getRoomSummary(parentSpaceId)?.membership != Membership.JOIN) { + throw UnsupportedOperationException("Cannot add canonical child if not member of parent") + } + val powerLevelsEvent = stateEventDataSource.getStateEvent( + roomId = parentSpaceId, + eventType = EventType.STATE_ROOM_POWER_LEVELS, + stateKey = QueryStringValue.NoCondition + ) + val powerLevelsContent = powerLevelsEvent?.content?.toModel() + ?: throw UnsupportedOperationException("Cannot add canonical child, missing powerlevel") + val powerLevelsHelper = PowerLevelsHelper(powerLevelsContent) + if (!powerLevelsHelper.isUserAllowedToSend(userId, true, EventType.STATE_SPACE_CHILD)) { + throw UnsupportedOperationException("Cannot add canonical child, not enough power level") + } + } + + val room = roomGetter.getRoom(childRoomId) + ?: throw IllegalArgumentException("Unknown Room $childRoomId") + + room.sendStateEvent( + eventType = EventType.STATE_SPACE_PARENT, + stateKey = parentSpaceId, + body = SpaceParentContent( + via = viaServers, + canonical = canonical + ).toContent() + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/JoinSpaceTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/JoinSpaceTask.kt new file mode 100644 index 0000000000..5e1b829249 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/JoinSpaceTask.kt @@ -0,0 +1,136 @@ +/* + * 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.space + +import io.realm.RealmConfiguration +import kotlinx.coroutines.TimeoutCancellationException +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.space.JoinSpaceResult +import org.matrix.android.sdk.internal.database.awaitNotEmptyResult +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.session.room.membership.joining.JoinRoomTask +import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource +import org.matrix.android.sdk.internal.task.Task +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +internal interface JoinSpaceTask : Task { + data class Params( + val roomIdOrAlias: String, + val reason: String?, + val viaServers: List = emptyList() + ) +} + +internal class DefaultJoinSpaceTask @Inject constructor( + private val joinRoomTask: JoinRoomTask, + @SessionDatabase + private val realmConfiguration: RealmConfiguration, + private val roomSummaryDataSource: RoomSummaryDataSource +) : JoinSpaceTask { + + override suspend fun execute(params: JoinSpaceTask.Params): JoinSpaceResult { + Timber.v("## Space: > Joining root space ${params.roomIdOrAlias} ...") + try { + joinRoomTask.execute(JoinRoomTask.Params( + params.roomIdOrAlias, + params.reason, + params.viaServers + )) + } catch (failure: Throwable) { + return JoinSpaceResult.Fail(failure) + } + Timber.v("## Space: < Joining root space done for ${params.roomIdOrAlias}") + // we want to wait for sync result to check for auto join rooms + + Timber.v("## Space: > Wait for post joined sync ${params.roomIdOrAlias} ...") + try { + awaitNotEmptyResult(realmConfiguration, TimeUnit.MINUTES.toMillis(2L)) { realm -> + realm.where(RoomSummaryEntity::class.java) + .apply { + if (params.roomIdOrAlias.startsWith("!")) { + equalTo(RoomSummaryEntityFields.ROOM_ID, params.roomIdOrAlias) + } else { + equalTo(RoomSummaryEntityFields.CANONICAL_ALIAS, params.roomIdOrAlias) + } + } + .equalTo(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.JOIN.name) + } + } catch (exception: TimeoutCancellationException) { + Timber.w("## Space: > Error created with timeout") + return JoinSpaceResult.PartialSuccess(emptyMap()) + } + + val errors = mutableMapOf() + Timber.v("## Space: > Sync done ...") + // after that i should have the children (? do I need to paginate to get state) + val summary = roomSummaryDataSource.getSpaceSummary(params.roomIdOrAlias) + Timber.v("## Space: Found space summary Name:[${summary?.name}] children: ${summary?.spaceChildren?.size}") + summary?.spaceChildren?.forEach { +// val childRoomSummary = it.roomSummary ?: return@forEach + Timber.v("## Space: Processing child :[${it.childRoomId}] autoJoin:${it.autoJoin}") + if (it.autoJoin) { + // I should try to join as well + if (it.roomType == RoomType.SPACE) { + // recursively join auto-joined child of this space? + when (val subspaceJoinResult = execute(JoinSpaceTask.Params(it.childRoomId, null, it.viaServers))) { + JoinSpaceResult.Success -> { + // nop + } + is JoinSpaceResult.Fail -> { + errors[it.childRoomId] = subspaceJoinResult.error + } + is JoinSpaceResult.PartialSuccess -> { + errors.putAll(subspaceJoinResult.failedRooms) + } + } + } else { + try { + Timber.v("## Space: Joining room child ${it.childRoomId}") + joinRoomTask.execute(JoinRoomTask.Params( + roomIdOrAlias = it.childRoomId, + reason = "Auto-join parent space", + viaServers = it.viaServers + )) + } catch (failure: Throwable) { + errors[it.childRoomId] = failure + Timber.e("## Space: Failed to join room child ${it.childRoomId}") + } + } + } + } + + return if (errors.isEmpty()) { + JoinSpaceResult.Success + } else { + JoinSpaceResult.PartialSuccess(errors) + } + } +} + +// try { +// awaitNotEmptyResult(realmConfiguration, TimeUnit.MINUTES.toMillis(1L)) { realm -> +// realm.where(RoomEntity::class.java) +// .equalTo(RoomEntityFields.ROOM_ID, roomId) +// } +// } catch (exception: TimeoutCancellationException) { +// throw CreateRoomFailure.CreatedWithTimeout +// } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt new file mode 100644 index 0000000000..d2be49b70b --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt @@ -0,0 +1,63 @@ +/* + * 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.space + +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 ResolveSpaceInfoTask : Task { + data class Params( + val spaceId: String, + val maxRoomPerSpace: Int?, + val limit: Int, + val batchToken: String?, + val suggestedOnly: Boolean?, + val autoJoinOnly: Boolean? + ) { + companion object { + fun withId(spaceId: String, suggestedOnly: Boolean?, autoJoinOnly: Boolean?) = + Params( + spaceId = spaceId, + maxRoomPerSpace = 10, + limit = 20, + batchToken = null, + suggestedOnly = suggestedOnly, + autoJoinOnly = autoJoinOnly + ) + } + } +} + +internal class DefaultResolveSpaceInfoTask @Inject constructor( + private val spaceApi: SpaceApi, + private val globalErrorReceiver: GlobalErrorReceiver +) : ResolveSpaceInfoTask { + override suspend fun execute(params: ResolveSpaceInfoTask.Params): SpacesResponse { + val body = SpaceSummaryParams( + maxRoomPerSpace = params.maxRoomPerSpace, + limit = params.limit, + batch = params.batchToken ?: "", + autoJoinedOnly = params.autoJoinOnly, + suggestedOnly = params.suggestedOnly + ) + return executeRequest(globalErrorReceiver) { + spaceApi.getSpaces(params.spaceId, body) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceApi.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceApi.kt new file mode 100644 index 0000000000..0fcc95fdb3 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceApi.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.space + +import org.matrix.android.sdk.internal.network.NetworkConstants +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Path + +internal interface SpaceApi { + + /** + * + * POST /_matrix/client/r0/rooms/{roomID}/spaces + * { + * "max_rooms_per_space": 5, // The maximum number of rooms/subspaces to return for a given space, if negative unbounded. default: -1. + * "auto_join_only": true, // If true, only return m.space.child events with auto_join:true, default: false, which returns all events. + * "limit": 100, // The maximum number of rooms/subspaces to return, server can override this, default: 100. + * "batch": "opaque_string" // A token to use if this is a subsequent HTTP hit, default: "". + * } + * + * Ref: + * - MSC 2946 https://github.com/matrix-org/matrix-doc/blob/kegan/spaces-summary/proposals/2946-spaces-summary.md + * - https://hackmd.io/fNYh4tjUT5mQfR1uuRzWDA + */ + @POST(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "org.matrix.msc2946/rooms/{roomId}/spaces") + suspend fun getSpaces(@Path("roomId") spaceId: String, + @Body params: SpaceSummaryParams): SpacesResponse +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceChildSummaryResponse.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceChildSummaryResponse.kt new file mode 100644 index 0000000000..5021ff638f --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceChildSummaryResponse.kt @@ -0,0 +1,96 @@ +/* + * 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.space + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class SpaceChildSummaryResponse( + /** + * The total number of state events which point to or from this room (inbound/outbound edges). + * This includes all m.space.child events in the room, in addition to m.room.parent events which point to this room as a parent. + */ + @Json(name = "num_refs") val numRefs: Int? = null, + + /** + * The room type, which is m.space for subspaces. + * It can be omitted if there is no room type in which case it should be interpreted as a normal room. + */ + @Json(name = "room_type") val roomType: String? = null, + + /** + * Aliases of the room. May be empty. + */ + @Json(name = "aliases") + val aliases: List? = null, + + /** + * The canonical alias of the room, if any. + */ + @Json(name = "canonical_alias") + val canonicalAlias: String? = null, + + /** + * The name of the room, if any. + */ + @Json(name = "name") + val name: String? = null, + + /** + * Required. The number of members joined to the room. + */ + @Json(name = "num_joined_members") + val numJoinedMembers: Int = 0, + + /** + * Required. The ID of the room. + */ + @Json(name = "room_id") + val roomId: String, + + /** + * The topic of the room, if any. + */ + @Json(name = "topic") + val topic: String? = null, + + /** + * Required. Whether the room may be viewed by guest users without joining. + */ + @Json(name = "world_readable") + val worldReadable: Boolean = false, + + /** + * Required. Whether guest users may join the room and participate in it. If they can, + * they will be subject to ordinary power level rules like any other user. + */ + @Json(name = "guest_can_join") + val guestCanJoin: Boolean = false, + + /** + * The URL for the room's avatar, if one is set. + */ + @Json(name = "avatar_url") + val avatarUrl: String? = null, + + /** + * Undocumented item + */ + @Json(name = "m.federate") + val isFederated: Boolean = false +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceModule.kt new file mode 100644 index 0000000000..87425f4af2 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceModule.kt @@ -0,0 +1,53 @@ +/* + * 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.space + +import dagger.Binds +import dagger.Module +import dagger.Provides +import org.matrix.android.sdk.internal.session.SessionScope +import org.matrix.android.sdk.internal.session.room.DefaultSpaceGetter +import org.matrix.android.sdk.internal.session.room.SpaceGetter +import org.matrix.android.sdk.internal.session.space.peeking.DefaultPeekSpaceTask +import org.matrix.android.sdk.internal.session.space.peeking.PeekSpaceTask +import retrofit2.Retrofit + +@Module +internal abstract class SpaceModule { + + @Module + companion object { + @Provides + @JvmStatic + @SessionScope + fun providesSpacesAPI(retrofit: Retrofit): SpaceApi { + return retrofit.create(SpaceApi::class.java) + } + } + + @Binds + abstract fun bindResolveSpaceTask(task: DefaultResolveSpaceInfoTask): ResolveSpaceInfoTask + + @Binds + abstract fun bindPeekSpaceTask(task: DefaultPeekSpaceTask): PeekSpaceTask + + @Binds + abstract fun bindJoinSpaceTask(task: DefaultJoinSpaceTask): JoinSpaceTask + + @Binds + abstract fun bindSpaceGetter(getter: DefaultSpaceGetter): SpaceGetter +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceSummaryParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceSummaryParams.kt new file mode 100644 index 0000000000..013db1c286 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceSummaryParams.kt @@ -0,0 +1,34 @@ +/* + * 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.space + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class SpaceSummaryParams( + /** The maximum number of rooms/subspaces to return for a given space, if negative unbounded. default: -1 */ + @Json(name = "max_rooms_per_space") val maxRoomPerSpace: Int?, + /** The maximum number of rooms/subspaces to return, server can override this, default: 100 */ + @Json(name = "limit") val limit: Int?, + /** A token to use if this is a subsequent HTTP hit, default: "". */ + @Json(name = "batch") val batch: String = "", + /** whether we should only return children with the "suggested" flag set. */ + @Json(name = "suggested_only") val suggestedOnly: Boolean?, + /** whether we should only return children with the "suggested" flag set. */ + @Json(name = "auto_join_only") val autoJoinedOnly: Boolean? +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpacesResponse.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpacesResponse.kt new file mode 100644 index 0000000000..20d63c8814 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpacesResponse.kt @@ -0,0 +1,31 @@ +/* + * 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.space + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.session.events.model.Event + +@JsonClass(generateAdapter = true) +internal data class SpacesResponse( + /** Its presence indicates that there are more results to return. */ + @Json(name = "next_batch") val nextBatch: String? = null, + /** Rooms information like name/avatar/type ... */ + @Json(name = "rooms") val rooms: List? = null, + /** These are the edges of the graph. The objects in the array are complete (or stripped?) m.room.parent or m.space.child events. */ + @Json(name = "events") val events: List? = null +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/peeking/PeekSpaceTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/peeking/PeekSpaceTask.kt new file mode 100644 index 0000000000..f6b156a6fb --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/peeking/PeekSpaceTask.kt @@ -0,0 +1,143 @@ +/* + * 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.space.peeking + +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.RoomType +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent +import org.matrix.android.sdk.api.session.room.peeking.PeekResult +import org.matrix.android.sdk.api.session.space.model.SpaceChildContent +import org.matrix.android.sdk.internal.session.room.peeking.PeekRoomTask +import org.matrix.android.sdk.internal.session.room.peeking.ResolveRoomStateTask +import org.matrix.android.sdk.internal.task.Task +import timber.log.Timber +import javax.inject.Inject + +internal interface PeekSpaceTask : Task { + data class Params( + val roomIdOrAlias: String, + // A depth limit as a simple protection against cycles + val maxDepth: Int = 4 + ) +} + +internal class DefaultPeekSpaceTask @Inject constructor( + private val peekRoomTask: PeekRoomTask, + private val resolveRoomStateTask: ResolveRoomStateTask +) : PeekSpaceTask { + + override suspend fun execute(params: PeekSpaceTask.Params): SpacePeekResult { + val peekResult = peekRoomTask.execute(PeekRoomTask.Params(params.roomIdOrAlias)) + val roomResult = peekResult as? PeekResult.Success ?: return SpacePeekResult.FailedToResolve(params.roomIdOrAlias, peekResult) + + // check the room type + // kind of duplicate cause we already did it in Peek? could we pass on the result?? + val stateEvents = try { + resolveRoomStateTask.execute(ResolveRoomStateTask.Params(roomResult.roomId)) + } catch (failure: Throwable) { + return SpacePeekResult.FailedToResolve(params.roomIdOrAlias, peekResult) + } + val isSpace = stateEvents + .lastOrNull { it.type == EventType.STATE_ROOM_CREATE && it.stateKey == "" } + ?.content + ?.toModel() + ?.type == RoomType.SPACE + + if (!isSpace) return SpacePeekResult.NotSpaceType(params.roomIdOrAlias) + + val children = peekChildren(stateEvents, 0, params.maxDepth) + + return SpacePeekResult.Success( + SpacePeekSummary( + params.roomIdOrAlias, + peekResult, + children + ) + ) + } + + private suspend fun peekChildren(stateEvents: List, depth: Int, maxDepth: Int): List { + if (depth >= maxDepth) return emptyList() + val childRoomsIds = stateEvents + .filter { + it.type == EventType.STATE_SPACE_CHILD && !it.stateKey.isNullOrEmpty() + // Children where via is not present are ignored. + && it.content?.toModel()?.via != null + } + .map { it.stateKey to it.content?.toModel() } + + Timber.v("## SPACE_PEEK: found ${childRoomsIds.size} present children") + + val spaceChildResults = mutableListOf() + childRoomsIds.forEach { entry -> + + Timber.v("## SPACE_PEEK: peeking child $entry") + // peek each child + val childId = entry.first ?: return@forEach + try { + val childPeek = peekRoomTask.execute(PeekRoomTask.Params(childId)) + + val childStateEvents = resolveRoomStateTask.execute(ResolveRoomStateTask.Params(childId)) + val createContent = childStateEvents + .lastOrNull { it.type == EventType.STATE_ROOM_CREATE && it.stateKey == "" } + ?.let { it.content?.toModel() } + + if (!childPeek.isSuccess() || createContent == null) { + Timber.v("## SPACE_PEEK: cannot peek child $entry") + // can't peek :/ + spaceChildResults.add( + SpaceChildPeekResult( + childId, childPeek, entry.second?.autoJoin, entry.second?.order + ) + ) + // continue to next child + return@forEach + } + val type = createContent.type + if (type == RoomType.SPACE) { + Timber.v("## SPACE_PEEK: subspace child $entry") + spaceChildResults.add( + SpaceSubChildPeekResult( + childId, + childPeek, + entry.second?.autoJoin, + entry.second?.order, + peekChildren(childStateEvents, depth + 1, maxDepth) + ) + ) + } else + /** if (type == RoomType.MESSAGING || type == null)*/ + { + Timber.v("## SPACE_PEEK: room child $entry") + spaceChildResults.add( + SpaceChildPeekResult( + childId, childPeek, entry.second?.autoJoin, entry.second?.order + ) + ) + } + + // let's check child info + } catch (failure: Throwable) { + // can this happen? + Timber.e(failure, "## Failed to resolve space child") + } + } + return spaceChildResults + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/peeking/SpacePeekResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/peeking/SpacePeekResult.kt new file mode 100644 index 0000000000..1df62e94e8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/peeking/SpacePeekResult.kt @@ -0,0 +1,56 @@ +/* + * 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.space.peeking + +import org.matrix.android.sdk.api.session.room.peeking.PeekResult + +// TODO Move to api package +data class SpacePeekSummary( + val idOrAlias: String, + val roomPeekResult: PeekResult.Success, + val children: List +) + +interface ISpaceChild { + val id: String + val roomPeekResult: PeekResult + val default: Boolean? + val order: String? +} + +data class SpaceChildPeekResult( + override val id: String, + override val roomPeekResult: PeekResult, + override val default: Boolean? = null, + override val order: String? = null +) : ISpaceChild + +data class SpaceSubChildPeekResult( + override val id: String, + override val roomPeekResult: PeekResult, + override val default: Boolean?, + override val order: String?, + val children: List +) : ISpaceChild + +sealed class SpacePeekResult { + abstract class SpacePeekError : SpacePeekResult() + data class FailedToResolve(val spaceId: String, val roomPeekResult: PeekResult) : SpacePeekError() + data class NotSpaceType(val spaceId: String) : SpacePeekError() + + data class Success(val summary: SpacePeekSummary): SpacePeekResult() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/ReadReceiptHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/ReadReceiptHandler.kt index e5d9217db7..fc1a2c3870 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/ReadReceiptHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/ReadReceiptHandler.kt @@ -143,7 +143,7 @@ internal class ReadReceiptHandler @Inject constructor( @Suppress("UNCHECKED_CAST") val content = dataFromFile .events - .firstOrNull { it.type == EventType.RECEIPT } + ?.firstOrNull { it.type == EventType.RECEIPT } ?.content as? ReadReceiptContent if (content == null) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/RoomSyncHandler.kt index 2bb606e921..7cebbb0192 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/RoomSyncHandler.kt @@ -95,8 +95,14 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle handleRoomSync(realm, HandlingStrategy.JOINED(roomsSyncResponse.join), isInitialSync, aggregator, reporter) handleRoomSync(realm, HandlingStrategy.INVITED(roomsSyncResponse.invite), isInitialSync, aggregator, reporter) handleRoomSync(realm, HandlingStrategy.LEFT(roomsSyncResponse.leave), isInitialSync, aggregator, reporter) + + // post room sync validation +// roomSummaryUpdater.validateSpaceRelationship(realm) } + fun postSyncSpaceHierarchyHandle(realm: Realm) { + roomSummaryUpdater.validateSpaceRelationship(realm) + } // PRIVATE METHODS ***************************************************************************** private fun handleRoomSync(realm: Realm, @@ -212,6 +218,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { + // Timber.v("## Space state event: $eventEntity") eventId = event.eventId root = eventEntity } @@ -455,7 +462,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle } private fun handleRoomAccountDataEvents(realm: Realm, roomId: String, accountData: RoomSyncAccountData) { - for (event in accountData.events) { + accountData.events?.forEach { event -> val eventType = event.getClearType() if (eventType == EventType.TAG) { val content = event.getClearContent().toModel() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt index 8e243c3443..157787c8cf 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt @@ -132,6 +132,11 @@ internal class SyncResponseHandler @Inject constructor( Timber.v("On sync completed") cryptoSyncHandler.onSyncCompleted(syncResponse) + + // post sync stuffs + monarchy.writeAsync { + roomSyncHandler.postSyncSpaceHierarchyHandle(it) + } } /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncThread.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncThread.kt index 424c24663c..de8d009892 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncThread.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncThread.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import com.squareup.moshi.JsonEncodingException +import kotlinx.coroutines.CancellationException import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.isTokenError import org.matrix.android.sdk.api.session.sync.SyncState @@ -199,7 +200,7 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, if (failure is Failure.NetworkConnection && failure.cause is SocketTimeoutException) { // Timeout are not critical Timber.v("Timeout") - } else if (failure is Failure.Cancelled) { + } else if (failure is CancellationException) { Timber.v("Cancelled") } else if (failure.isTokenError()) { // No token or invalid token, stop the thread diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncAccountData.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncAccountData.kt index 1c35d812ee..a2375507d8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncAccountData.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncAccountData.kt @@ -25,5 +25,5 @@ internal data class RoomSyncAccountData( /** * List of account data events (array of Event). */ - @Json(name = "events") val events: List = emptyList() + @Json(name = "events") val events: List? = null ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncEphemeral.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncEphemeral.kt index d59dddb3ea..f2135db6b7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncEphemeral.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncEphemeral.kt @@ -26,5 +26,5 @@ internal data class RoomSyncEphemeral( /** * List of ephemeral events (array of Event). */ - @Json(name = "events") val events: List = emptyList() + @Json(name = "events") val events: List? = null ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncState.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncState.kt index 5355b7eef1..f86f05d000 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncState.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncState.kt @@ -27,5 +27,5 @@ internal data class RoomSyncState( /** * List of state events (array of Event). The resulting state corresponds to the *start* of the timeline. */ - @Json(name = "events") val events: List = emptyList() + @Json(name = "events") val events: List? = null ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncTimeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncTimeline.kt index ddf430099a..27bbc4343f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncTimeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncTimeline.kt @@ -27,7 +27,7 @@ internal data class RoomSyncTimeline( /** * List of events (array of Event). */ - @Json(name = "events") val events: List = emptyList(), + @Json(name = "events") val events: List? = null, /** * Boolean which tells whether there are more events on the server diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/DefaultWidgetURLFormatter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/DefaultWidgetURLFormatter.kt index 0937f6d18b..f7664bf3c2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/DefaultWidgetURLFormatter.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/widgets/DefaultWidgetURLFormatter.kt @@ -17,12 +17,13 @@ package org.matrix.android.sdk.internal.session.widgets import org.matrix.android.sdk.api.MatrixConfiguration +import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.integrationmanager.IntegrationManagerConfig import org.matrix.android.sdk.api.session.integrationmanager.IntegrationManagerService import org.matrix.android.sdk.api.session.widgets.WidgetURLFormatter import org.matrix.android.sdk.api.util.appendParamToUrl import org.matrix.android.sdk.api.util.appendParamsToUrl -import org.matrix.android.sdk.internal.session.SessionLifecycleObserver +import org.matrix.android.sdk.api.session.SessionLifecycleObserver import org.matrix.android.sdk.internal.session.SessionScope import org.matrix.android.sdk.internal.session.integrationmanager.IntegrationManager import org.matrix.android.sdk.internal.session.widgets.token.GetScalarTokenTask @@ -37,12 +38,12 @@ internal class DefaultWidgetURLFormatter @Inject constructor(private val integra private lateinit var currentConfig: IntegrationManagerConfig private var whiteListedUrls: List = emptyList() - override fun onSessionStarted() { + override fun onSessionStarted(session: Session) { setupWithConfiguration() integrationManager.addListener(this) } - override fun onSessionStopped() { + override fun onSessionStopped(session: Session) { integrationManager.removeListener(this) } 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 3244212487..d741dbc966 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 @@ -22,6 +22,7 @@ import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.accountdata.UserAccountDataEvent import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes import org.matrix.android.sdk.api.session.events.model.Content @@ -34,7 +35,7 @@ import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.widgets.WidgetManagementFailure import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.internal.di.UserId -import org.matrix.android.sdk.internal.session.SessionLifecycleObserver +import org.matrix.android.sdk.api.session.SessionLifecycleObserver import org.matrix.android.sdk.internal.session.SessionScope import org.matrix.android.sdk.internal.session.integrationmanager.IntegrationManager import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource @@ -57,12 +58,12 @@ internal class WidgetManager @Inject constructor(private val integrationManager: private val lifecycleOwner: LifecycleOwner = LifecycleOwner { lifecycleRegistry } private val lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(lifecycleOwner) - override fun onSessionStarted() { + override fun onSessionStarted(session: Session) { lifecycleRegistry.currentState = Lifecycle.State.STARTED integrationManager.addListener(this) } - override fun onSessionStopped() { + override fun onSessionStopped(session: Session) { integrationManager.removeListener(this) lifecycleRegistry.currentState = Lifecycle.State.DESTROYED } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/ConfigurableTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/ConfigurableTask.kt index 97f9a0dd51..bc80cf7ee8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/ConfigurableTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/ConfigurableTask.kt @@ -37,7 +37,8 @@ internal data class ConfigurableTask( val id: UUID, val callbackThread: TaskThread, val executionThread: TaskThread, - val callback: MatrixCallback + val callback: MatrixCallback, + val maxRetryCount: Int = 0 ) : Task by task { @@ -57,7 +58,8 @@ internal data class ConfigurableTask( id = id, callbackThread = callbackThread, executionThread = executionThread, - callback = callback + callback = callback, + maxRetryCount = retryCount ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/Task.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/Task.kt index a6c80a0b1a..a5d031e02a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/Task.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/Task.kt @@ -16,7 +16,29 @@ package org.matrix.android.sdk.internal.task +import kotlinx.coroutines.delay +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.shouldBeRetried +import timber.log.Timber + internal interface Task { suspend fun execute(params: PARAMS): RESULT + + suspend fun executeRetry(params: PARAMS, remainingRetry: Int) : RESULT { + return try { + execute(params) + } catch (failure: Throwable) { + if (failure.shouldBeRetried() && remainingRetry > 0) { + Timber.d(failure, "## TASK: Retriable error") + if (failure is Failure.ServerError) { + val waitTime = failure.error.retryAfterMillis ?: 0L + Timber.d(failure, "## TASK: Quota wait time $waitTime") + delay(waitTime + 100) + } + return executeRetry(params, remainingRetry - 1) + } + throw failure + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/TaskExecutor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/TaskExecutor.kt index 478a356432..4da16eff22 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/TaskExecutor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/TaskExecutor.kt @@ -40,9 +40,9 @@ internal class TaskExecutor @Inject constructor(private val coroutineDispatchers .launch(task.callbackThread.toDispatcher()) { val resultOrFailure = runCatching { withContext(task.executionThread.toDispatcher()) { - Timber.v("Enqueue task $task") - Timber.v("Execute task $task on ${Thread.currentThread().name}") - task.execute(task.params) + Timber.v("## TASK: Enqueue task $task") + Timber.v("## TASK: Execute task $task on ${Thread.currentThread().name}") + task.executeRetry(task.params, task.maxRetryCount) } } resultOrFailure diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/FailureExt.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/FailureExt.kt new file mode 100644 index 0000000000..8c78feeac3 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/FailureExt.kt @@ -0,0 +1,36 @@ +/* + * 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.util + +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.MatrixError +import org.matrix.android.sdk.internal.di.MoshiProvider + +/** + * Try to extract and serialize a MatrixError, or default to localizedMessage + */ +internal fun Throwable.toMatrixErrorStr(): String { + return (this as? Failure.ServerError) + ?.let { + // Serialize the MatrixError in this case + val adapter = MoshiProvider.providesMoshi().adapter(MatrixError::class.java) + tryOrNull { adapter.toJson(error) } + } + ?: localizedMessage + ?: "error" +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/TemporaryFileCreator.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/TemporaryFileCreator.kt new file mode 100644 index 0000000000..2790ffba36 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/TemporaryFileCreator.kt @@ -0,0 +1,34 @@ +/* + * 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.util + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID +import javax.inject.Inject + +internal class TemporaryFileCreator @Inject constructor( + private val context: Context +) { + suspend fun create(): File { + return withContext(Dispatchers.IO) { + File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir) + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/util/GraphUtilsTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/util/GraphUtilsTest.kt new file mode 100644 index 0000000000..d28192a282 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/util/GraphUtilsTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright 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.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.MatrixTest +import org.matrix.android.sdk.internal.session.room.summary.Graph + +@FixMethodOrder(MethodSorters.JVM) +class GraphUtilsTest : MatrixTest { + + @Test + fun testCreateGraph() { + val graph = Graph() + + graph.addEdge("E", "C") + graph.addEdge("B", "A") + graph.addEdge("C", "A") + graph.addEdge("D", "C") + graph.addEdge("E", "D") + + graph.getOrCreateNode("F") + + System.out.println(graph.toString()) + + val backEdges = graph.findBackwardEdges(graph.getOrCreateNode("E")) + + assertTrue("There should not be any cycle in this graphs", backEdges.isEmpty()) + } + + @Test + fun testCycleGraph() { + val graph = Graph() + + graph.addEdge("E", "C") + graph.addEdge("B", "A") + graph.addEdge("C", "A") + graph.addEdge("D", "C") + graph.addEdge("E", "D") + + graph.getOrCreateNode("F") + + // adding loops + graph.addEdge("C", "E") + graph.addEdge("B", "B") + + System.out.println(graph.toString()) + + val backEdges = graph.findBackwardEdges(graph.getOrCreateNode("E")) + System.out.println(backEdges.joinToString(" | ") { "${it.source.name} -> ${it.destination.name}" }) + + assertEquals("There should be 2 backward edges not ${backEdges.size}", 2, backEdges.size) + + val edge1 = backEdges.find { it.source.name == "C" } + assertNotNull("There should be a back edge from C", edge1) + assertEquals("There should be a back edge C -> E", "E", edge1!!.destination.name) + + val edge2 = backEdges.find { it.source.name == "B" } + assertNotNull("There should be a back edge from B", edge2) + assertEquals("There should be a back edge C -> C", "B", edge2!!.destination.name) + + // clean the graph + val acyclicGraph = graph.withoutEdges(backEdges) + System.out.println(acyclicGraph.toString()) + + assertTrue("There should be no backward edges", acyclicGraph.findBackwardEdges(acyclicGraph.getOrCreateNode("E")).isEmpty()) + + val flatten = acyclicGraph.flattenDestination() + + assertTrue(flatten[acyclicGraph.getOrCreateNode("A")]!!.isEmpty()) + + val flattenParentsB = flatten[acyclicGraph.getOrCreateNode("B")] + assertTrue(flattenParentsB!!.size == 1) + assertTrue(flattenParentsB.contains(acyclicGraph.getOrCreateNode("A"))) + + val flattenParentsE = flatten[acyclicGraph.getOrCreateNode("E")] + assertEquals(3, flattenParentsE!!.size) + assertTrue(flattenParentsE.contains(acyclicGraph.getOrCreateNode("A"))) + assertTrue(flattenParentsE.contains(acyclicGraph.getOrCreateNode("C"))) + assertTrue(flattenParentsE.contains(acyclicGraph.getOrCreateNode("D"))) + +// System.out.println( +// buildString { +// flatten.entries.forEach { +// append("${it.key.name}: [") +// append(it.value.joinToString(",") { it.name }) +// append("]\n") +// } +// } +// ) + } +} diff --git a/multipicker/build.gradle b/multipicker/build.gradle index 26afd5fb77..5eff2ec3ec 100644 --- a/multipicker/build.gradle +++ b/multipicker/build.gradle @@ -43,7 +43,7 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'androidx.appcompat:appcompat:1.2.0' - implementation "androidx.fragment:fragment-ktx:1.3.2" + implementation "androidx.fragment:fragment-ktx:1.3.3" implementation 'androidx.exifinterface:exifinterface:1.3.2' // Log diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/AudioPicker.kt b/multipicker/src/main/java/im/vector/lib/multipicker/AudioPicker.kt index e8970d72ef..739bda7004 100644 --- a/multipicker/src/main/java/im/vector/lib/multipicker/AudioPicker.kt +++ b/multipicker/src/main/java/im/vector/lib/multipicker/AudioPicker.kt @@ -18,9 +18,8 @@ package im.vector.lib.multipicker import android.content.Context import android.content.Intent -import android.media.MediaMetadataRetriever -import android.provider.MediaStore import im.vector.lib.multipicker.entity.MultiPickerAudioType +import im.vector.lib.multipicker.utils.toMultiPickerAudioType /** * Audio file picker implementation @@ -32,48 +31,9 @@ class AudioPicker : Picker() { * Returns selected audio files or empty list if user did not select any files. */ override fun getSelectedFiles(context: Context, data: Intent?): List { - val audioList = mutableListOf() - - getSelectedUriList(data).forEach { selectedUri -> - val projection = arrayOf( - MediaStore.Audio.Media.DISPLAY_NAME, - MediaStore.Audio.Media.SIZE - ) - - context.contentResolver.query( - selectedUri, - projection, - null, - null, - null - )?.use { cursor -> - val nameColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DISPLAY_NAME) - val sizeColumn = cursor.getColumnIndex(MediaStore.Audio.Media.SIZE) - - if (cursor.moveToNext()) { - val name = cursor.getString(nameColumn) - val size = cursor.getLong(sizeColumn) - var duration = 0L - - context.contentResolver.openFileDescriptor(selectedUri, "r")?.use { pfd -> - val mediaMetadataRetriever = MediaMetadataRetriever() - mediaMetadataRetriever.setDataSource(pfd.fileDescriptor) - duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L - } - - audioList.add( - MultiPickerAudioType( - name, - size, - context.contentResolver.getType(selectedUri), - selectedUri, - duration - ) - ) - } - } + return getSelectedUriList(data).mapNotNull { selectedUri -> + selectedUri.toMultiPickerAudioType(context) } - return audioList } override fun createIntent(): Intent { diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/CameraPicker.kt b/multipicker/src/main/java/im/vector/lib/multipicker/CameraPicker.kt index 64df788e53..b1442a56e1 100644 --- a/multipicker/src/main/java/im/vector/lib/multipicker/CameraPicker.kt +++ b/multipicker/src/main/java/im/vector/lib/multipicker/CameraPicker.kt @@ -23,7 +23,7 @@ import android.provider.MediaStore import androidx.activity.result.ActivityResultLauncher import androidx.core.content.FileProvider import im.vector.lib.multipicker.entity.MultiPickerImageType -import im.vector.lib.multipicker.utils.ImageUtils +import im.vector.lib.multipicker.utils.toMultiPickerImageType import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -54,40 +54,7 @@ class CameraPicker { * or user cancelled the operation. */ fun getTakenPhoto(context: Context, photoUri: Uri): MultiPickerImageType? { - val projection = arrayOf( - MediaStore.Images.Media.DISPLAY_NAME, - MediaStore.Images.Media.SIZE - ) - - context.contentResolver.query( - photoUri, - projection, - null, - null, - null - )?.use { cursor -> - val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME) - val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE) - - if (cursor.moveToNext()) { - val name = cursor.getString(nameColumn) - val size = cursor.getLong(sizeColumn) - - val bitmap = ImageUtils.getBitmap(context, photoUri) - val orientation = ImageUtils.getOrientation(context, photoUri) - - return MultiPickerImageType( - name, - size, - context.contentResolver.getType(photoUri), - photoUri, - bitmap?.width ?: 0, - bitmap?.height ?: 0, - orientation - ) - } - } - return null + return photoUri.toMultiPickerImageType(context) } private fun createIntent(): Intent { diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/CameraVideoPicker.kt b/multipicker/src/main/java/im/vector/lib/multipicker/CameraVideoPicker.kt new file mode 100644 index 0000000000..76342b6e2e --- /dev/null +++ b/multipicker/src/main/java/im/vector/lib/multipicker/CameraVideoPicker.kt @@ -0,0 +1,81 @@ +/* + * 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.lib.multipicker + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import androidx.activity.result.ActivityResultLauncher +import androidx.core.content.FileProvider +import im.vector.lib.multipicker.entity.MultiPickerVideoType +import im.vector.lib.multipicker.utils.toMultiPickerVideoType +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Implementation of taking a video with Camera + */ +class CameraVideoPicker { + + /** + * Start camera by using a ActivityResultLauncher + * @return Uri of taken photo or null if the operation is cancelled. + */ + fun startWithExpectingFile(context: Context, activityResultLauncher: ActivityResultLauncher): Uri? { + val videoUri = createVideoUri(context) + val intent = createIntent().apply { + putExtra(MediaStore.EXTRA_OUTPUT, videoUri) + } + activityResultLauncher.launch(intent) + return videoUri + } + + /** + * Call this function from onActivityResult(int, int, Intent). + * @return Taken photo or null if request code is wrong + * or result code is not Activity.RESULT_OK + * or user cancelled the operation. + */ + fun getTakenVideo(context: Context, videoUri: Uri): MultiPickerVideoType? { + return videoUri.toMultiPickerVideoType(context) + } + + private fun createIntent(): Intent { + return Intent(MediaStore.ACTION_VIDEO_CAPTURE) + } + + companion object { + fun createVideoUri(context: Context): Uri { + val file = createVideoFile(context) + val authority = context.packageName + ".multipicker.fileprovider" + return FileProvider.getUriForFile(context, authority, file) + } + + private fun createVideoFile(context: Context): File { + val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val storageDir: File = context.filesDir + return File.createTempFile( + "${timeStamp}_", /* prefix */ + ".mp4", /* suffix */ + storageDir /* directory */ + ) + } + } +} diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/FilePicker.kt b/multipicker/src/main/java/im/vector/lib/multipicker/FilePicker.kt index 39bd93d03e..ec98152aa7 100644 --- a/multipicker/src/main/java/im/vector/lib/multipicker/FilePicker.kt +++ b/multipicker/src/main/java/im/vector/lib/multipicker/FilePicker.kt @@ -19,41 +19,55 @@ package im.vector.lib.multipicker import android.content.Context import android.content.Intent import android.provider.OpenableColumns +import im.vector.lib.multipicker.entity.MultiPickerBaseType import im.vector.lib.multipicker.entity.MultiPickerFileType +import im.vector.lib.multipicker.utils.isMimeTypeAudio +import im.vector.lib.multipicker.utils.isMimeTypeImage +import im.vector.lib.multipicker.utils.isMimeTypeVideo +import im.vector.lib.multipicker.utils.toMultiPickerAudioType +import im.vector.lib.multipicker.utils.toMultiPickerImageType +import im.vector.lib.multipicker.utils.toMultiPickerVideoType /** * Implementation of selecting any type of files */ -class FilePicker : Picker() { +class FilePicker : Picker() { /** * Call this function from onActivityResult(int, int, Intent). * Returns selected files or empty list if user did not select any files. */ - override fun getSelectedFiles(context: Context, data: Intent?): List { - val fileList = mutableListOf() + override fun getSelectedFiles(context: Context, data: Intent?): List { + return getSelectedUriList(data).mapNotNull { selectedUri -> + val type = context.contentResolver.getType(selectedUri) - getSelectedUriList(data).forEach { selectedUri -> - context.contentResolver.query(selectedUri, null, null, null, null) - ?.use { cursor -> - val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE) - if (cursor.moveToFirst()) { - val name = cursor.getString(nameColumn) - val size = cursor.getLong(sizeColumn) + when { + type.isMimeTypeVideo() -> selectedUri.toMultiPickerVideoType(context) + type.isMimeTypeImage() -> selectedUri.toMultiPickerImageType(context) + type.isMimeTypeAudio() -> selectedUri.toMultiPickerAudioType(context) + else -> { + // Other files + context.contentResolver.query(selectedUri, null, null, null, null) + ?.use { cursor -> + val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE) + if (cursor.moveToFirst()) { + val name = cursor.getString(nameColumn) + val size = cursor.getLong(sizeColumn) - fileList.add( MultiPickerFileType( name, size, context.contentResolver.getType(selectedUri), selectedUri ) - ) - } - } + } else { + null + } + } + } + } } - return fileList } override fun createIntent(): Intent { diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/ImagePicker.kt b/multipicker/src/main/java/im/vector/lib/multipicker/ImagePicker.kt index ce73058039..4cc2352109 100644 --- a/multipicker/src/main/java/im/vector/lib/multipicker/ImagePicker.kt +++ b/multipicker/src/main/java/im/vector/lib/multipicker/ImagePicker.kt @@ -18,9 +18,8 @@ package im.vector.lib.multipicker import android.content.Context import android.content.Intent -import android.provider.MediaStore import im.vector.lib.multipicker.entity.MultiPickerImageType -import im.vector.lib.multipicker.utils.ImageUtils +import im.vector.lib.multipicker.utils.toMultiPickerImageType /** * Image Picker implementation @@ -32,46 +31,9 @@ class ImagePicker : Picker() { * Returns selected image files or empty list if user did not select any files. */ override fun getSelectedFiles(context: Context, data: Intent?): List { - val imageList = mutableListOf() - - getSelectedUriList(data).forEach { selectedUri -> - val projection = arrayOf( - MediaStore.Images.Media.DISPLAY_NAME, - MediaStore.Images.Media.SIZE - ) - - context.contentResolver.query( - selectedUri, - projection, - null, - null, - null - )?.use { cursor -> - val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME) - val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE) - - if (cursor.moveToNext()) { - val name = cursor.getString(nameColumn) - val size = cursor.getLong(sizeColumn) - - val bitmap = ImageUtils.getBitmap(context, selectedUri) - val orientation = ImageUtils.getOrientation(context, selectedUri) - - imageList.add( - MultiPickerImageType( - name, - size, - context.contentResolver.getType(selectedUri), - selectedUri, - bitmap?.width ?: 0, - bitmap?.height ?: 0, - orientation - ) - ) - } - } + return getSelectedUriList(data).mapNotNull { selectedUri -> + selectedUri.toMultiPickerImageType(context) } - return imageList } override fun createIntent(): Intent { diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/MediaPicker.kt b/multipicker/src/main/java/im/vector/lib/multipicker/MediaPicker.kt new file mode 100644 index 0000000000..c58abde694 --- /dev/null +++ b/multipicker/src/main/java/im/vector/lib/multipicker/MediaPicker.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.lib.multipicker + +import android.content.Context +import android.content.Intent +import im.vector.lib.multipicker.entity.MultiPickerBaseMediaType +import im.vector.lib.multipicker.utils.isMimeTypeVideo +import im.vector.lib.multipicker.utils.toMultiPickerImageType +import im.vector.lib.multipicker.utils.toMultiPickerVideoType + +/** + * Image/Video Picker implementation + */ +class MediaPicker : Picker() { + + /** + * Call this function from onActivityResult(int, int, Intent). + * Returns selected image/video files or empty list if user did not select any files. + */ + override fun getSelectedFiles(context: Context, data: Intent?): List { + return getSelectedUriList(data).mapNotNull { selectedUri -> + val mimeType = context.contentResolver.getType(selectedUri) + + if (mimeType.isMimeTypeVideo()) { + selectedUri.toMultiPickerVideoType(context) + } else { + // Assume it's an image + selectedUri.toMultiPickerImageType(context) + } + } + } + + override fun createIntent(): Intent { + return Intent(Intent.ACTION_GET_CONTENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !single) + type = "video/*, image/*" + val mimeTypes = arrayOf("image/*", "video/*") + putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) + } + } +} diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/MultiPicker.kt b/multipicker/src/main/java/im/vector/lib/multipicker/MultiPicker.kt index 7e639a9bd3..6ce50f622a 100644 --- a/multipicker/src/main/java/im/vector/lib/multipicker/MultiPicker.kt +++ b/multipicker/src/main/java/im/vector/lib/multipicker/MultiPicker.kt @@ -20,21 +20,25 @@ class MultiPicker { companion object Type { val IMAGE by lazy { MultiPicker() } + val MEDIA by lazy { MultiPicker() } val FILE by lazy { MultiPicker() } val VIDEO by lazy { MultiPicker() } val AUDIO by lazy { MultiPicker() } val CONTACT by lazy { MultiPicker() } val CAMERA by lazy { MultiPicker() } + val CAMERA_VIDEO by lazy { MultiPicker() } @Suppress("UNCHECKED_CAST") fun get(type: MultiPicker): T { return when (type) { IMAGE -> ImagePicker() as T VIDEO -> VideoPicker() as T + MEDIA -> MediaPicker() as T FILE -> FilePicker() as T AUDIO -> AudioPicker() as T CONTACT -> ContactPicker() as T CAMERA -> CameraPicker() as T + CAMERA_VIDEO -> CameraVideoPicker() as T else -> throw IllegalArgumentException("Unsupported type $type") } } diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/VideoPicker.kt b/multipicker/src/main/java/im/vector/lib/multipicker/VideoPicker.kt index dada9ac5bd..6b6bc52c1b 100644 --- a/multipicker/src/main/java/im/vector/lib/multipicker/VideoPicker.kt +++ b/multipicker/src/main/java/im/vector/lib/multipicker/VideoPicker.kt @@ -18,9 +18,8 @@ package im.vector.lib.multipicker import android.content.Context import android.content.Intent -import android.media.MediaMetadataRetriever -import android.provider.MediaStore import im.vector.lib.multipicker.entity.MultiPickerVideoType +import im.vector.lib.multipicker.utils.toMultiPickerVideoType /** * Video Picker implementation @@ -32,57 +31,9 @@ class VideoPicker : Picker() { * Returns selected video files or empty list if user did not select any files. */ override fun getSelectedFiles(context: Context, data: Intent?): List { - val videoList = mutableListOf() - - getSelectedUriList(data).forEach { selectedUri -> - val projection = arrayOf( - MediaStore.Video.Media.DISPLAY_NAME, - MediaStore.Video.Media.SIZE - ) - - context.contentResolver.query( - selectedUri, - projection, - null, - null, - null - )?.use { cursor -> - val nameColumn = cursor.getColumnIndex(MediaStore.Video.Media.DISPLAY_NAME) - val sizeColumn = cursor.getColumnIndex(MediaStore.Video.Media.SIZE) - - if (cursor.moveToNext()) { - val name = cursor.getString(nameColumn) - val size = cursor.getLong(sizeColumn) - var duration = 0L - var width = 0 - var height = 0 - var orientation = 0 - - context.contentResolver.openFileDescriptor(selectedUri, "r")?.use { pfd -> - val mediaMetadataRetriever = MediaMetadataRetriever() - mediaMetadataRetriever.setDataSource(pfd.fileDescriptor) - duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L - width = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0 - height = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0 - orientation = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0 - } - - videoList.add( - MultiPickerVideoType( - name, - size, - context.contentResolver.getType(selectedUri), - selectedUri, - width, - height, - orientation, - duration - ) - ) - } - } + return getSelectedUriList(data).mapNotNull { selectedUri -> + selectedUri.toMultiPickerVideoType(context) } - return videoList } override fun createIntent(): Intent { diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerBaseMediaType.kt b/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerBaseMediaType.kt new file mode 100644 index 0000000000..9357e22a74 --- /dev/null +++ b/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerBaseMediaType.kt @@ -0,0 +1,23 @@ +/* + * 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.lib.multipicker.entity + +interface MultiPickerBaseMediaType : MultiPickerBaseType { + val width: Int + val height: Int + val orientation: Int +} diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerImageType.kt b/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerImageType.kt index a3f30fc0d5..9efae715cd 100644 --- a/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerImageType.kt +++ b/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerImageType.kt @@ -23,7 +23,7 @@ data class MultiPickerImageType( override val size: Long, override val mimeType: String?, override val contentUri: Uri, - val width: Int, - val height: Int, - val orientation: Int -) : MultiPickerBaseType + override val width: Int, + override val height: Int, + override val orientation: Int +) : MultiPickerBaseMediaType diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerVideoType.kt b/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerVideoType.kt index 0015052c7c..20eb844c8a 100644 --- a/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerVideoType.kt +++ b/multipicker/src/main/java/im/vector/lib/multipicker/entity/MultiPickerVideoType.kt @@ -23,8 +23,8 @@ data class MultiPickerVideoType( override val size: Long, override val mimeType: String?, override val contentUri: Uri, - val width: Int, - val height: Int, - val orientation: Int, + override val width: Int, + override val height: Int, + override val orientation: Int, val duration: Long -) : MultiPickerBaseType +) : MultiPickerBaseMediaType diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/utils/ContentResolverUtil.kt b/multipicker/src/main/java/im/vector/lib/multipicker/utils/ContentResolverUtil.kt new file mode 100644 index 0000000000..a1982b0bbc --- /dev/null +++ b/multipicker/src/main/java/im/vector/lib/multipicker/utils/ContentResolverUtil.kt @@ -0,0 +1,152 @@ +/* + * 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.lib.multipicker.utils + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.provider.MediaStore +import im.vector.lib.multipicker.entity.MultiPickerAudioType +import im.vector.lib.multipicker.entity.MultiPickerImageType +import im.vector.lib.multipicker.entity.MultiPickerVideoType + +internal fun Uri.toMultiPickerImageType(context: Context): MultiPickerImageType? { + val projection = arrayOf( + MediaStore.Images.Media.DISPLAY_NAME, + MediaStore.Images.Media.SIZE + ) + + return context.contentResolver.query( + this, + projection, + null, + null, + null + )?.use { cursor -> + val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME) + val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE) + + if (cursor.moveToNext()) { + val name = cursor.getString(nameColumn) + val size = cursor.getLong(sizeColumn) + + val bitmap = ImageUtils.getBitmap(context, this) + val orientation = ImageUtils.getOrientation(context, this) + + MultiPickerImageType( + name, + size, + context.contentResolver.getType(this), + this, + bitmap?.width ?: 0, + bitmap?.height ?: 0, + orientation + ) + } else { + null + } + } +} + +internal fun Uri.toMultiPickerVideoType(context: Context): MultiPickerVideoType? { + val projection = arrayOf( + MediaStore.Video.Media.DISPLAY_NAME, + MediaStore.Video.Media.SIZE + ) + + return context.contentResolver.query( + this, + projection, + null, + null, + null + )?.use { cursor -> + val nameColumn = cursor.getColumnIndex(MediaStore.Video.Media.DISPLAY_NAME) + val sizeColumn = cursor.getColumnIndex(MediaStore.Video.Media.SIZE) + + if (cursor.moveToNext()) { + val name = cursor.getString(nameColumn) + val size = cursor.getLong(sizeColumn) + var duration = 0L + var width = 0 + var height = 0 + var orientation = 0 + + context.contentResolver.openFileDescriptor(this, "r")?.use { pfd -> + val mediaMetadataRetriever = MediaMetadataRetriever() + mediaMetadataRetriever.setDataSource(pfd.fileDescriptor) + duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L + width = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0 + height = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0 + orientation = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0 + } + + MultiPickerVideoType( + name, + size, + context.contentResolver.getType(this), + this, + width, + height, + orientation, + duration + ) + } else { + null + } + } +} + +internal fun Uri.toMultiPickerAudioType(context: Context): MultiPickerAudioType? { + val projection = arrayOf( + MediaStore.Audio.Media.DISPLAY_NAME, + MediaStore.Audio.Media.SIZE + ) + + return context.contentResolver.query( + this, + projection, + null, + null, + null + )?.use { cursor -> + val nameColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DISPLAY_NAME) + val sizeColumn = cursor.getColumnIndex(MediaStore.Audio.Media.SIZE) + + if (cursor.moveToNext()) { + val name = cursor.getString(nameColumn) + val size = cursor.getLong(sizeColumn) + var duration = 0L + + context.contentResolver.openFileDescriptor(this, "r")?.use { pfd -> + val mediaMetadataRetriever = MediaMetadataRetriever() + mediaMetadataRetriever.setDataSource(pfd.fileDescriptor) + duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L + } + + MultiPickerAudioType( + name, + size, + context.contentResolver.getType(this), + this, + duration + ) + } else { + null + } + } +} diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/utils/MimeTypeUtil.kt b/multipicker/src/main/java/im/vector/lib/multipicker/utils/MimeTypeUtil.kt new file mode 100644 index 0000000000..fc82d03dc5 --- /dev/null +++ b/multipicker/src/main/java/im/vector/lib/multipicker/utils/MimeTypeUtil.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.lib.multipicker.utils + +internal fun String?.isMimeTypeImage() = this?.startsWith("image/") == true +internal fun String?.isMimeTypeVideo() = this?.startsWith("video/") == true +internal fun String?.isMimeTypeAudio() = this?.startsWith("audio/") == true diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt index 5a53ececec..8f9cc852a7 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===94 +enum class===99 ### Do not import temporary legacy classes import org.matrix.android.sdk.internal.legacy.riot===3 diff --git a/vector/build.gradle b/vector/build.gradle index 760a5d0743..3ecdcea524 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -14,7 +14,7 @@ kapt { // Note: 2 digits max for each value ext.versionMajor = 1 ext.versionMinor = 1 -ext.versionPatch = 5 +ext.versionPatch = 7 static def getGitTimestamp() { def cmd = 'git show -s --format=%ct' @@ -64,9 +64,9 @@ static def gitBranchName() { } } -// For Google Play build, build on any other branch than master will have a "-dev" suffix +// For Google Play build, build on any other branch than main will have a "-dev" suffix static def getGplayVersionSuffix() { - if (gitBranchName() == "master") { + if (gitBranchName() == "main") { return "" } else { return "-dev" @@ -119,7 +119,7 @@ android { 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. + // Other branches (main, features, etc.) will have version code based on application version. versionCode project.getVersionCode() // Required for sonar analysis @@ -290,14 +290,14 @@ android { dependencies { - def epoxy_version = '4.4.4' - def fragment_version = '1.3.2' + def epoxy_version = '4.5.0' + def fragment_version = '1.3.3' def arrow_version = "0.8.2" def markwon_version = '4.1.2' - def big_image_viewer_version = '1.7.1' + def big_image_viewer_version = '1.8.0' def glide_version = '4.12.0' def moshi_version = '1.12.0' - def daggerVersion = '2.34' + def daggerVersion = '2.35' def autofill_version = "1.1.0" def work_version = '2.5.0' def arch_version = '2.1.0' @@ -333,6 +333,7 @@ dependencies { implementation "com.squareup.moshi:moshi-adapters:$moshi_version" implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1" kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version" // Log @@ -342,7 +343,7 @@ dependencies { implementation 'com.facebook.stetho:stetho:1.6.0' // Phone number https://github.com/google/libphonenumber - implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.21' + implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.22' // rx implementation 'io.reactivex.rxjava2:rxkotlin:2.4.0' @@ -388,7 +389,7 @@ dependencies { implementation 'androidx.browser:browser:1.3.0' // Passphrase strength helper - implementation 'com.nulab-inc:zxcvbn:1.4.0' + implementation 'com.nulab-inc:zxcvbn:1.5.0' //Alerter implementation 'com.tapadoo.android:alerter:7.0.1' diff --git a/vector/sampledata/matrix.json b/vector/sampledata/matrix.json index 5328ec81b7..c69e0201ad 100644 --- a/vector/sampledata/matrix.json +++ b/vector/sampledata/matrix.json @@ -6,6 +6,7 @@ "message": "William Shakespeare (bapt. 26 April 1564 – 23 April 1616) was an English poet, playwright and actor, widely regarded as the greatest writer in the English language and the world's greatest dramatist. He is often called England's national poet and the \"Bard of Avon\". His extant works, including collaborations, consist of approximately 39 plays, 154 sonnets, two long narrative poems, and a few other verses, some of uncertain authorship. His plays have been translated into every major living language and are performed more often than those of any other playwright.\n\nShakespeare was born and raised in Stratford-upon-Avon, Warwickshire. At the age of 18, he married Anne Hathaway, with whom he had three children: Susanna and twins Hamnet and Judith. Sometime between 1585 and 1592, he began a successful career in London as an actor, writer, and part-owner of a playing company called the Lord Chamberlain's Men, later known as the King's Men. At age 49 (around 1613), he appears to have retired to Stratford, where he died three years later. Few records of Shakespeare's private life survive; this has stimulated considerable speculation about such matters as his physical appearance, his sexuality, his religious beliefs, and whether the works attributed to him were written by others. Such theories are often criticised for failing to adequately note that few records survive of most commoners of the period.\n\nShakespeare produced most of his known works between 1589 and 1613. His early plays were primarily comedies and histories and are regarded as some of the best work produced in these genres. Until about 1608, he wrote mainly tragedies, among them Hamlet, Othello, King Lear, and Macbeth, all considered to be among the finest works in the English language. In the last phase of his life, he wrote tragicomedies (also known as romances) and collaborated with other playwrights.\n\nMany of Shakespeare's plays were published in editions of varying quality and accuracy in his lifetime. However, in 1623, two fellow actors and friends of Shakespeare's, John Heminges and Henry Condell, published a more definitive text known as the First Folio, a posthumous collected edition of Shakespeare's dramatic works that included all but two of his plays. The volume was prefaced with a poem by Ben Jonson, in which Jonson presciently hails Shakespeare in a now-famous quote as \"not of an age, but for all time\".\n\nThroughout the 20th and 21st centuries, Shakespeare's works have been continually adapted and rediscovered by new movements in scholarship and performance. His plays remain popular and are studied, performed, and reinterpreted through various cultural and political contexts around the world.", "roomName": "Matrix HQ", "roomAlias": "#matrix:matrix.org", + "spaceName": "Runner's world", "roomTopic": "Welcome to Matrix HQ! Here is the rest of the room topic, with a https://www.example.org url and a phone number: 0102030405 which should not be clickable." }, { @@ -14,6 +15,7 @@ "message": "Hello!", "roomName": "Room name very loooooooong with some details", "roomAlias": "#matrix:matrix.org", + "spaceName": "Matrix Org", "roomTopic": "Room topic very loooooooong with some details" }, { @@ -22,6 +24,7 @@ "message": "How are you?", "roomName": "Room name very loooooooong with some details", "roomAlias": "#matrix:matrix.org", + "spaceName": "Rennes", "roomTopic": "Room topic very loooooooong with some details" }, { @@ -30,6 +33,7 @@ "message": "Great weather today!", "roomName": "Room name very loooooooong with some details", "roomAlias": "#matrix:matrix.org", + "spaceName": "Est London", "roomTopic": "Room topic very loooooooong with some details" }, { @@ -38,6 +42,7 @@ "message": "Let's do a picnic", "roomName": "Room name very loooooooong with some details", "roomAlias": "#matrix:matrix.org", + "spaceName": "Element HQ", "roomTopic": "Room topic very loooooooong with some details" }, { @@ -46,6 +51,7 @@ "message": "Yes, great idea", "roomName": "Room name very loooooooong with some details", "roomAlias": "#matrix:matrix.org", + "spaceName": "My Company", "roomTopic": "Room topic very loooooooong with some details" } ] diff --git a/vector/src/debug/res/layout/item_sas_emoji.xml b/vector/src/debug/res/layout/item_sas_emoji.xml index 53fd448f90..fc56bc1948 100644 --- a/vector/src/debug/res/layout/item_sas_emoji.xml +++ b/vector/src/debug/res/layout/item_sas_emoji.xml @@ -39,7 +39,7 @@ android:layout_height="wrap_content" android:layout_marginStart="8dp" android:textSize="16sp" - tools:text="@string/verification_emoji_wrench" /> + tools:text="@string/verification_emoji_spanner" /> + tools:text="verification_emoji_spanner" /> diff --git a/vector/src/gplay/java/im/vector/app/gplay/features/settings/troubleshoot/TestPushFromPushGateway.kt b/vector/src/gplay/java/im/vector/app/gplay/features/settings/troubleshoot/TestPushFromPushGateway.kt index 015754145f..15c7e88bac 100644 --- a/vector/src/gplay/java/im/vector/app/gplay/features/settings/troubleshoot/TestPushFromPushGateway.kt +++ b/vector/src/gplay/java/im/vector/app/gplay/features/settings/troubleshoot/TestPushFromPushGateway.kt @@ -24,9 +24,11 @@ import im.vector.app.core.pushers.PushersManager import im.vector.app.core.resources.StringProvider import im.vector.app.features.settings.troubleshoot.TroubleshootTest import im.vector.app.push.fcm.FcmHelper +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.matrix.android.sdk.api.session.pushers.PushGatewayFailure import javax.inject.Inject @@ -40,33 +42,46 @@ class TestPushFromPushGateway @Inject constructor(private val context: AppCompat : TroubleshootTest(R.string.settings_troubleshoot_test_push_loop_title) { private var action: Job? = null + private var pushReceived: Boolean = false override fun perform(activityResultLauncher: ActivityResultLauncher) { + pushReceived = false val fcmToken = FcmHelper.getFcmToken(context) ?: run { status = TestStatus.FAILED return } action = GlobalScope.launch { - status = runCatching { pushersManager.testPush(fcmToken) } - .fold( - { - // Wait for the push to be received - description = stringProvider.getString(R.string.settings_troubleshoot_test_push_loop_waiting_for_push) - TestStatus.RUNNING - }, - { - description = if (it is PushGatewayFailure.PusherRejected) { - stringProvider.getString(R.string.settings_troubleshoot_test_push_loop_failed) - } else { - errorFormatter.toHumanReadable(it) + val result = runCatching { pushersManager.testPush(fcmToken) } + + withContext(Dispatchers.Main) { + status = result + .fold( + { + if (pushReceived) { + // Push already received (race condition) + description = stringProvider.getString(R.string.settings_troubleshoot_test_push_loop_success) + TestStatus.SUCCESS + } else { + // Wait for the push to be received + description = stringProvider.getString(R.string.settings_troubleshoot_test_push_loop_waiting_for_push) + TestStatus.RUNNING + } + }, + { + description = if (it is PushGatewayFailure.PusherRejected) { + stringProvider.getString(R.string.settings_troubleshoot_test_push_loop_failed) + } else { + errorFormatter.toHumanReadable(it) + } + TestStatus.FAILED } - TestStatus.FAILED - } - ) + ) + } } } override fun onPushReceived() { + pushReceived = true description = stringProvider.getString(R.string.settings_troubleshoot_test_push_loop_success) status = TestStatus.SUCCESS } diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 07606d315c..ea4ea9fd12 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -28,6 +28,9 @@ + + + + + + + + + + + + + + + + + + + + + + >(Option.empty()) + + val selectedRoomGroupingObservable = selectedSpaceDataSource.observe() + + fun getCurrentRoomGroupingMethod(): RoomGroupingMethod? = selectedSpaceDataSource.currentValue?.orNull() + + fun setCurrentSpace(spaceId: String?, session: Session? = null) { + val uSession = session ?: activeSessionHolder.getSafeActiveSession() + if (selectedSpaceDataSource.currentValue?.orNull() is RoomGroupingMethod.BySpace + && spaceId == selectedSpaceDataSource.currentValue?.orNull()?.space()?.roomId) return + val spaceSum = spaceId?.let { uSession?.getRoomSummary(spaceId) } + selectedSpaceDataSource.post(Option.just(RoomGroupingMethod.BySpace(spaceSum))) + if (spaceId != null) { + GlobalScope.launch(Dispatchers.IO) { + tryOrNull { + uSession?.getRoom(spaceId)?.loadRoomMembersIfNeeded() + } + } + } + } + + fun setCurrentGroup(groupId: String?, session: Session? = null) { + val uSession = session ?: activeSessionHolder.getSafeActiveSession() + if (selectedSpaceDataSource.currentValue?.orNull() is RoomGroupingMethod.ByLegacyGroup + && groupId == selectedSpaceDataSource.currentValue?.orNull()?.group()?.groupId) return + val activeGroup = groupId?.let { uSession?.getGroupSummary(groupId) } + selectedSpaceDataSource.post(Option.just(RoomGroupingMethod.ByLegacyGroup(activeGroup))) + if (groupId != null) { + GlobalScope.launch { + tryOrNull { + uSession?.getGroup(groupId)?.fetchGroupData() + } + } + } + } + + init { + sessionDataSource.observe() + .distinctUntilChanged() + .subscribe { + // sessionDataSource could already return a session while acitveSession holder still returns null + it.orNull()?.let { session -> + if (uiStateRepository.isGroupingMethodSpace(session.sessionId)) { + setCurrentSpace(uiStateRepository.getSelectedSpace(session.sessionId), session) + } else { + setCurrentGroup(uiStateRepository.getSelectedGroup(session.sessionId), session) + } + } + }.also { + compositeDisposable.add(it) + } + } + + fun safeActiveSpaceId(): String? { + return (selectedSpaceDataSource.currentValue?.orNull() as? RoomGroupingMethod.BySpace)?.spaceSummary?.roomId + } + + fun safeActiveGroupId(): String? { + return (selectedSpaceDataSource.currentValue?.orNull() as? RoomGroupingMethod.ByLegacyGroup)?.groupSummary?.groupId + } + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) fun entersForeground() { observeUserAccountData() @@ -50,6 +133,17 @@ class AppStateHandler @Inject constructor( @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) fun entersBackground() { compositeDisposable.clear() + val session = activeSessionHolder.getSafeActiveSession() ?: return + when (val currentMethod = selectedSpaceDataSource.currentValue?.orNull() ?: RoomGroupingMethod.BySpace(null)) { + is RoomGroupingMethod.BySpace -> { + uiStateRepository.storeGroupingMethod(true, session.sessionId) + uiStateRepository.storeSelectedSpace(currentMethod.spaceSummary?.roomId, session.sessionId) + } + is RoomGroupingMethod.ByLegacyGroup -> { + uiStateRepository.storeGroupingMethod(false, session.sessionId) + uiStateRepository.storeSelectedGroup(currentMethod.groupSummary?.groupId, session.sessionId) + } + } } private fun observeUserAccountData() { 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 430aee5468..c685231756 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 @@ -51,7 +51,6 @@ 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 import im.vector.app.features.home.HomeDetailFragment import im.vector.app.features.home.HomeDrawerFragment import im.vector.app.features.home.LoadingFragment @@ -72,6 +71,8 @@ import im.vector.app.features.login.LoginSplashFragment import im.vector.app.features.login.LoginWaitForEmailFragment import im.vector.app.features.login.LoginWebFragment import im.vector.app.features.login.terms.LoginTermsFragment +import im.vector.app.features.matrixto.MatrixToRoomSpaceFragment +import im.vector.app.features.matrixto.MatrixToUserFragment import im.vector.app.features.pin.PinFragment import im.vector.app.features.qrcode.QrCodeScannerFragment import im.vector.app.features.reactions.EmojiChooserFragment @@ -117,6 +118,15 @@ import im.vector.app.features.settings.push.PushRulesFragment import im.vector.app.features.settings.threepids.ThreePidsSettingsFragment import im.vector.app.features.share.IncomingShareFragment import im.vector.app.features.signout.soft.SoftLogoutFragment +import im.vector.app.features.spaces.SpaceListFragment +import im.vector.app.features.spaces.create.ChoosePrivateSpaceTypeFragment +import im.vector.app.features.spaces.create.ChooseSpaceTypeFragment +import im.vector.app.features.spaces.create.CreateSpaceDefaultRoomsFragment +import im.vector.app.features.spaces.create.CreateSpaceDetailsFragment +import im.vector.app.features.spaces.explore.SpaceDirectoryFragment +import im.vector.app.features.spaces.manage.SpaceAddRoomFragment +import im.vector.app.features.spaces.people.SpacePeopleFragment +import im.vector.app.features.spaces.preview.SpacePreviewFragment import im.vector.app.features.terms.ReviewTermsFragment import im.vector.app.features.usercode.ShowUserCodeFragment import im.vector.app.features.userdirectory.UserListFragment @@ -142,8 +152,8 @@ interface FragmentModule { @Binds @IntoMap - @FragmentKey(GroupListFragment::class) - fun bindGroupListFragment(fragment: GroupListFragment): Fragment + @FragmentKey(SpaceListFragment::class) + fun bindSpaceListFragment(fragment: SpaceListFragment): Fragment @Binds @IntoMap @@ -624,4 +634,54 @@ interface FragmentModule { @IntoMap @FragmentKey(RoomDevToolSendFormFragment::class) fun bindRoomDevToolSendFormFragment(fragment: RoomDevToolSendFormFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(SpacePreviewFragment::class) + fun bindSpacePreviewFragment(fragment: SpacePreviewFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(ChooseSpaceTypeFragment::class) + fun bindChooseSpaceTypeFragment(fragment: ChooseSpaceTypeFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(CreateSpaceDetailsFragment::class) + fun bindCreateSpaceDetailsFragment(fragment: CreateSpaceDetailsFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(CreateSpaceDefaultRoomsFragment::class) + fun bindCreateSpaceDefaultRoomsFragment(fragment: CreateSpaceDefaultRoomsFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(MatrixToUserFragment::class) + fun bindMatrixToUserFragment(fragment: MatrixToUserFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(MatrixToRoomSpaceFragment::class) + fun bindMatrixToRoomSpaceFragment(fragment: MatrixToRoomSpaceFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(SpaceDirectoryFragment::class) + fun bindSpaceDirectoryFragment(fragment: SpaceDirectoryFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(ChoosePrivateSpaceTypeFragment::class) + fun bindChoosePrivateSpaceTypeFragment(fragment: ChoosePrivateSpaceTypeFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(SpaceAddRoomFragment::class) + fun bindSpaceAddRoomFragment(fragment: SpaceAddRoomFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(SpacePeopleFragment::class) + fun bindSpacePeopleFragment(fragment: SpacePeopleFragment): 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 a1de892c4e..c16c602530 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 @@ -77,6 +77,13 @@ import im.vector.app.features.settings.VectorSettingsActivity import im.vector.app.features.settings.devices.DeviceVerificationInfoBottomSheet import im.vector.app.features.share.IncomingShareActivity import im.vector.app.features.signout.soft.SoftLogoutActivity +import im.vector.app.features.spaces.InviteRoomSpaceChooserBottomSheet +import im.vector.app.features.spaces.ShareSpaceBottomSheet +import im.vector.app.features.spaces.SpaceCreationActivity +import im.vector.app.features.spaces.SpaceExploreActivity +import im.vector.app.features.spaces.invite.SpaceInviteBottomSheet +import im.vector.app.features.spaces.SpaceSettingsMenuBottomSheet +import im.vector.app.features.spaces.manage.SpaceManageActivity import im.vector.app.features.terms.ReviewTermsActivity import im.vector.app.features.ui.UiStateRepository import im.vector.app.features.usercode.UserCodeActivity @@ -151,6 +158,9 @@ interface ScreenComponent { fun inject(activity: CallTransferActivity) fun inject(activity: ReAuthActivity) fun inject(activity: RoomDevToolActivity) + fun inject(activity: SpaceCreationActivity) + fun inject(activity: SpaceExploreActivity) + fun inject(activity: SpaceManageActivity) /* ========================================================================================== * BottomSheets @@ -173,6 +183,10 @@ interface ScreenComponent { fun inject(bottomSheet: CallControlsBottomSheet) fun inject(bottomSheet: SignOutBottomSheetDialogFragment) fun inject(bottomSheet: MatrixToBottomSheet) + fun inject(bottomSheet: ShareSpaceBottomSheet) + fun inject(bottomSheet: SpaceSettingsMenuBottomSheet) + fun inject(bottomSheet: InviteRoomSpaceChooserBottomSheet) + fun inject(bottomSheet: SpaceInviteBottomSheet) /* ========================================================================================== * Others 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 4b88ff6767..e5a47e872c 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 @@ -21,6 +21,7 @@ import android.content.res.Resources import dagger.BindsInstance import dagger.Component import im.vector.app.ActiveSessionDataSource +import im.vector.app.AppStateHandler import im.vector.app.EmojiCompatFontProvider import im.vector.app.EmojiCompatWrapper import im.vector.app.VectorApplication @@ -34,8 +35,8 @@ 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 -import im.vector.app.features.grouplist.SelectedGroupDataSource import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.CurrentSpaceSuggestedRoomListDataSource 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 @@ -113,7 +114,9 @@ interface VectorComponent { fun errorFormatter(): ErrorFormatter - fun selectedGroupStore(): SelectedGroupDataSource + fun appStateHandler(): AppStateHandler + + fun currentSpaceSuggestedRoomListDataSource(): CurrentSpaceSuggestedRoomListDataSource fun roomDetailPendingActionStore(): RoomDetailPendingActionStore 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 8409021845..4e07c1e2ca 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 @@ -38,6 +38,8 @@ import im.vector.app.features.roomprofile.RoomProfileSharedActionViewModel import im.vector.app.features.roomprofile.alias.detail.RoomAliasBottomSheetSharedActionViewModel import im.vector.app.features.roomprofile.settings.historyvisibility.RoomHistoryVisibilitySharedActionViewModel import im.vector.app.features.roomprofile.settings.joinrule.RoomJoinRuleSharedActionViewModel +import im.vector.app.features.spaces.SpacePreviewSharedActionViewModel +import im.vector.app.features.spaces.people.SpacePeopleSharedActionViewModel import im.vector.app.features.userdirectory.UserListSharedActionViewModel @Module @@ -142,4 +144,14 @@ interface ViewModelModule { @IntoMap @ViewModelKey(DiscoverySharedViewModel::class) fun bindDiscoverySharedViewModel(viewModel: DiscoverySharedViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(SpacePreviewSharedActionViewModel::class) + fun bindSpacePreviewSharedActionViewModel(viewModel: SpacePreviewSharedActionViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(SpacePeopleSharedActionViewModel::class) + fun bindSpacePeopleSharedActionViewModel(viewModel: SpacePeopleSharedActionViewModel): ViewModel } diff --git a/vector/src/main/java/im/vector/app/core/dialogs/PhotoOrVideoDialog.kt b/vector/src/main/java/im/vector/app/core/dialogs/PhotoOrVideoDialog.kt new file mode 100644 index 0000000000..d9188397d8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/dialogs/PhotoOrVideoDialog.kt @@ -0,0 +1,118 @@ +/* + * 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.dialogs + +import android.app.Activity +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import im.vector.app.R +import im.vector.app.databinding.DialogPhotoOrVideoBinding +import im.vector.app.features.settings.VectorPreferences + +class PhotoOrVideoDialog( + private val activity: Activity, + private val vectorPreferences: VectorPreferences +) { + + interface PhotoOrVideoDialogListener { + fun takePhoto() + fun takeVideo() + } + + interface PhotoOrVideoDialogSettingsListener { + fun onUpdated() + } + + fun show(listener: PhotoOrVideoDialogListener) { + when (vectorPreferences.getTakePhotoVideoMode()) { + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO -> listener.takePhoto() + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO -> listener.takeVideo() + /* VectorPreferences.TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK */ + else -> { + val dialogLayout = activity.layoutInflater.inflate(R.layout.dialog_photo_or_video, null) + val views = DialogPhotoOrVideoBinding.bind(dialogLayout) + + // Show option to set as default in this case + views.dialogPhotoOrVideoAsDefault.isVisible = true + // Always default to photo + views.dialogPhotoOrVideoPhoto.isChecked = true + + AlertDialog.Builder(activity) + .setTitle(R.string.option_take_photo_video) + .setView(dialogLayout) + .setPositiveButton(R.string._continue) { _, _ -> + submit(views, vectorPreferences, listener) + } + .setNegativeButton(R.string.cancel, null) + .show() + } + } + } + + private fun submit(views: DialogPhotoOrVideoBinding, + vectorPreferences: VectorPreferences, + listener: PhotoOrVideoDialogListener) { + val mode = if (views.dialogPhotoOrVideoPhoto.isChecked) { + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO + } else { + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO + } + + if (views.dialogPhotoOrVideoAsDefault.isChecked) { + vectorPreferences.setTakePhotoVideoMode(mode) + } + + when (mode) { + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO -> listener.takePhoto() + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO -> listener.takeVideo() + } + } + + fun showForSettings(listener: PhotoOrVideoDialogSettingsListener) { + val currentMode = vectorPreferences.getTakePhotoVideoMode() + + val dialogLayout = activity.layoutInflater.inflate(R.layout.dialog_photo_or_video, null) + val views = DialogPhotoOrVideoBinding.bind(dialogLayout) + + // Show option for always ask in this case + views.dialogPhotoOrVideoAlwaysAsk.isVisible = true + // Always default to photo + views.dialogPhotoOrVideoPhoto.isChecked = currentMode == VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO + views.dialogPhotoOrVideoVideo.isChecked = currentMode == VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO + views.dialogPhotoOrVideoAlwaysAsk.isChecked = currentMode == VectorPreferences.TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK + + AlertDialog.Builder(activity) + .setTitle(R.string.option_take_photo_video) + .setView(dialogLayout) + .setPositiveButton(R.string.save) { _, _ -> + submitSettings(views) + listener.onUpdated() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun submitSettings(views: DialogPhotoOrVideoBinding) { + vectorPreferences.setTakePhotoVideoMode( + when { + views.dialogPhotoOrVideoPhoto.isChecked -> VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO + views.dialogPhotoOrVideoVideo.isChecked -> VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO + else -> VectorPreferences.TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK + } + ) + } +} diff --git a/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt b/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt index 5ff7a07e3c..f05e0843e8 100644 --- a/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt +++ b/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt @@ -46,6 +46,9 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel(R.id.bottom_sheet_message_preview_avatar) val sender by bind(R.id.bottom_sheet_message_preview_sender) val body by bind(R.id.bottom_sheet_message_preview_body) + val bodyDetails by bind(R.id.bottom_sheet_message_preview_body_details) val timestamp by bind(R.id.bottom_sheet_message_preview_timestamp) val imagePreview by bind(R.id.bottom_sheet_message_preview_image) } diff --git a/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetRadioActionItem.kt b/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetRadioActionItem.kt new file mode 100644 index 0000000000..6ca1182cce --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetRadioActionItem.kt @@ -0,0 +1,80 @@ +/* + * 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.bottomsheet + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +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.setTextOrHide + +/** + * A action for bottom sheet. + */ +@EpoxyModelClass(layout = R.layout.item_bottom_sheet_radio) +abstract class BottomSheetRadioActionItem : VectorEpoxyModel() { + + @EpoxyAttribute + var title: CharSequence? = null + + @StringRes + @EpoxyAttribute + var titleRes: Int? = null + + @EpoxyAttribute + var selected = false + + @EpoxyAttribute + var description: CharSequence? = null + + @EpoxyAttribute + lateinit var listener: View.OnClickListener + + override fun bind(holder: Holder) { + super.bind(holder) + holder.view.setOnClickListener { + listener.onClick(it) + } + + if (titleRes != null) { + holder.titleText.setText(titleRes!!) + } else { + holder.titleText.text = title + } + holder.descriptionText.setTextOrHide(description) + + if (selected) { + holder.radioImage.setImageDrawable(ContextCompat.getDrawable(holder.view.context, R.drawable.ic_radio_on)) + holder.radioImage.contentDescription = holder.view.context.getString(R.string.a11y_checked) + } else { + holder.radioImage.setImageDrawable(ContextCompat.getDrawable(holder.view.context, R.drawable.ic_radio_off)) + holder.radioImage.contentDescription = holder.view.context.getString(R.string.a11y_unchecked) + } + } + + class Holder : VectorEpoxyHolder() { + val titleText by bind(R.id.actionTitle) + val descriptionText by bind(R.id.actionDescription) + val radioImage by bind(R.id.radioIcon) + } +} 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 ccb3bea25a..9f865df372 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 @@ -17,6 +17,7 @@ package im.vector.app.core.epoxy.profiles import android.view.View +import androidx.annotation.CallSuper import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import im.vector.app.core.epoxy.VectorEpoxyModel @@ -34,6 +35,7 @@ abstract class BaseProfileMatrixItem : VectorEpoxy var userEncryptionTrustLevel: RoomEncryptionTrustLevel? = null @EpoxyAttribute var clickListener: View.OnClickListener? = null + @CallSuper override fun bind(holder: T) { super.bind(holder) val bestName = matrixItem.getBestName() diff --git a/vector/src/main/java/im/vector/app/core/epoxy/profiles/ProfileMatrixItemWithPowerLevel.kt b/vector/src/main/java/im/vector/app/core/epoxy/profiles/ProfileMatrixItemWithPowerLevel.kt new file mode 100644 index 0000000000..b7fd597789 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/epoxy/profiles/ProfileMatrixItemWithPowerLevel.kt @@ -0,0 +1,41 @@ +/* + * 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.epoxy.profiles + +import android.widget.TextView +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.extensions.setTextOrHide + +@EpoxyModelClass(layout = R.layout.item_profile_matrix_item) +abstract class ProfileMatrixItemWithPowerLevel : BaseProfileMatrixItem() { + + @EpoxyAttribute var powerLevelLabel: CharSequence? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.editableView.isVisible = false + holder.powerLabel.setTextOrHide(powerLevelLabel) + } + + class Holder : ProfileMatrixItem.Holder() { + val powerLabel by bind(R.id.matrixItemPowerLevelLabel) + } +} 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 d7f003574c..0a724b62c6 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 @@ -78,6 +78,9 @@ class DefaultErrorFormatter @Inject constructor( throwable.error.code == MatrixError.M_LIMIT_EXCEEDED -> { limitExceededError(throwable.error) } + throwable.error.code == MatrixError.M_TOO_LARGE -> { + stringProvider.getString(R.string.error_file_too_big_simple) + } throwable.error.code == MatrixError.M_THREEPID_NOT_FOUND -> { stringProvider.getString(R.string.login_reset_password_error_not_found) } diff --git a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt index 63e35c1c0f..48e3a488ed 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt @@ -21,6 +21,8 @@ import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent fun TimelineEvent.canReact(): Boolean { - // Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment - return root.getClearType() == EventType.MESSAGE && root.sendState == SendState.SYNCED && !root.isRedacted() + // Only event of type EventType.MESSAGE or EventType.STICKER are supported for the moment + return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER) + && root.sendState == SendState.SYNCED + && !root.isRedacted() } diff --git a/vector/src/main/java/im/vector/app/core/glide/VectorGlideModelLoader.kt b/vector/src/main/java/im/vector/app/core/glide/VectorGlideModelLoader.kt index cbc5effe44..5fae815dfb 100644 --- a/vector/src/main/java/im/vector/app/core/glide/VectorGlideModelLoader.kt +++ b/vector/src/main/java/im/vector/app/core/glide/VectorGlideModelLoader.kt @@ -28,8 +28,10 @@ import com.bumptech.glide.signature.ObjectKey import im.vector.app.core.extensions.vectorComponent import im.vector.app.core.files.LocalFilesHelper import im.vector.app.features.media.ImageContentRenderer -import kotlinx.coroutines.GlobalScope +import im.vector.app.features.session.coroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import timber.log.Timber import java.io.IOException @@ -113,7 +115,7 @@ class VectorGlideDataFetcher(context: Context, callback.onLoadFailed(IllegalArgumentException("No File service")) } // Use the file vector service, will avoid flickering and redownload after upload - GlobalScope.launch { + activeSessionHolder.getSafeActiveSession()?.coroutineScope?.launch { val result = runCatching { fileService.downloadFile( fileName = data.filename, @@ -121,10 +123,12 @@ class VectorGlideDataFetcher(context: Context, url = data.url, elementToDecrypt = data.elementToDecrypt) } - result.fold( - { callback.onDataReady(it.inputStream()) }, - { callback.onLoadFailed(it as? Exception ?: IOException(it.localizedMessage)) } - ) + withContext(Dispatchers.Main) { + result.fold( + { callback.onDataReady(it.inputStream()) }, + { callback.onLoadFailed(it as? Exception ?: IOException(it.localizedMessage)) } + ) + } } // val url = contentUrlResolver.resolveFullSize(data.url) // ?: return diff --git a/vector/src/main/java/im/vector/app/core/mvrx/ResultExtension.kt b/vector/src/main/java/im/vector/app/core/mvrx/ResultExtension.kt new file mode 100644 index 0000000000..dfd04ea6f6 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/mvrx/ResultExtension.kt @@ -0,0 +1,33 @@ +/* + * 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.mvrx + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Success + +/** + * Note: this will be removed when upgrading to mvrx2 + */ +suspend fun runCatchingToAsync(block: suspend () -> A): Async { + return runCatching { + block.invoke() + }.fold( + { Success(it) }, + { Fail(it) } + ) +} diff --git a/vector/src/main/java/im/vector/app/core/platform/GenericIdArgs.kt b/vector/src/main/java/im/vector/app/core/platform/GenericIdArgs.kt new file mode 100644 index 0000000000..0f22ae9136 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/platform/GenericIdArgs.kt @@ -0,0 +1,28 @@ +/* + * 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.platform + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Generic argument with one String. Can be an id (ex: roomId, spaceId, callId, etc.), or anything else + */ +@Parcelize +data class GenericIdArgs( + val id: String +) : Parcelable diff --git a/vector/src/main/java/im/vector/app/core/platform/livedata/SharedPreferenceLiveData.kt b/vector/src/main/java/im/vector/app/core/platform/livedata/SharedPreferenceLiveData.kt new file mode 100644 index 0000000000..3e0733b35d --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/platform/livedata/SharedPreferenceLiveData.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.core.platform.livedata + +import android.content.SharedPreferences +import androidx.lifecycle.LiveData + +abstract class SharedPreferenceLiveData(protected val sharedPrefs: SharedPreferences, + protected val key: String, + private val defValue: T) : LiveData() { + + private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == this.key) { + value = getValueFromPreferences(key, defValue) + } + } + + abstract fun getValueFromPreferences(key: String, defValue: T): T + + override fun onActive() { + super.onActive() + value = getValueFromPreferences(key, defValue) + sharedPrefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + } + + override fun onInactive() { + sharedPrefs.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + super.onInactive() + } + + companion object { + fun booleanLiveData(sharedPrefs: SharedPreferences, key: String, defaultValue: Boolean): SharedPreferenceLiveData { + return object : SharedPreferenceLiveData(sharedPrefs, key, defaultValue) { + override fun getValueFromPreferences(key: String, defValue: Boolean): Boolean { + return this.sharedPrefs.getBoolean(key, defValue) + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/core/resources/AppNameProvider.kt b/vector/src/main/java/im/vector/app/core/resources/AppNameProvider.kt index 9874c1744f..90558e35b7 100644 --- a/vector/src/main/java/im/vector/app/core/resources/AppNameProvider.kt +++ b/vector/src/main/java/im/vector/app/core/resources/AppNameProvider.kt @@ -23,7 +23,7 @@ import javax.inject.Inject class AppNameProvider @Inject constructor(private val context: Context) { fun getAppName(): String { - try { + return try { val appPackageName = context.applicationContext.packageName val pm = context.packageManager val appInfo = pm.getApplicationInfo(appPackageName, 0) @@ -33,10 +33,10 @@ class AppNameProvider @Inject constructor(private val context: Context) { if (!appName.matches("\\A\\p{ASCII}*\\z".toRegex())) { appName = appPackageName } - return appName + appName } catch (e: Exception) { Timber.e(e, "## AppNameProvider() : failed") - return "ElementAndroid" + "ElementAndroid" } } } diff --git a/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGeneric.kt b/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGeneric.kt index f8f345f09d..e773993b21 100644 --- a/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGeneric.kt +++ b/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGeneric.kt @@ -32,7 +32,7 @@ import javax.inject.Inject /** * Generic Bottom sheet with actions */ -abstract class BottomSheetGeneric : +abstract class BottomSheetGeneric : VectorBaseBottomSheetDialogFragment(), BottomSheetGenericController.Listener { diff --git a/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericController.kt b/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericController.kt index 67347c3220..c5e0c51047 100644 --- a/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericController.kt +++ b/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericController.kt @@ -17,12 +17,11 @@ package im.vector.app.core.ui.bottomsheet import android.view.View import com.airbnb.epoxy.TypedEpoxyController -import im.vector.app.core.epoxy.dividerItem /** * Epoxy controller for generic bottom sheet actions */ -abstract class BottomSheetGenericController +abstract class BottomSheetGenericController : TypedEpoxyController() { var listener: Listener? = null @@ -43,16 +42,14 @@ abstract class BottomSheetGenericController 0 } actions.forEach { action -> - action.toBottomSheetItem() - .showIcon(showIcons) + action.toRadioBottomSheetItem() .listener(View.OnClickListener { listener?.didSelectAction(action) }) .addTo(this) } diff --git a/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericAction.kt b/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericRadioAction.kt similarity index 58% rename from vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericAction.kt rename to vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericRadioAction.kt index da48accf35..516612717a 100644 --- a/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericAction.kt +++ b/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericRadioAction.kt @@ -16,27 +16,24 @@ package im.vector.app.core.ui.bottomsheet -import androidx.annotation.DrawableRes -import im.vector.app.core.epoxy.bottomsheet.BottomSheetActionItem_ +import im.vector.app.core.epoxy.bottomsheet.BottomSheetRadioActionItem_ import im.vector.app.core.platform.VectorSharedAction /** * Parent class for a bottom sheet action */ -open class BottomSheetGenericAction( - open val title: String, - @DrawableRes open val iconResId: Int, - open val isSelected: Boolean, - open val destructive: Boolean +open class BottomSheetGenericRadioAction( + open val title: CharSequence?, + open val description: String? = null, + open val isSelected: Boolean ) : VectorSharedAction { - fun toBottomSheetItem(): BottomSheetActionItem_ { - return BottomSheetActionItem_().apply { - id("action_$title") - iconRes(iconResId) - text(title) - selected(isSelected) - destructive(destructive) + fun toRadioBottomSheetItem(): BottomSheetRadioActionItem_ { + return BottomSheetRadioActionItem_().also { + it.id("action_$title") + it.title(title) + it.selected(isSelected) + it.description(description) } } } diff --git a/vector/src/main/java/im/vector/app/core/ui/list/GenericFooterItem.kt b/vector/src/main/java/im/vector/app/core/ui/list/GenericFooterItem.kt index 2539e59ae4..e185a4dbc4 100644 --- a/vector/src/main/java/im/vector/app/core/ui/list/GenericFooterItem.kt +++ b/vector/src/main/java/im/vector/app/core/ui/list/GenericFooterItem.kt @@ -36,10 +36,10 @@ import im.vector.app.features.themes.ThemeUtils abstract class GenericFooterItem : VectorEpoxyModel() { @EpoxyAttribute - var text: String? = null + var text: CharSequence? = null @EpoxyAttribute - var style: GenericItem.STYLE = GenericItem.STYLE.NORMAL_TEXT + var style: ItemStyle = ItemStyle.NORMAL_TEXT @EpoxyAttribute var itemClickAction: GenericItem.Action? = null @@ -53,11 +53,10 @@ abstract class GenericFooterItem : VectorEpoxyModel() override fun bind(holder: Holder) { super.bind(holder) + holder.text.setTextOrHide(text) - when (style) { - GenericItem.STYLE.BIG_TEXT -> holder.text.textSize = 18f - GenericItem.STYLE.NORMAL_TEXT -> holder.text.textSize = 14f - } + holder.text.typeface = style.toTypeFace() + holder.text.textSize = style.toTextSize() holder.text.gravity = if (centered) Gravity.CENTER_HORIZONTAL else Gravity.START if (textColor != null) { 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 3a1337e78c..8a01183915 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 @@ -38,11 +38,6 @@ import im.vector.app.core.extensions.setTextOrHide @EpoxyModelClass(layout = R.layout.item_generic_list) abstract class GenericItem : VectorEpoxyModel() { - enum class STYLE { - BIG_TEXT, - NORMAL_TEXT - } - class Action(var title: String) { var perform: Runnable? = null } @@ -54,7 +49,7 @@ abstract class GenericItem : VectorEpoxyModel() { var description: CharSequence? = null @EpoxyAttribute - var style: STYLE = STYLE.NORMAL_TEXT + var style: ItemStyle = ItemStyle.NORMAL_TEXT @EpoxyAttribute @DrawableRes @@ -87,10 +82,7 @@ abstract class GenericItem : VectorEpoxyModel() { holder.titleIcon.isVisible = false } - when (style) { - STYLE.BIG_TEXT -> holder.titleText.textSize = 18f - STYLE.NORMAL_TEXT -> holder.titleText.textSize = 14f - } + holder.titleText.textSize = style.toTextSize() holder.descriptionText.setTextOrHide(description) diff --git a/vector/src/main/java/im/vector/app/core/ui/list/ItemStyle.kt b/vector/src/main/java/im/vector/app/core/ui/list/ItemStyle.kt new file mode 100644 index 0000000000..b98d29040d --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/ui/list/ItemStyle.kt @@ -0,0 +1,43 @@ +/* + * 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.list + +import android.graphics.Typeface + +enum class ItemStyle { + BIG_TEXT, + NORMAL_TEXT, + TITLE, + SUBHEADER; + + fun toTypeFace(): Typeface { + return if (this == TITLE) { + Typeface.DEFAULT_BOLD + } else { + Typeface.DEFAULT + } + } + + fun toTextSize(): Float { + return when (this) { + BIG_TEXT -> 18f + NORMAL_TEXT -> 14f + TITLE -> 20f + SUBHEADER -> 16f + } + } +} diff --git a/vector/src/main/java/im/vector/app/core/ui/views/SendStateImageView.kt b/vector/src/main/java/im/vector/app/core/ui/views/SendStateImageView.kt index 9acb82581f..308efe09b2 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/SendStateImageView.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/SendStateImageView.kt @@ -17,11 +17,13 @@ package im.vector.app.core.ui.views import android.content.Context +import android.content.res.ColorStateList import android.util.AttributeSet import androidx.appcompat.widget.AppCompatImageView import androidx.core.view.isVisible import im.vector.app.R import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration +import im.vector.app.features.themes.ThemeUtils class SendStateImageView @JvmOverloads constructor( context: Context, @@ -39,16 +41,19 @@ class SendStateImageView @JvmOverloads constructor( isVisible = when (sendState) { SendStateDecoration.SENDING_NON_MEDIA -> { setImageResource(R.drawable.ic_sending_message) + imageTintList = ColorStateList.valueOf(ThemeUtils.getColor(context, R.attr.riotx_text_tertiary)) contentDescription = context.getString(R.string.event_status_a11y_sending) true } SendStateDecoration.SENT -> { setImageResource(R.drawable.ic_message_sent) + imageTintList = ColorStateList.valueOf(ThemeUtils.getColor(context, R.attr.riotx_text_tertiary)) contentDescription = context.getString(R.string.event_status_a11y_sent) true } SendStateDecoration.FAILED -> { setImageResource(R.drawable.ic_sending_message_failed) + imageTintList = null contentDescription = context.getString(R.string.event_status_a11y_failed) true } diff --git a/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt b/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt index 2348b07c7b..b12c1b7369 100644 --- a/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt +++ b/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt @@ -29,6 +29,7 @@ import android.os.PowerManager import android.provider.Settings import android.widget.Toast import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.core.content.getSystemService import androidx.fragment.app.Fragment @@ -132,6 +133,17 @@ fun startAddGoogleAccountIntent(context: Context, activityResultLauncher: Activi } } +@RequiresApi(Build.VERSION_CODES.O) +fun startInstallFromSourceIntent(context: Context, activityResultLauncher: ActivityResultLauncher) { + try { + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) + .setData(Uri.parse(String.format("package:%s", context.packageName))) + activityResultLauncher.launch(intent) + } catch (activityNotFoundException: ActivityNotFoundException) { + context.toast(R.string.error_no_external_application_found) + } +} + fun startSharePlainTextIntent(fragment: Fragment, activityResultLauncher: ActivityResultLauncher?, chooserTitle: String?, diff --git a/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt b/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt index 84dd793172..992a85679c 100644 --- a/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt +++ b/vector/src/main/java/im/vector/app/core/utils/TextUtils.kt @@ -19,6 +19,7 @@ package im.vector.app.core.utils import android.content.Context import android.os.Build import android.text.format.Formatter +import org.threeten.bp.Duration import java.util.TreeMap object TextUtils { @@ -68,4 +69,15 @@ object TextUtils { Formatter.formatFileSize(context, normalizedSize) } } + + 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) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt index d4efb22eb8..28760bf52f 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt @@ -15,12 +15,15 @@ */ package im.vector.app.features.attachments +import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.activity.result.ActivityResultLauncher +import im.vector.app.core.dialogs.PhotoOrVideoDialog import im.vector.app.core.platform.Restorable +import im.vector.app.features.settings.VectorPreferences import im.vector.lib.multipicker.MultiPicker import org.matrix.android.sdk.BuildConfig import org.matrix.android.sdk.api.session.content.ContentAttachmentData @@ -77,10 +80,10 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab } /** - * Starts the process for handling image picking + * Starts the process for handling image/video picking */ fun selectGallery(activityResultLauncher: ActivityResultLauncher) { - MultiPicker.get(MultiPicker.IMAGE).startWith(activityResultLauncher) + MultiPicker.get(MultiPicker.MEDIA).startWith(activityResultLauncher) } /** @@ -91,10 +94,21 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab } /** - * Starts the process for handling capture image picking + * Starts the process for handling image/video capture. Can open a dialog */ - fun openCamera(context: Context, activityResultLauncher: ActivityResultLauncher) { - captureUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(context, activityResultLauncher) + fun openCamera(activity: Activity, + vectorPreferences: VectorPreferences, + cameraActivityResultLauncher: ActivityResultLauncher, + cameraVideoActivityResultLauncher: ActivityResultLauncher) { + PhotoOrVideoDialog(activity, vectorPreferences).show(object : PhotoOrVideoDialog.PhotoOrVideoDialogListener { + override fun takePhoto() { + captureUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(context, cameraActivityResultLauncher) + } + + override fun takeVideo() { + captureUri = MultiPicker.get(MultiPicker.CAMERA_VIDEO).startWithExpectingFile(context, cameraVideoActivityResultLauncher) + } + }) } /** @@ -133,15 +147,15 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab } } - fun onImageResult(data: Intent?) { + fun onMediaResult(data: Intent?) { callback.onContentAttachmentsReady( - MultiPicker.get(MultiPicker.IMAGE) + MultiPicker.get(MultiPicker.MEDIA) .getSelectedFiles(context, data) .map { it.toContentAttachmentData() } ) } - fun onPhotoResult() { + fun onCameraResult() { captureUri?.let { captureUri -> MultiPicker.get(MultiPicker.CAMERA) .getTakenPhoto(context, captureUri) @@ -153,6 +167,18 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab } } + fun onCameraVideoResult() { + captureUri?.let { captureUri -> + MultiPicker.get(MultiPicker.CAMERA_VIDEO) + .getTakenVideo(context, captureUri) + ?.let { + callback.onContentAttachmentsReady( + listOf(it).map { it.toContentAttachmentData() } + ) + } + } + } + fun onVideoResult(data: Intent?) { callback.onContentAttachmentsReady( MultiPicker.get(MultiPicker.VIDEO) diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsMapper.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsMapper.kt index 4e8dcaacb7..2229455dfe 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsMapper.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsMapper.kt @@ -17,6 +17,7 @@ package im.vector.app.features.attachments import im.vector.lib.multipicker.entity.MultiPickerAudioType +import im.vector.lib.multipicker.entity.MultiPickerBaseMediaType import im.vector.lib.multipicker.entity.MultiPickerBaseType import im.vector.lib.multipicker.entity.MultiPickerContactType import im.vector.lib.multipicker.entity.MultiPickerFileType @@ -69,6 +70,24 @@ private fun MultiPickerBaseType.mapType(): ContentAttachmentData.Type { } } +fun MultiPickerBaseType.toContentAttachmentData(): ContentAttachmentData { + return when (this) { + is MultiPickerImageType -> toContentAttachmentData() + is MultiPickerVideoType -> toContentAttachmentData() + is MultiPickerAudioType -> toContentAttachmentData() + is MultiPickerFileType -> toContentAttachmentData() + else -> throw IllegalStateException("Unknown file type") + } +} + +fun MultiPickerBaseMediaType.toContentAttachmentData(): ContentAttachmentData { + return when (this) { + is MultiPickerImageType -> toContentAttachmentData() + is MultiPickerVideoType -> toContentAttachmentData() + else -> throw IllegalStateException("Unknown media type") + } +} + fun MultiPickerImageType.toContentAttachmentData(): ContentAttachmentData { if (mimeType == null) Timber.w("No mimeType") return ContentAttachmentData( diff --git a/vector/src/main/java/im/vector/app/features/attachments/ContentAttachmentData.kt b/vector/src/main/java/im/vector/app/features/attachments/ContentAttachmentData.kt index e35ab96365..0502f2b0ad 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/ContentAttachmentData.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/ContentAttachmentData.kt @@ -21,15 +21,15 @@ import org.matrix.android.sdk.api.util.MimeTypes private val listOfPreviewableMimeTypes = listOf( MimeTypes.Jpeg, - MimeTypes.BadJpg, MimeTypes.Png, MimeTypes.Gif ) fun ContentAttachmentData.isPreviewable(): Boolean { - // For now the preview only supports still image - return type == ContentAttachmentData.Type.IMAGE - && listOfPreviewableMimeTypes.contains(getSafeMimeType() ?: "") + // Preview supports image and video + return (type == ContentAttachmentData.Type.IMAGE + && listOfPreviewableMimeTypes.contains(getSafeMimeType() ?: "")) + || type == ContentAttachmentData.Type.VIDEO } data class GroupedContentAttachmentData( diff --git a/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentPreviewItems.kt b/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentPreviewItems.kt index f715d0cb3f..ae18d2561d 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentPreviewItems.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentPreviewItems.kt @@ -18,6 +18,7 @@ package im.vector.app.features.attachments.preview import android.view.View import android.widget.ImageView +import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import com.bumptech.glide.Glide @@ -65,6 +66,7 @@ abstract class AttachmentMiniaturePreviewItem : AttachmentPreviewItem(R.id.attachmentMiniatureImageView) + val miniatureVideoIndicator by bind(R.id.attachmentMiniatureVideoIndicator) } } diff --git a/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentsPreviewFragment.kt b/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentsPreviewFragment.kt index cdb015e4da..9594f89a0e 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentsPreviewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentsPreviewFragment.kt @@ -139,7 +139,17 @@ class AttachmentsPreviewFragment @Inject constructor( attachmentBigPreviewController.setData(state) views.attachmentPreviewerBigList.scrollToPosition(state.currentAttachmentIndex) views.attachmentPreviewerMiniatureList.scrollToPosition(state.currentAttachmentIndex) - views.attachmentPreviewerSendImageOriginalSize.text = resources.getQuantityString(R.plurals.send_images_with_original_size, state.attachments.size) + views.attachmentPreviewerSendImageOriginalSize.text = getCheckboxText(state) + } + } + + private fun getCheckboxText(state: AttachmentsPreviewViewState): CharSequence { + val nbImages = state.attachments.count { it.type == ContentAttachmentData.Type.IMAGE } + val nbVideos = state.attachments.count { it.type == ContentAttachmentData.Type.VIDEO } + return when { + nbVideos == 0 -> resources.getQuantityString(R.plurals.send_images_with_original_size, nbImages) + nbImages == 0 -> resources.getQuantityString(R.plurals.send_videos_with_original_size, nbVideos) + else -> getString(R.string.send_images_and_video_with_original_size) } } diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt b/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt index d121c68557..5ad31aeaa6 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt @@ -21,10 +21,12 @@ import androidx.recyclerview.widget.RecyclerView import im.vector.app.features.autocomplete.AutocompleteClickListener import im.vector.app.features.autocomplete.RecyclerViewPresenter import im.vector.app.features.command.Command +import im.vector.app.features.settings.VectorPreferences import javax.inject.Inject class AutocompleteCommandPresenter @Inject constructor(context: Context, - private val controller: AutocompleteCommandController) : + private val controller: AutocompleteCommandController, + private val vectorPreferences: VectorPreferences) : RecyclerViewPresenter(context), AutocompleteClickListener { init { @@ -40,13 +42,17 @@ class AutocompleteCommandPresenter @Inject constructor(context: Context, } override fun onQuery(query: CharSequence?) { - val data = Command.values().filter { - if (query.isNullOrEmpty()) { - true - } else { - it.command.startsWith(query, 1, true) - } - } + val data = Command.values() + .filter { + !it.isDevCommand || vectorPreferences.developerMode() + } + .filter { + if (query.isNullOrEmpty()) { + true + } else { + it.command.startsWith(query, 1, true) + } + } controller.setData(data) } 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 091dbeec24..a9e2982714 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 @@ -78,7 +78,6 @@ class VectorCallActivity : VectorBaseActivity(), CallContro @Inject lateinit var avatarRenderer: AvatarRenderer override fun injectWith(injector: ScreenComponent) { - super.injectWith(injector) injector.inject(this) } 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 16478192f7..3f2d52e9e7 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 @@ -67,7 +67,6 @@ class VectorJitsiActivity : VectorBaseActivity(), JitsiMee private val jitsiViewModel: JitsiCallViewModel by viewModel() override fun injectWith(injector: ScreenComponent) { - super.injectWith(injector) injector.inject(this) } 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 index b488a1af0e..11c3af9394 100644 --- 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 @@ -146,7 +146,7 @@ class DialPadFragment : Fragment() { } private fun poll() { - if (!input.isEmpty()) { + if (input.isNotEmpty()) { input = input.substring(0, input.length - 1) formatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(regionCode) if (formatAsYouType) { 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 index c5b4dda135..7f59a1c89b 100644 --- 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 @@ -59,7 +59,6 @@ class CallTransferActivity : VectorBaseActivity(), override fun getCoordinatorLayout() = views.vectorCoordinatorLayout override fun injectWith(injector: ScreenComponent) { - super.injectWith(injector) injector.inject(this) } 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 index 469fba4d5e..fcd6bf0a77 100644 --- 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 @@ -21,6 +21,7 @@ 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.core.utils.TextUtils.formatDuration import im.vector.app.features.call.CameraEventsHandlerAdapter import im.vector.app.features.call.CameraProxy import im.vector.app.features.call.CameraType @@ -829,17 +830,6 @@ class WebRtcCall(val mxCall: MxCall, } } - 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) { diff --git a/vector/src/main/java/im/vector/app/features/command/Command.kt b/vector/src/main/java/im/vector/app/features/command/Command.kt index 66d88f149a..0b210cf298 100644 --- a/vector/src/main/java/im/vector/app/features/command/Command.kt +++ b/vector/src/main/java/im/vector/app/features/command/Command.kt @@ -24,29 +24,33 @@ import im.vector.app.R * the user can write theses messages to perform some actions * the list will be displayed in this order */ -enum class Command(val command: String, val parameters: String, @StringRes val description: Int) { - EMOTE("/me", "", R.string.command_description_emote), - BAN_USER("/ban", " [reason]", R.string.command_description_ban_user), - UNBAN_USER("/unban", " [reason]", R.string.command_description_unban_user), - SET_USER_POWER_LEVEL("/op", " []", R.string.command_description_op_user), - RESET_USER_POWER_LEVEL("/deop", "", R.string.command_description_deop_user), - INVITE("/invite", " [reason]", R.string.command_description_invite_user), - JOIN_ROOM("/join", " [reason]", R.string.command_description_join_room), - PART("/part", " [reason]", R.string.command_description_part_room), - TOPIC("/topic", "", R.string.command_description_topic), - KICK_USER("/kick", " [reason]", R.string.command_description_kick_user), - CHANGE_DISPLAY_NAME("/nick", "", R.string.command_description_nick), - MARKDOWN("/markdown", "", R.string.command_description_markdown), - RAINBOW("/rainbow", "", R.string.command_description_rainbow), - RAINBOW_EMOTE("/rainbowme", "", R.string.command_description_rainbow_emote), - CLEAR_SCALAR_TOKEN("/clear_scalar_token", "", R.string.command_description_clear_scalar_token), - SPOILER("/spoiler", "", R.string.command_description_spoiler), - POLL("/poll", "Question | Option 1 | Option 2 ...", R.string.command_description_poll), - SHRUG("/shrug", "", R.string.command_description_shrug), - PLAIN("/plain", "", R.string.command_description_plain), - DISCARD_SESSION("/discardsession", "", R.string.command_description_discard_session), - CONFETTI("/confetti", "", R.string.command_confetti), - SNOW("/snow", "", R.string.command_snow); +enum class Command(val command: String, val parameters: String, @StringRes val description: Int, val isDevCommand: Boolean) { + EMOTE("/me", "", R.string.command_description_emote, false), + BAN_USER("/ban", " [reason]", R.string.command_description_ban_user, false), + UNBAN_USER("/unban", " [reason]", R.string.command_description_unban_user, false), + SET_USER_POWER_LEVEL("/op", " []", R.string.command_description_op_user, false), + RESET_USER_POWER_LEVEL("/deop", "", R.string.command_description_deop_user, false), + INVITE("/invite", " [reason]", R.string.command_description_invite_user, false), + JOIN_ROOM("/join", " [reason]", R.string.command_description_join_room, false), + PART("/part", " [reason]", R.string.command_description_part_room, false), + TOPIC("/topic", "", R.string.command_description_topic, false), + KICK_USER("/kick", " [reason]", R.string.command_description_kick_user, false), + CHANGE_DISPLAY_NAME("/nick", "", R.string.command_description_nick, false), + MARKDOWN("/markdown", "", R.string.command_description_markdown, false), + RAINBOW("/rainbow", "", R.string.command_description_rainbow, false), + RAINBOW_EMOTE("/rainbowme", "", R.string.command_description_rainbow_emote, false), + CLEAR_SCALAR_TOKEN("/clear_scalar_token", "", R.string.command_description_clear_scalar_token, false), + SPOILER("/spoiler", "", R.string.command_description_spoiler, false), + POLL("/poll", "Question | Option 1 | Option 2 ...", R.string.command_description_poll, false), + SHRUG("/shrug", "", R.string.command_description_shrug, false), + PLAIN("/plain", "", R.string.command_description_plain, false), + DISCARD_SESSION("/discardsession", "", R.string.command_description_discard_session, false), + CONFETTI("/confetti", "", R.string.command_confetti, false), + SNOW("/snow", "", R.string.command_snow, false), + CREATE_SPACE("/createspace", " *", R.string.command_description_create_space, true), + ADD_TO_SPACE("/addToSpace", "spaceId", R.string.command_description_create_space, true), + JOIN_SPACE("/joinSpace", "spaceId", R.string.command_description_join_space, true), + LEAVE_ROOM("/leave", "", R.string.command_description_leave_room, true); val length get() = command.length + 1 diff --git a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt index d458751364..9b190d64fe 100644 --- a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt +++ b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt @@ -296,10 +296,40 @@ object CommandParser { val message = textMessage.substring(Command.CONFETTI.command.length).trim() ParsedCommand.SendChatEffect(ChatEffect.CONFETTI, message) } - Command.SNOW.command -> { + Command.SNOW.command -> { val message = textMessage.substring(Command.SNOW.command.length).trim() ParsedCommand.SendChatEffect(ChatEffect.SNOW, message) } + Command.CREATE_SPACE.command -> { + val rawCommand = textMessage.substring(Command.CREATE_SPACE.command.length).trim() + val split = rawCommand.split(" ").map { it.trim() } + if (split.isEmpty()) { + ParsedCommand.ErrorSyntax(Command.CREATE_SPACE) + } else { + ParsedCommand.CreateSpace( + split[0], + split.subList(1, split.size) + ) + } + } + Command.ADD_TO_SPACE.command -> { + val rawCommand = textMessage.substring(Command.ADD_TO_SPACE.command.length).trim() + ParsedCommand.AddToSpace( + rawCommand + ) + } + Command.JOIN_SPACE.command -> { + val spaceIdOrAlias = textMessage.substring(Command.JOIN_SPACE.command.length).trim() + ParsedCommand.JoinSpace( + spaceIdOrAlias + ) + } + Command.LEAVE_ROOM.command -> { + val spaceIdOrAlias = textMessage.substring(Command.LEAVE_ROOM.command.length).trim() + ParsedCommand.LeaveRoom( + spaceIdOrAlias + ) + } else -> { // Unknown command ParsedCommand.ErrorUnknownSlashCommand(slashCommand) diff --git a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt index d17faeafb8..d67caac60a 100644 --- a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt +++ b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt @@ -57,4 +57,8 @@ sealed class ParsedCommand { class SendPoll(val question: String, val options: List) : ParsedCommand() object DiscardSession : ParsedCommand() class SendChatEffect(val chatEffect: ChatEffect, val message: String) : ParsedCommand() + class CreateSpace(val name: String, val invitees: List) : ParsedCommand() + class AddToSpace(val spaceId: String) : ParsedCommand() + class JoinSpace(val spaceIdOrAlias: String) : ParsedCommand() + class LeaveRoom(val roomId: String) : ParsedCommand() } diff --git a/vector/src/main/java/im/vector/app/features/configuration/VectorConfiguration.kt b/vector/src/main/java/im/vector/app/features/configuration/VectorConfiguration.kt index 394eca030b..a2d190bd69 100644 --- a/vector/src/main/java/im/vector/app/features/configuration/VectorConfiguration.kt +++ b/vector/src/main/java/im/vector/app/features/configuration/VectorConfiguration.kt @@ -39,6 +39,8 @@ class VectorConfiguration @Inject constructor(private val context: Context) { Timber.v("## onConfigurationChanged(): restore the expected value ${VectorLocale.applicationLocale}") Locale.setDefault(VectorLocale.applicationLocale) } + // Night mode may have changed + ThemeUtils.init(context) } fun applyToApplicationContext() { 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 cbe363aa0e..2f0b6e5ec9 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 @@ -19,6 +19,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.Loading import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.Success import com.airbnb.mvrx.ViewModelContext @@ -26,6 +27,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.assisted.AssistedFactory import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.mvrx.runCatchingToAsync import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.raw.wellknown.getElementWellknown import im.vector.app.features.raw.wellknown.isE2EByDefault @@ -35,7 +37,6 @@ import kotlinx.coroutines.launch 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.rx.rx class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted initialState: CreateDirectRoomViewState, @@ -82,6 +83,8 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted } private fun createRoomAndInviteSelectedUsers(selections: Set) { + setState { copy(createAndInviteState = Loading()) } + viewModelScope.launch(Dispatchers.IO) { val adminE2EByDefault = rawService.getElementWellknown(session.myUserId) ?.isE2EByDefault() @@ -99,11 +102,15 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted enableEncryptionIfInvitedUsersSupportIt = adminE2EByDefault } - session.rx() - .createRoom(roomParams) - .execute { - copy(createAndInviteState = it) - } + val result = runCatchingToAsync { + session.createRoom(roomParams) + } + + setState { + copy( + createAndInviteState = result + ) + } } } } diff --git a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/settings/KeysBackupSettingsRecyclerViewController.kt b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/settings/KeysBackupSettingsRecyclerViewController.kt index bfdb297b23..ca5f88968a 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/settings/KeysBackupSettingsRecyclerViewController.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/settings/KeysBackupSettingsRecyclerViewController.kt @@ -26,7 +26,7 @@ import im.vector.app.R import im.vector.app.core.epoxy.errorWithRetryItem import im.vector.app.core.epoxy.loadingItem import im.vector.app.core.resources.StringProvider -import im.vector.app.core.ui.list.GenericItem +import im.vector.app.core.ui.list.ItemStyle import im.vector.app.core.ui.list.genericItem import im.vector.app.features.settings.VectorPreferences import org.matrix.android.sdk.api.session.Session @@ -72,7 +72,7 @@ class KeysBackupSettingsRecyclerViewController @Inject constructor(private val s genericItem { id("summary") title(stringProvider.getString(R.string.keys_backup_settings_status_not_setup)) - style(GenericItem.STYLE.BIG_TEXT) + style(ItemStyle.BIG_TEXT) if (data.keysBackupVersionTrust()?.usable == false) { description(stringProvider.getString(R.string.keys_backup_settings_untrusted_backup)) @@ -87,7 +87,7 @@ class KeysBackupSettingsRecyclerViewController @Inject constructor(private val s genericItem { id("summary") title(stringProvider.getString(R.string.keys_backup_settings_status_ko)) - style(GenericItem.STYLE.BIG_TEXT) + style(ItemStyle.BIG_TEXT) if (data.keysBackupVersionTrust()?.usable == false) { description(stringProvider.getString(R.string.keys_backup_settings_untrusted_backup)) } else { @@ -102,7 +102,7 @@ class KeysBackupSettingsRecyclerViewController @Inject constructor(private val s genericItem { id("summary") title(stringProvider.getString(R.string.keys_backup_settings_status_ok)) - style(GenericItem.STYLE.BIG_TEXT) + style(ItemStyle.BIG_TEXT) if (data.keysBackupVersionTrust()?.usable == false) { description(stringProvider.getString(R.string.keys_backup_settings_untrusted_backup)) } else { @@ -118,7 +118,7 @@ class KeysBackupSettingsRecyclerViewController @Inject constructor(private val s genericItem { id("summary") title(stringProvider.getString(R.string.keys_backup_settings_status_ok)) - style(GenericItem.STYLE.BIG_TEXT) + style(ItemStyle.BIG_TEXT) hasIndeterminateProcess(true) val totalKeys = session.cryptoService().inboundGroupSessionsCount(false) diff --git a/vector/src/main/java/im/vector/app/features/form/FormEditTextItem.kt b/vector/src/main/java/im/vector/app/features/form/FormEditTextItem.kt index 68e2e6b371..fdac8afaed 100644 --- a/vector/src/main/java/im/vector/app/features/form/FormEditTextItem.kt +++ b/vector/src/main/java/im/vector/app/features/form/FormEditTextItem.kt @@ -18,6 +18,7 @@ package im.vector.app.features.form import android.text.Editable import android.view.View +import android.view.inputmethod.EditorInfo import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass @@ -50,6 +51,15 @@ abstract class FormEditTextItem : VectorEpoxyModel() { @EpoxyAttribute var inputType: Int? = null + @EpoxyAttribute + var singleLine: Boolean? = null + + @EpoxyAttribute + var imeOptions: Int? = null + + @EpoxyAttribute + var endIconMode: Int? = null + @EpoxyAttribute var onTextChange: ((String) -> Unit)? = null @@ -64,11 +74,14 @@ abstract class FormEditTextItem : VectorEpoxyModel() { holder.textInputLayout.isEnabled = enabled holder.textInputLayout.hint = hint holder.textInputLayout.error = errorMessage + holder.textInputLayout.endIconMode = endIconMode ?: TextInputLayout.END_ICON_NONE // Update only if text is different and value is not null holder.textInputEditText.setTextSafe(value) holder.textInputEditText.isEnabled = enabled inputType?.let { holder.textInputEditText.inputType = it } + holder.textInputEditText.isSingleLine = singleLine ?: false + holder.textInputEditText.imeOptions = imeOptions ?: EditorInfo.IME_ACTION_NONE holder.textInputEditText.addTextChangedListener(onTextChangeListener) holder.bottomSeparator.isVisible = showBottomSeparator diff --git a/vector/src/main/java/im/vector/app/features/form/FormEditableSquareAvatarItem.kt b/vector/src/main/java/im/vector/app/features/form/FormEditableSquareAvatarItem.kt new file mode 100644 index 0000000000..cbb545825d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/form/FormEditableSquareAvatarItem.kt @@ -0,0 +1,95 @@ +/* + * 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.form + +import android.net.Uri +import android.util.TypedValue +import android.view.View +import android.widget.ImageView +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import com.airbnb.epoxy.EpoxyModelWithHolder +import com.bumptech.glide.load.MultiTransformation +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import im.vector.app.R +import im.vector.app.core.epoxy.ClickListener +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.onClick +import im.vector.app.core.glide.GlideApp +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.util.MatrixItem + +@EpoxyModelClass(layout = R.layout.item_editable_square_avatar) +abstract class FormEditableSquareAvatarItem : EpoxyModelWithHolder() { + + @EpoxyAttribute + var avatarRenderer: AvatarRenderer? = null + + @EpoxyAttribute + var matrixItem: MatrixItem? = null + + @EpoxyAttribute + var enabled: Boolean = true + + @EpoxyAttribute + var imageUri: Uri? = null + + @EpoxyAttribute + var clickListener: ClickListener? = null + + @EpoxyAttribute + var deleteListener: ClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.imageContainer.onClick(clickListener?.takeIf { enabled }) + when { + imageUri != null -> { + val corner = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8f, + holder.view.resources.displayMetrics + ).toInt() + GlideApp.with(holder.image) + .load(imageUri) + .transform(MultiTransformation(CenterCrop(), RoundedCorners(corner))) + .into(holder.image) + } + matrixItem != null -> { + avatarRenderer?.renderSpace(matrixItem!!, holder.image) + } + else -> { + avatarRenderer?.clear(holder.image) + } + } + holder.delete.isVisible = enabled && (imageUri != null || matrixItem?.avatarUrl?.isNotEmpty() == true) + holder.delete.onClick(deleteListener?.takeIf { enabled }) + } + + override fun unbind(holder: Holder) { + avatarRenderer?.clear(holder.image) + GlideApp.with(holder.image).clear(holder.image) + super.unbind(holder) + } + + class Holder : VectorEpoxyHolder() { + val imageContainer by bind(R.id.itemEditableAvatarImageContainer) + val image by bind(R.id.itemEditableAvatarImage) + val delete by bind(R.id.itemEditableAvatarDelete) + } +} 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 index 4ba668a051..6c6f6d284d 100644 --- a/vector/src/main/java/im/vector/app/features/form/FormMultiLineEditTextItem.kt +++ b/vector/src/main/java/im/vector/app/features/form/FormMultiLineEditTextItem.kt @@ -73,7 +73,7 @@ abstract class FormMultiLineEditTextItem : VectorEpoxyModel(), - GroupSummaryController.Callback { - - private lateinit var sharedActionViewModel: HomeSharedActionViewModel - private val viewModel: GroupListViewModel by fragmentViewModel() - - override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentGroupListBinding { - return FragmentGroupListBinding.inflate(inflater, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - sharedActionViewModel = activityViewModelProvider.get(HomeSharedActionViewModel::class.java) - groupController.callback = this - views.stateView.contentView = views.groupListView - views.groupListView.configureWith(groupController) - viewModel.observeViewEvents { - when (it) { - is GroupListViewEvents.OpenGroupSummary -> sharedActionViewModel.post(HomeActivitySharedAction.OpenGroup) - }.exhaustive - } - } - - override fun onDestroyView() { - groupController.callback = null - views.groupListView.cleanup() - super.onDestroyView() - } - - override fun invalidate() = withState(viewModel) { state -> - when (state.asyncGroups) { - is Incomplete -> views.stateView.state = StateView.State.Loading - is Success -> views.stateView.state = StateView.State.Content - } - groupController.update(state) - } - - override fun onGroupSelected(groupSummary: GroupSummary) { - viewModel.handle(GroupListAction.SelectGroup(groupSummary)) - } -} 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 deleted file mode 100644 index 4b187f83ca..0000000000 --- a/vector/src/main/java/im/vector/app/features/grouplist/GroupListViewModel.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * 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.grouplist - -import androidx.lifecycle.viewModelScope -import arrow.core.Option -import com.airbnb.mvrx.FragmentViewModelContext -import com.airbnb.mvrx.MvRxViewModelFactory -import com.airbnb.mvrx.ViewModelContext -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import dagger.assisted.AssistedFactory -import im.vector.app.R -import im.vector.app.core.platform.VectorViewModel -import im.vector.app.core.resources.StringProvider -import io.reactivex.Observable -import kotlinx.coroutines.launch -import org.matrix.android.sdk.api.query.QueryStringValue -import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.group.groupSummaryQueryParams -import org.matrix.android.sdk.api.session.group.model.GroupSummary -import org.matrix.android.sdk.api.session.room.model.Membership -import org.matrix.android.sdk.rx.rx - -const val ALL_COMMUNITIES_GROUP_ID = "+ALL_COMMUNITIES_GROUP_ID" - -class GroupListViewModel @AssistedInject constructor(@Assisted initialState: GroupListViewState, - private val selectedGroupStore: SelectedGroupDataSource, - private val session: Session, - private val stringProvider: StringProvider -) : VectorViewModel(initialState) { - - @AssistedFactory - interface Factory { - fun create(initialState: GroupListViewState): GroupListViewModel - } - - companion object : MvRxViewModelFactory { - - @JvmStatic - override fun create(viewModelContext: ViewModelContext, state: GroupListViewState): GroupListViewModel? { - val groupListFragment: GroupListFragment = (viewModelContext as FragmentViewModelContext).fragment() - return groupListFragment.groupListViewModelFactory.create(state) - } - } - - private var currentGroupId = "" - - init { - observeGroupSummaries() - observeSelectionState() - } - - private fun observeSelectionState() { - selectSubscribe(GroupListViewState::selectedGroup) { groupSummary -> - if (groupSummary != null) { - // We only want to open group if the updated selectedGroup is a different one. - if (currentGroupId != groupSummary.groupId) { - currentGroupId = groupSummary.groupId - _viewEvents.post(GroupListViewEvents.OpenGroupSummary) - } - val optionGroup = Option.just(groupSummary) - selectedGroupStore.post(optionGroup) - } else { - // If selected group is null we force to default. It can happens when leaving the selected group. - setState { - copy(selectedGroup = this.asyncGroups()?.find { it.groupId == ALL_COMMUNITIES_GROUP_ID }) - } - } - } - } - - override fun handle(action: GroupListAction) { - when (action) { - is GroupListAction.SelectGroup -> handleSelectGroup(action) - } - } - - // PRIVATE METHODS ***************************************************************************** - - private fun handleSelectGroup(action: GroupListAction.SelectGroup) = withState { state -> - if (state.selectedGroup?.groupId != action.groupSummary.groupId) { - // We take care of refreshing group data when selecting to be sure we get all the rooms and users - viewModelScope.launch { - session.getGroup(action.groupSummary.groupId)?.fetchGroupData() - } - setState { copy(selectedGroup = action.groupSummary) } - } - } - - private fun observeGroupSummaries() { - val groupSummariesQueryParams = groupSummaryQueryParams { - memberships = listOf(Membership.JOIN) - displayName = QueryStringValue.IsNotEmpty - } - Observable.combineLatest, List>( - session - .rx() - .liveUser(session.myUserId) - .map { optionalUser -> - GroupSummary( - groupId = ALL_COMMUNITIES_GROUP_ID, - membership = Membership.JOIN, - displayName = stringProvider.getString(R.string.group_all_communities), - avatarUrl = optionalUser.getOrNull()?.avatarUrl ?: "") - }, - session - .rx() - .liveGroupSummaries(groupSummariesQueryParams), - { allCommunityGroup, communityGroups -> - listOf(allCommunityGroup) + communityGroups - } - ) - .execute { async -> - val currentSelectedGroupId = selectedGroup?.groupId - val newSelectedGroup = if (currentSelectedGroupId != null) { - async()?.find { it.groupId == currentSelectedGroupId } - } else { - async()?.firstOrNull() - } - copy(asyncGroups = async, selectedGroup = newSelectedGroup) - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/grouplist/GroupSummaryController.kt b/vector/src/main/java/im/vector/app/features/grouplist/GroupSummaryController.kt deleted file mode 100644 index 03272c2729..0000000000 --- a/vector/src/main/java/im/vector/app/features/grouplist/GroupSummaryController.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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.grouplist - -import com.airbnb.epoxy.EpoxyController -import im.vector.app.features.home.AvatarRenderer -import org.matrix.android.sdk.api.session.group.model.GroupSummary -import org.matrix.android.sdk.api.util.toMatrixItem -import javax.inject.Inject - -class GroupSummaryController @Inject constructor(private val avatarRenderer: AvatarRenderer) : EpoxyController() { - - var callback: Callback? = null - private var viewState: GroupListViewState? = null - - init { - requestModelBuild() - } - - fun update(viewState: GroupListViewState) { - this.viewState = viewState - requestModelBuild() - } - - override fun buildModels() { - val nonNullViewState = viewState ?: return - buildGroupModels(nonNullViewState.asyncGroups(), nonNullViewState.selectedGroup) - } - - private fun buildGroupModels(summaries: List?, selected: GroupSummary?) { - if (summaries.isNullOrEmpty()) { - return - } - summaries.forEach { groupSummary -> - val isSelected = groupSummary.groupId == selected?.groupId - groupSummaryItem { - avatarRenderer(avatarRenderer) - id(groupSummary.groupId) - matrixItem(groupSummary.toMatrixItem()) - selected(isSelected) - listener { callback?.onGroupSelected(groupSummary) } - } - } - } - - interface Callback { - fun onGroupSelected(groupSummary: GroupSummary) - } -} diff --git a/vector/src/main/java/im/vector/app/features/grouplist/HomeSpaceSummaryItem.kt b/vector/src/main/java/im/vector/app/features/grouplist/HomeSpaceSummaryItem.kt new file mode 100644 index 0000000000..553f82e98f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/grouplist/HomeSpaceSummaryItem.kt @@ -0,0 +1,68 @@ +/* + * 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.grouplist + +import android.content.res.ColorStateList +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.platform.CheckableConstraintLayout +import im.vector.app.features.home.room.list.UnreadCounterBadgeView +import im.vector.app.features.themes.ThemeUtils + +@EpoxyModelClass(layout = R.layout.item_space) +abstract class HomeSpaceSummaryItem : VectorEpoxyModel() { + + @EpoxyAttribute var selected: Boolean = false + @EpoxyAttribute var listener: (() -> Unit)? = null + @EpoxyAttribute var countState : UnreadCounterBadgeView.State = UnreadCounterBadgeView.State(0, false) + + override fun getViewType(): Int { + // mm.. it's reusing the same layout for basic space item + return R.id.space_item_home + } + + override fun bind(holder: Holder) { + super.bind(holder) + holder.rootView.setOnClickListener { listener?.invoke() } + holder.groupNameView.text = holder.view.context.getString(R.string.group_details_home) + holder.rootView.isChecked = selected + holder.rootView.context.resources + holder.avatarImageView.background = ContextCompat.getDrawable(holder.view.context, R.drawable.space_home_background) + holder.avatarImageView.setImageResource(R.drawable.ic_space_home) + holder.avatarImageView.imageTintList = ColorStateList.valueOf(ThemeUtils.getColor(holder.view.context, R.attr.riot_primary_text_color)) + holder.avatarImageView.scaleType = ImageView.ScaleType.CENTER_INSIDE + holder.leaveView.isVisible = false + + holder.counterBadgeView.render(countState) + } + + class Holder : VectorEpoxyHolder() { + val avatarImageView by bind(R.id.groupAvatarImageView) + val groupNameView by bind(R.id.groupNameView) + val rootView by bind(R.id.itemGroupLayout) + val leaveView by bind(R.id.groupTmpLeave) + val counterBadgeView by bind(R.id.groupCounterBadge) + } +} 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 1d673a2a07..23ca5eee9c 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 @@ -26,7 +26,9 @@ 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.CenterCrop import com.bumptech.glide.load.resource.bitmap.CircleCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.DrawableImageViewTarget import com.bumptech.glide.request.target.Target @@ -35,6 +37,7 @@ import im.vector.app.core.di.ActiveSessionHolder 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.core.utils.DimensionConverter import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider import jp.wasabeef.glide.transformations.BlurTransformation import jp.wasabeef.glide.transformations.ColorFilterTransformation @@ -48,7 +51,8 @@ import javax.inject.Inject */ class AvatarRenderer @Inject constructor(private val activeSessionHolder: ActiveSessionHolder, - private val matrixItemColorProvider: MatrixItemColorProvider) { + private val matrixItemColorProvider: MatrixItemColorProvider, + private val dimensionConverter: DimensionConverter) { companion object { private const val THUMBNAIL_SIZE = 250 @@ -61,6 +65,25 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active DrawableImageViewTarget(imageView)) } + @UiThread + fun renderSpace(matrixItem: MatrixItem, imageView: ImageView, glideRequests: GlideRequests) { + val placeholder = getSpacePlaceholderDrawable(matrixItem) + val resolvedUrl = resolvedUrl(matrixItem.avatarUrl) + glideRequests + .load(resolvedUrl) + .transform(MultiTransformation(CenterCrop(), RoundedCorners(dimensionConverter.dpToPx(8)))) + .placeholder(placeholder) + .into(DrawableImageViewTarget(imageView)) + } + + fun renderSpace(matrixItem: MatrixItem, imageView: ImageView) { + renderSpace( + matrixItem, + imageView, + GlideApp.with(imageView) + ) + } + fun clear(imageView: ImageView) { // It can be called after recycler view is destroyed, just silently catch tryOrNull { GlideApp.with(imageView).clear(imageView) } @@ -159,6 +182,16 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active .buildRound(matrixItem.firstLetterOfDisplayName(), avatarColor) } + @AnyThread + fun getSpacePlaceholderDrawable(matrixItem: MatrixItem): Drawable { + val avatarColor = matrixItemColorProvider.getColor(matrixItem) + return TextDrawable.builder() + .beginConfig() + .bold() + .endConfig() + .buildRoundRect(matrixItem.firstLetterOfDisplayName(), avatarColor, dimensionConverter.dpToPx(8)) + } + // PRIVATE API ********************************************************************************* private fun buildGlideRequest(glideRequests: GlideRequests, avatarUrl: String?): GlideRequest { diff --git a/vector/src/main/java/im/vector/app/features/grouplist/SelectedGroupDataSource.kt b/vector/src/main/java/im/vector/app/features/home/CurrentSpaceSuggestedRoomListDataSource.kt similarity index 74% rename from vector/src/main/java/im/vector/app/features/grouplist/SelectedGroupDataSource.kt rename to vector/src/main/java/im/vector/app/features/home/CurrentSpaceSuggestedRoomListDataSource.kt index 5a172e2636..21fd37c8fc 100644 --- a/vector/src/main/java/im/vector/app/features/grouplist/SelectedGroupDataSource.kt +++ b/vector/src/main/java/im/vector/app/features/home/CurrentSpaceSuggestedRoomListDataSource.kt @@ -14,13 +14,12 @@ * limitations under the License. */ -package im.vector.app.features.grouplist +package im.vector.app.features.home -import arrow.core.Option import im.vector.app.core.utils.BehaviorDataSource -import org.matrix.android.sdk.api.session.group.model.GroupSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo import javax.inject.Inject import javax.inject.Singleton @Singleton -class SelectedGroupDataSource @Inject constructor() : BehaviorDataSource>(Option.empty()) +class CurrentSpaceSuggestedRoomListDataSource @Inject constructor() : BehaviorDataSource>() 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 60a8836be5..1de1ff1c3e 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 @@ -16,6 +16,7 @@ package im.vector.app.features.home +import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri @@ -31,11 +32,13 @@ import androidx.core.view.isVisible import androidx.drawerlayout.widget.DrawerLayout import com.airbnb.mvrx.MvRx import com.airbnb.mvrx.viewModel +import im.vector.app.AppStateHandler import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ScreenComponent import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.ToolbarConfigurable import im.vector.app.core.platform.VectorBaseActivity @@ -45,6 +48,7 @@ import im.vector.app.features.MainActivity import im.vector.app.features.MainActivityArgs import im.vector.app.features.disclaimer.showDisclaimerDialog import im.vector.app.features.matrixto.MatrixToBottomSheet +import im.vector.app.features.navigation.Navigator import im.vector.app.features.notifications.NotificationDrawerManager import im.vector.app.features.permalink.NavigationInterceptor import im.vector.app.features.permalink.PermalinkHandler @@ -54,6 +58,11 @@ import im.vector.app.features.popup.VerificationVectorAlert import im.vector.app.features.rageshake.VectorUncaughtExceptionHandler import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity +import im.vector.app.features.spaces.ShareSpaceBottomSheet +import im.vector.app.features.spaces.SpaceCreationActivity +import im.vector.app.features.spaces.SpacePreviewActivity +import im.vector.app.features.spaces.SpaceSettingsMenuBottomSheet +import im.vector.app.features.spaces.invite.SpaceInviteBottomSheet import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.workers.signout.ServerBackupStatusViewModel import im.vector.app.features.workers.signout.ServerBackupStatusViewState @@ -79,7 +88,9 @@ class HomeActivity : ToolbarConfigurable, UnknownDeviceDetectorSharedViewModel.Factory, ServerBackupStatusViewModel.Factory, - NavigationInterceptor { + UnreadMessagesSharedViewModel.Factory, + NavigationInterceptor, + SpaceInviteBottomSheet.InteractionListener { private lateinit var sharedActionViewModel: HomeSharedActionViewModel @@ -97,9 +108,34 @@ class HomeActivity : @Inject lateinit var popupAlertManager: PopupAlertManager @Inject lateinit var shortcutsHandler: ShortcutsHandler @Inject lateinit var unknownDeviceViewModelFactory: UnknownDeviceDetectorSharedViewModel.Factory + @Inject lateinit var unreadMessagesSharedViewModelFactory: UnreadMessagesSharedViewModel.Factory @Inject lateinit var permalinkHandler: PermalinkHandler @Inject lateinit var avatarRenderer: AvatarRenderer @Inject lateinit var initSyncStepFormatter: InitSyncStepFormatter + @Inject lateinit var appStateHandler: AppStateHandler + + private val createSpaceResultLauncher = registerStartForActivityResult { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + val spaceId = SpaceCreationActivity.getCreatedSpaceId(activityResult.data) + val defaultRoomId = SpaceCreationActivity.getDefaultRoomId(activityResult.data) + val isJustMe = SpaceCreationActivity.isJustMeSpace(activityResult.data) + views.drawerLayout.closeDrawer(GravityCompat.START) + + val postSwitchOption: Navigator.PostSwitchSpaceAction = if (defaultRoomId != null) { + Navigator.PostSwitchSpaceAction.OpenDefaultRoom(defaultRoomId, !isJustMe) + } else if (isJustMe) { + Navigator.PostSwitchSpaceAction.OpenAddExistingRooms + } else { + Navigator.PostSwitchSpaceAction.None + } + // Here we want to change current space to the newly created one, and then immediately open the default room + if (spaceId != null) { + navigator.switchToSpace(context = this, + spaceId = spaceId, + postSwitchOption) + } + } + } private val drawerListener = object : DrawerLayout.SimpleDrawerListener() { override fun onDrawerStateChanged(newState: Int) { @@ -121,16 +157,25 @@ class HomeActivity : return serverBackupviewModelFactory.create(initialState) } + override fun create(initialState: UnreadMessagesState): UnreadMessagesSharedViewModel { + return unreadMessagesSharedViewModelFactory.create(initialState) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) FcmHelper.ensureFcmTokenIsRetrieved(this, pushManager, vectorPreferences.areNotificationEnabledForDevice()) sharedActionViewModel = viewModelProvider.get(HomeSharedActionViewModel::class.java) views.drawerLayout.addDrawerListener(drawerListener) if (isFirstCreation()) { - replaceFragment(R.id.homeDetailFragmentContainer, LoadingFragment::class.java) + replaceFragment(R.id.homeDetailFragmentContainer, HomeDetailFragment::class.java) replaceFragment(R.id.homeDrawerFragmentContainer, HomeDrawerFragment::class.java) } +// appStateHandler.selectedRoomGroupingObservable.subscribe { +// if (supportFragmentManager.getFragment()) +// replaceFragment(R.id.homeDetailFragmentContainer, HomeDetailFragment::class.java, allowStateLoss = true) +// }.disposeOnDestroy() + sharedActionViewModel .observe() .subscribe { sharedAction -> @@ -139,7 +184,37 @@ class HomeActivity : is HomeActivitySharedAction.CloseDrawer -> views.drawerLayout.closeDrawer(GravityCompat.START) is HomeActivitySharedAction.OpenGroup -> { views.drawerLayout.closeDrawer(GravityCompat.START) - replaceFragment(R.id.homeDetailFragmentContainer, HomeDetailFragment::class.java, allowStateLoss = true) + + // Temporary + // When switching from space to group or group to space, we need to reload the fragment + // To be removed when dropping legacy groups + if (sharedAction.clearFragment) { + replaceFragment(R.id.homeDetailFragmentContainer, HomeDetailFragment::class.java, allowStateLoss = true) + } else { + // nop + } + // we might want to delay that to avoid having the drawer animation lagging + // would be probably better to let the drawer do that? in the on closed callback? + } + is HomeActivitySharedAction.OpenSpacePreview -> { + startActivity(SpacePreviewActivity.newIntent(this, sharedAction.spaceId)) + } + is HomeActivitySharedAction.AddSpace -> { + createSpaceResultLauncher.launch(SpaceCreationActivity.newIntent(this)) + } + is HomeActivitySharedAction.ShowSpaceSettings -> { + // open bottom sheet + SpaceSettingsMenuBottomSheet + .newInstance(sharedAction.spaceId, object : SpaceSettingsMenuBottomSheet.InteractionListener { + override fun onShareSpaceSelected(spaceId: String) { + ShareSpaceBottomSheet.show(supportFragmentManager, spaceId) + } + }) + .show(supportFragmentManager, "SPACE_SETTINGS") + } + is HomeActivitySharedAction.OpenSpaceInvite -> { + SpaceInviteBottomSheet.newInstance(sharedAction.spaceId) + .show(supportFragmentManager, "SPACE_INVITE") } }.exhaustive } @@ -209,7 +284,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 -> { @@ -428,6 +503,31 @@ class HomeActivity : return true } + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { + if (roomId == null) return false + val listener = object : MatrixToBottomSheet.InteractionListener { + override fun navigateToRoom(roomId: String) { + navigator.openRoom(this@HomeActivity, roomId) + } + + override fun switchToSpace(spaceId: String) { + navigator.switchToSpace(this@HomeActivity, spaceId, Navigator.PostSwitchSpaceAction.None) + } + } + + MatrixToBottomSheet.withLink(deepLink.toString(), listener) + .show(supportFragmentManager, "HA#MatrixToBottomSheet") + return true + } + + override fun spaceInviteBottomSheetOnAccept(spaceId: String) { + navigator.switchToSpace(this, spaceId, Navigator.PostSwitchSpaceAction.None) + } + + override fun spaceInviteBottomSheetOnDecline(spaceId: String) { + // nop + } + companion object { fun newIntent(context: Context, clearNotification: Boolean = false, accountCreation: Boolean = false): Intent { val args = HomeActivityArgs( diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivitySharedAction.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivitySharedAction.kt index 52b3c58785..d79f24fc4c 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivitySharedAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivitySharedAction.kt @@ -24,5 +24,9 @@ import im.vector.app.core.platform.VectorSharedAction sealed class HomeActivitySharedAction : VectorSharedAction { object OpenDrawer : HomeActivitySharedAction() object CloseDrawer : HomeActivitySharedAction() - object OpenGroup : HomeActivitySharedAction() + data class OpenGroup(val clearFragment: Boolean) : HomeActivitySharedAction() + object AddSpace : HomeActivitySharedAction() + data class OpenSpacePreview(val spaceId: String) : HomeActivitySharedAction() + data class OpenSpaceInvite(val spaceId: String) : HomeActivitySharedAction() + data class ShowSpaceSettings(val spaceId: String) : HomeActivitySharedAction() } 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 5def43b60b..69395b2386 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 @@ -23,14 +23,15 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import com.google.android.material.badge.BadgeDrawable import im.vector.app.R +import im.vector.app.RoomGroupingMethod import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.toMvRxBundle -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 @@ -43,6 +44,7 @@ import im.vector.app.features.call.VectorCallActivity 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.home.room.list.UnreadCounterBadgeView import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.popup.VerificationVectorAlert import im.vector.app.features.settings.VectorPreferences @@ -52,15 +54,10 @@ import im.vector.app.features.workers.signout.BannerState import im.vector.app.features.workers.signout.ServerBackupStatusViewModel import im.vector.app.features.workers.signout.ServerBackupStatusViewState import org.matrix.android.sdk.api.session.group.model.GroupSummary -import org.matrix.android.sdk.api.util.toMatrixItem +import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.internal.crypto.model.rest.DeviceInfo -import timber.log.Timber import javax.inject.Inject -private const val INDEX_PEOPLE = 0 -private const val INDEX_ROOMS = 1 -private const val INDEX_CATCHUP = 2 - class HomeDetailFragment @Inject constructor( val homeDetailViewModelFactory: HomeDetailViewModel.Factory, private val serverBackupStatusViewModelFactory: ServerBackupStatusViewModel.Factory, @@ -75,6 +72,7 @@ class HomeDetailFragment @Inject constructor( private val viewModel: HomeDetailViewModel by fragmentViewModel() private val unknownDeviceDetectorSharedViewModel: UnknownDeviceDetectorSharedViewModel by activityViewModel() + private val unreadMessagesSharedViewModel: UnreadMessagesSharedViewModel by activityViewModel() private val serverBackupStatusViewModel: ServerBackupStatusViewModel by activityViewModel() private lateinit var sharedActionViewModel: HomeSharedActionViewModel @@ -127,9 +125,17 @@ class HomeDetailFragment @Inject constructor( views.bottomNavigationView.selectedItemId = it.displayMode.toMenuId() } - viewModel.selectSubscribe(this, HomeDetailViewState::groupSummary) { groupSummary -> - onGroupChange(groupSummary.orNull()) + viewModel.selectSubscribe(this, HomeDetailViewState::roomGroupingMethod) { roomGroupingMethod -> + when (roomGroupingMethod) { + is RoomGroupingMethod.ByLegacyGroup -> { + onGroupChange(roomGroupingMethod.groupSummary) + } + is RoomGroupingMethod.BySpace -> { + onSpaceChange(roomGroupingMethod.spaceSummary) + } + } } + viewModel.selectSubscribe(this, HomeDetailViewState::displayMode) { displayMode -> switchDisplayMode(displayMode) } @@ -152,6 +158,15 @@ class HomeDetailFragment @Inject constructor( } } + unreadMessagesSharedViewModel.subscribe { state -> + views.drawerUnreadCounterBadgeView.render( + UnreadCounterBadgeView.State( + count = state.otherSpacesUnread.totalCount, + highlighted = state.otherSpacesUnread.isHighlight + ) + ) + } + sharedCallActionViewModel .liveKnownCalls .observe(viewLifecycleOwner, { @@ -237,9 +252,20 @@ class HomeDetailFragment @Inject constructor( } private fun onGroupChange(groupSummary: GroupSummary?) { - groupSummary?.let { - // Use GlideApp with activity context to avoid the glideRequests to be paused - avatarRenderer.render(it.toMatrixItem(), views.groupToolbarAvatarImageView, GlideApp.with(requireActivity())) + if (groupSummary == null) { + views.groupToolbarSpaceTitleView.isVisible = false + } else { + views.groupToolbarSpaceTitleView.isVisible = true + views.groupToolbarSpaceTitleView.text = groupSummary.displayName + } + } + + private fun onSpaceChange(spaceSummary: RoomSummary?) { + if (spaceSummary == null) { + views.groupToolbarSpaceTitleView.isVisible = false + } else { + views.groupToolbarSpaceTitleView.isVisible = true + views.groupToolbarSpaceTitleView.text = spaceSummary.displayName } } @@ -247,10 +273,10 @@ class HomeDetailFragment @Inject constructor( serverBackupStatusViewModel .subscribe(this) { when (val banState = it.bannerState.invoke()) { - is BannerState.Setup -> views.homeKeysBackupBanner.render(KeysBackupBanner.State.Setup(banState.numberOfKeys), false) + is BannerState.Setup -> views.homeKeysBackupBanner.render(KeysBackupBanner.State.Setup(banState.numberOfKeys), false) BannerState.BackingUp -> views.homeKeysBackupBanner.render(KeysBackupBanner.State.BackingUp, false) null, - BannerState.Hidden -> views.homeKeysBackupBanner.render(KeysBackupBanner.State.Hidden, false) + BannerState.Hidden -> views.homeKeysBackupBanner.render(KeysBackupBanner.State.Hidden, false) } } views.homeKeysBackupBanner.delegate = this @@ -274,6 +300,21 @@ class HomeDetailFragment @Inject constructor( views.groupToolbarAvatarImageView.debouncedClicks { sharedActionViewModel.post(HomeActivitySharedAction.OpenDrawer) } + + views.homeToolbarContent.debouncedClicks { + withState(viewModel) { + when (it.roomGroupingMethod) { + is RoomGroupingMethod.ByLegacyGroup -> { + // nothing do far + } + is RoomGroupingMethod.BySpace -> { + it.roomGroupingMethod.spaceSummary?.let { + sharedActionViewModel.post(HomeActivitySharedAction.ShowSpaceSettings(it.roomId)) + } + } + } + } + } } private fun setupBottomNavigationView() { @@ -281,7 +322,7 @@ class HomeDetailFragment @Inject constructor( views.bottomNavigationView.setOnNavigationItemSelectedListener { val displayMode = when (it.itemId) { R.id.bottom_action_people -> RoomListDisplayMode.PEOPLE - R.id.bottom_action_rooms -> RoomListDisplayMode.ROOMS + R.id.bottom_action_rooms -> RoomListDisplayMode.ROOMS else -> RoomListDisplayMode.NOTIFICATIONS } viewModel.handle(HomeDetailAction.SwitchDisplayMode(displayMode)) @@ -336,7 +377,7 @@ class HomeDetailFragment @Inject constructor( } override fun invalidate() = withState(viewModel) { - Timber.v(it.toString()) +// Timber.v(it.toString()) views.bottomNavigationView.getOrCreateBadge(R.id.bottom_action_people).render(it.notificationCountPeople, it.notificationHighlightPeople) views.bottomNavigationView.getOrCreateBadge(R.id.bottom_action_rooms).render(it.notificationCountRooms, it.notificationHighlightRooms) views.bottomNavigationView.getOrCreateBadge(R.id.bottom_action_notification).render(it.notificationCountCatchup, it.notificationHighlightCatchup) @@ -359,7 +400,7 @@ class HomeDetailFragment @Inject constructor( private fun RoomListDisplayMode.toMenuId() = when (this) { RoomListDisplayMode.PEOPLE -> R.id.bottom_action_people - RoomListDisplayMode.ROOMS -> R.id.bottom_action_rooms + RoomListDisplayMode.ROOMS -> R.id.bottom_action_rooms else -> R.id.bottom_action_notification } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeDetailViewModel.kt index d6a8b075f4..be5c72330e 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDetailViewModel.kt @@ -23,18 +23,22 @@ import com.airbnb.mvrx.ViewModelContext import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import im.vector.app.AppStateHandler +import im.vector.app.RoomGroupingMethod import im.vector.app.core.di.HasScreenInjector import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.VectorViewModel -import im.vector.app.core.resources.StringProvider -import im.vector.app.features.grouplist.SelectedGroupDataSource import im.vector.app.features.ui.UiStateRepository +import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.query.ActiveSpaceFilter import org.matrix.android.sdk.api.query.RoomCategoryFilter import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.RoomSortOrder 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.rx.asObservable import org.matrix.android.sdk.rx.rx import timber.log.Timber @@ -47,8 +51,7 @@ import java.util.concurrent.TimeUnit class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: HomeDetailViewState, private val session: Session, private val uiStateRepository: UiStateRepository, - private val selectedGroupStore: SelectedGroupDataSource, - private val stringProvider: StringProvider) + private val appStateHandler: AppStateHandler) : VectorViewModel(initialState) { @AssistedFactory @@ -74,14 +77,20 @@ class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: Ho init { observeSyncState() - observeSelectedGroupStore() + observeRoomGroupingMethod() observeRoomSummaries() + + session.rx().liveUser(session.myUserId).execute { + copy( + myMatrixItem = it.invoke()?.getOrNull()?.toMatrixItem() + ) + } } override fun handle(action: HomeDetailAction) { when (action) { is HomeDetailAction.SwitchDisplayMode -> handleSwitchDisplayMode(action) - HomeDetailAction.MarkAllRoomsRead -> handleMarkAllRoomsRead() + HomeDetailAction.MarkAllRoomsRead -> handleMarkAllRoomsRead() } } @@ -126,64 +135,82 @@ class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: Ho .disposeOnClear() } - private fun observeSelectedGroupStore() { - selectedGroupStore - .observe() + private fun observeRoomGroupingMethod() { + appStateHandler.selectedRoomGroupingObservable .subscribe { - setState { - copy(groupSummary = it) - } + setState { + copy( + roomGroupingMethod = it.orNull() ?: RoomGroupingMethod.BySpace(null) + ) + } } .disposeOnClear() } private fun observeRoomSummaries() { - session.getPagedRoomSummariesLive( - roomSummaryQueryParams { - memberships = Membership.activeMemberships() - } - ) - .asObservable() + appStateHandler.selectedRoomGroupingObservable.distinctUntilChanged().switchMap { + // we use it as a trigger to all changes in room, but do not really load + // the actual models + session.getPagedRoomSummariesLive( + roomSummaryQueryParams { + memberships = Membership.activeMemberships() + }, + sortOrder = RoomSortOrder.NONE + ).asObservable() + } + .observeOn(Schedulers.computation()) .throttleFirst(300, TimeUnit.MILLISECONDS) .subscribe { - val dmInvites = session.getRoomSummaries( - roomSummaryQueryParams { - memberships = listOf(Membership.INVITE) - roomCategoryFilter = RoomCategoryFilter.ONLY_DM - } - ).size + when (val groupingMethod = appStateHandler.getCurrentRoomGroupingMethod()) { + is RoomGroupingMethod.ByLegacyGroup -> { + // TODO!! + } + is RoomGroupingMethod.BySpace -> { + val activeSpaceRoomId = groupingMethod.spaceSummary?.roomId + val dmInvites = session.getRoomSummaries( + roomSummaryQueryParams { + memberships = listOf(Membership.INVITE) + roomCategoryFilter = RoomCategoryFilter.ONLY_DM + activeSpaceFilter = activeSpaceRoomId?.let { ActiveSpaceFilter.ActiveSpace(it) } ?: ActiveSpaceFilter.None + } + ).size - val roomsInvite = session.getRoomSummaries( - roomSummaryQueryParams { - memberships = listOf(Membership.INVITE) - roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - } - ).size + val roomsInvite = session.getRoomSummaries( + roomSummaryQueryParams { + memberships = listOf(Membership.INVITE) + roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(groupingMethod.spaceSummary?.roomId) + } + ).size - val dmRooms = session.getNotificationCountForRooms( - roomSummaryQueryParams { - memberships = listOf(Membership.JOIN) - roomCategoryFilter = RoomCategoryFilter.ONLY_DM - } - ) + val dmRooms = session.getNotificationCountForRooms( + roomSummaryQueryParams { + memberships = listOf(Membership.JOIN) + roomCategoryFilter = RoomCategoryFilter.ONLY_DM + activeSpaceFilter = activeSpaceRoomId?.let { ActiveSpaceFilter.ActiveSpace(it) } ?: ActiveSpaceFilter.None + } + ) - val otherRooms = session.getNotificationCountForRooms( - roomSummaryQueryParams { - memberships = listOf(Membership.JOIN) - roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - } - ) + val otherRooms = session.getNotificationCountForRooms( + roomSummaryQueryParams { + memberships = listOf(Membership.JOIN) + roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(groupingMethod.spaceSummary?.roomId) + } + ) - setState { - copy( - notificationCountCatchup = dmRooms.totalCount + otherRooms.totalCount + roomsInvite + dmInvites, - notificationHighlightCatchup = dmRooms.isHighlight || otherRooms.isHighlight, - notificationCountPeople = dmRooms.totalCount + dmInvites, - notificationHighlightPeople = dmRooms.isHighlight || dmInvites > 0, - notificationCountRooms = otherRooms.totalCount + roomsInvite, - notificationHighlightRooms = otherRooms.isHighlight || roomsInvite > 0, - hasUnreadMessages = dmRooms.totalCount + otherRooms.totalCount > 0 - ) + setState { + copy( + notificationCountCatchup = dmRooms.totalCount + otherRooms.totalCount + roomsInvite + dmInvites, + notificationHighlightCatchup = dmRooms.isHighlight || otherRooms.isHighlight, + notificationCountPeople = dmRooms.totalCount + dmInvites, + notificationHighlightPeople = dmRooms.isHighlight || dmInvites > 0, + notificationCountRooms = otherRooms.totalCount + roomsInvite, + notificationHighlightRooms = otherRooms.isHighlight || roomsInvite > 0, + hasUnreadMessages = dmRooms.totalCount + otherRooms.totalCount > 0 + ) + } + } } } .disposeOnClear() diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/HomeDetailViewState.kt index 533c9166f9..5aa9612a7a 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDetailViewState.kt @@ -16,16 +16,17 @@ package im.vector.app.features.home -import arrow.core.Option import com.airbnb.mvrx.Async import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.Uninitialized -import org.matrix.android.sdk.api.session.group.model.GroupSummary +import im.vector.app.RoomGroupingMethod import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.sync.SyncState +import org.matrix.android.sdk.api.util.MatrixItem data class HomeDetailViewState( - val groupSummary: Option = Option.empty(), + val roomGroupingMethod: RoomGroupingMethod = RoomGroupingMethod.BySpace(null), + val myMatrixItem: MatrixItem? = null, val asyncRooms: Async> = Uninitialized, val displayMode: RoomListDisplayMode = RoomListDisplayMode.PEOPLE, val notificationCountCatchup: Int = 0, diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt b/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt index 59eb45607e..9ff865b9d1 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt @@ -30,9 +30,10 @@ import im.vector.app.core.extensions.replaceChildFragment import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.utils.startSharePlainTextIntent import im.vector.app.databinding.FragmentHomeDrawerBinding -import im.vector.app.features.grouplist.GroupListFragment +// import im.vector.app.features.grouplist.GroupListFragment import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity +import im.vector.app.features.spaces.SpaceListFragment import im.vector.app.features.usercode.UserCodeActivity import im.vector.app.features.workers.signout.SignOutUiWorker @@ -58,7 +59,7 @@ class HomeDrawerFragment @Inject constructor( sharedActionViewModel = activityViewModelProvider.get(HomeSharedActionViewModel::class.java) if (savedInstanceState == null) { - replaceChildFragment(R.id.homeDrawerGroupListContainer, GroupListFragment::class.java) + replaceChildFragment(R.id.homeDrawerGroupListContainer, SpaceListFragment::class.java) } session.getUserLive(session.myUserId).observeK(viewLifecycleOwner) { optionalUser -> val user = optionalUser?.getOrNull() diff --git a/vector/src/main/java/im/vector/app/features/home/UnreadMessagesSharedViewModel.kt b/vector/src/main/java/im/vector/app/features/home/UnreadMessagesSharedViewModel.kt new file mode 100644 index 0000000000..43c878624b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/UnreadMessagesSharedViewModel.kt @@ -0,0 +1,167 @@ +/* + * 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 + +import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.MvRxState +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.AppStateHandler +import im.vector.app.RoomGroupingMethod +import im.vector.app.core.platform.EmptyAction +import im.vector.app.core.platform.EmptyViewEvents +import im.vector.app.core.platform.VectorViewModel +import io.reactivex.Observable +import io.reactivex.schedulers.Schedulers +import org.matrix.android.sdk.api.query.ActiveSpaceFilter +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.RoomSortOrder +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.session.room.summary.RoomAggregateNotificationCount +import org.matrix.android.sdk.rx.asObservable +import java.util.concurrent.TimeUnit + +data class UnreadMessagesState( + val homeSpaceUnread: RoomAggregateNotificationCount = RoomAggregateNotificationCount(0, 0), + val otherSpacesUnread: RoomAggregateNotificationCount = RoomAggregateNotificationCount(0, 0) +) : MvRxState + +data class CountInfo( + val homeCount: RoomAggregateNotificationCount, + val otherCount: RoomAggregateNotificationCount +) + +class UnreadMessagesSharedViewModel @AssistedInject constructor(@Assisted initialState: UnreadMessagesState, + session: Session, + appStateHandler: AppStateHandler) + : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory { + fun create(initialState: UnreadMessagesState): UnreadMessagesSharedViewModel + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: UnreadMessagesState): UnreadMessagesSharedViewModel? { + 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") + } + } + + override fun handle(action: EmptyAction) {} + + init { + + session.getPagedRoomSummariesLive( + roomSummaryQueryParams { + this.memberships = listOf(Membership.JOIN) + this.activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(null) + }, sortOrder = RoomSortOrder.NONE + ).asObservable() + .throttleFirst(300, TimeUnit.MILLISECONDS) + .execute { + val counts = session.getNotificationCountForRooms( + roomSummaryQueryParams { + this.memberships = listOf(Membership.JOIN) + this.activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(null) + } + ) + val invites = session.getRoomSummaries( + roomSummaryQueryParams { + this.memberships = listOf(Membership.INVITE) + this.activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(null) + } + ).size + copy( + homeSpaceUnread = RoomAggregateNotificationCount( + counts.notificationCount + invites, + highlightCount = counts.highlightCount + invites + ) + ) + } + + Observable.combineLatest( + appStateHandler.selectedRoomGroupingObservable.distinctUntilChanged(), + appStateHandler.selectedRoomGroupingObservable.switchMap { + session.getPagedRoomSummariesLive( + roomSummaryQueryParams { + this.memberships = Membership.activeMemberships() + }, sortOrder = RoomSortOrder.NONE + ).asObservable() + .throttleFirst(300, TimeUnit.MILLISECONDS) + .observeOn(Schedulers.computation()) + }, + { groupingMethod, _ -> + when (groupingMethod.orNull()) { + is RoomGroupingMethod.ByLegacyGroup -> { + // currently not supported + CountInfo( + RoomAggregateNotificationCount(0, 0), + RoomAggregateNotificationCount(0, 0) + ) + } + is RoomGroupingMethod.BySpace -> { + val selectedSpace = appStateHandler.safeActiveSpaceId() + val counts = session.getNotificationCountForRooms( + roomSummaryQueryParams { + this.memberships = listOf(Membership.JOIN) + this.activeSpaceFilter = ActiveSpaceFilter.ActiveSpace(null) + } + ) + val rootCounts = session.spaceService().getRootSpaceSummaries() + .filter { + // filter out current selection + it.roomId != selectedSpace + } + CountInfo( + homeCount = counts, + otherCount = RoomAggregateNotificationCount( + rootCounts.fold(0, { acc, rs -> + acc + rs.notificationCount + }) + (counts.notificationCount.takeIf { selectedSpace != null } ?: 0), + rootCounts.fold(0, { acc, rs -> + acc + rs.highlightCount + }) + (counts.highlightCount.takeIf { selectedSpace != null } ?: 0) + ) + ) + } + null -> { + CountInfo( + RoomAggregateNotificationCount(0, 0), + RoomAggregateNotificationCount(0, 0) + ) + } + } + } + ).execute { + copy( + homeSpaceUnread = it.invoke()?.homeCount ?: RoomAggregateNotificationCount(0, 0), + otherSpacesUnread = it.invoke()?.otherCount ?: RoomAggregateNotificationCount(0, 0) + ) + } + } +} 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 906d81bc25..640e9a62ff 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 @@ -69,7 +69,6 @@ class RoomDetailActivity : } override fun injectWith(injector: ScreenComponent) { - super.injectWith(injector) injector.inject(this) } 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 b7e2e189d3..cabd69ecf9 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 @@ -91,17 +91,16 @@ import im.vector.app.core.intent.getMimeTypeFromUri import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.showOptimizedSnackbar import im.vector.app.core.resources.ColorProvider -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.FailedMessagesWarningView +import im.vector.app.core.ui.views.CurrentCallsView import im.vector.app.core.ui.views.JumpToReadMarkerView +import im.vector.app.core.ui.views.KnownCallsViewHolder +import im.vector.app.core.ui.views.FailedMessagesWarningView import im.vector.app.core.ui.views.NotificationAreaView import im.vector.app.core.utils.Debouncer import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.KeyboardStateUtils import im.vector.app.core.utils.PERMISSIONS_FOR_WRITING_FILES -import im.vector.app.core.utils.TextUtils import im.vector.app.core.utils.checkPermissions import im.vector.app.core.utils.colorizeMatchingText import im.vector.app.core.utils.copyToClipboard @@ -113,6 +112,7 @@ import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.core.utils.saveMedia import im.vector.app.core.utils.shareMedia import im.vector.app.core.utils.shareText +import im.vector.app.core.utils.startInstallFromSourceIntent import im.vector.app.core.utils.toast import im.vector.app.databinding.DialogReportContentBinding import im.vector.app.databinding.FragmentRoomDetailBinding @@ -160,9 +160,11 @@ import im.vector.app.features.permalink.NavigationInterceptor import im.vector.app.features.permalink.PermalinkHandler import im.vector.app.features.reactions.EmojiReactionPickerActivity import im.vector.app.features.roomprofile.RoomProfileActivity +import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity import im.vector.app.features.share.SharedData +import im.vector.app.features.spaces.ShareSpaceBottomSheet import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.widgets.WidgetActivity import im.vector.app.features.widgets.WidgetArgs @@ -197,6 +199,7 @@ import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.api.session.widgets.model.WidgetType import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode @@ -210,7 +213,8 @@ import javax.inject.Inject data class RoomDetailArgs( val roomId: String, val eventId: String? = null, - val sharedData: SharedData? = null + val sharedData: SharedData? = null, + val openShareSpaceForId: String? = null ) : Parcelable class RoomDetailFragment @Inject constructor( @@ -293,7 +297,7 @@ class RoomDetailFragment @Inject constructor( private lateinit var attachmentsHelper: AttachmentsHelper private lateinit var keyboardStateUtils: KeyboardStateUtils - private lateinit var callActionsHandler : StartCallActionsHandler + private lateinit var callActionsHandler: StartCallActionsHandler private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView @@ -376,7 +380,6 @@ class RoomDetailFragment @Inject constructor( is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it) is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message) is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it) - is RoomDetailViewEvents.FileTooBigError -> displayFileTooBigError(it) is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it) is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(it) @@ -407,6 +410,7 @@ class RoomDetailFragment @Inject constructor( if (savedInstanceState == null) { handleShareData() + handleSpaceShare() } } @@ -419,7 +423,7 @@ class RoomDetailFragment @Inject constructor( startActivity(intent) } - private fun handleChatEffect(chatEffect: ChatEffect) { + private fun handleChatEffect(chatEffect: ChatEffect) { when (chatEffect) { ChatEffect.CONFETTI -> { views.viewKonfetti.isVisible = true @@ -589,20 +593,53 @@ class RoomDetailFragment @Inject constructor( } private fun startOpenFileIntent(action: RoomDetailViewEvents.OpenFile) { - if (action.uri != null) { - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndTypeAndNormalize(action.uri, action.mimeType) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - } - - if (intent.resolveActivity(requireActivity().packageManager) != null) { - requireActivity().startActivity(intent) - } else { - requireActivity().toast(R.string.error_no_external_application_found) - } + if (action.mimeType == MimeTypes.Apk) { + installApk(action) + } else { + openFile(action) } } + private fun openFile(action: RoomDetailViewEvents.OpenFile) { + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndTypeAndNormalize(action.uri, action.mimeType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + + if (intent.resolveActivity(requireActivity().packageManager) != null) { + requireActivity().startActivity(intent) + } else { + requireActivity().toast(R.string.error_no_external_application_found) + } + } + + private fun installApk(action: RoomDetailViewEvents.OpenFile) { + val safeContext = context ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!safeContext.packageManager.canRequestPackageInstalls()) { + roomDetailViewModel.pendingEvent = action + startInstallFromSourceIntent(safeContext, installApkActivityResultLauncher) + } else { + openFile(action) + } + } else { + openFile(action) + } + } + + private val installApkActivityResultLauncher = registerStartForActivityResult { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + roomDetailViewModel.pendingEvent?.let { + if (it is RoomDetailViewEvents.OpenFile) { + openFile(it) + } + } + } else { + // User cancelled + } + roomDetailViewModel.pendingEvent = null + } + private fun displayPromptForIntegrationManager() { // The Sticker picker widget is not installed yet. Propose the user to install it val builder = AlertDialog.Builder(requireContext()) @@ -638,6 +675,15 @@ class RoomDetailFragment @Inject constructor( }.exhaustive } + private fun handleSpaceShare() { + roomDetailArgs.openShareSpaceForId?.let { spaceId -> + ShareSpaceBottomSheet.show(childFragmentManager, spaceId) + view?.post { + handleChatEffect(ChatEffect.CONFETTI) + } + } + } + override fun onDestroyView() { timelineEventController.callback = null timelineEventController.removeModelBuildListener(modelBuildListener) @@ -701,18 +747,6 @@ class RoomDetailFragment @Inject constructor( } } - private fun displayFileTooBigError(action: RoomDetailViewEvents.FileTooBigError) { - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.dialog_title_error) - .setMessage(getString(R.string.error_file_too_big, - action.filename, - TextUtils.formatFileSize(requireContext(), action.fileSizeInBytes), - TextUtils.formatFileSize(requireContext(), action.homeServerLimitInBytes) - )) - .setPositiveButton(R.string.ok, null) - .show() - } - private fun handleDownloadFileState(action: RoomDetailViewEvents.DownloadFileState) { val activity = requireActivity() if (action.throwable != null) { @@ -803,7 +837,7 @@ class RoomDetailFragment @Inject constructor( roomDetailViewModel.handle(RoomDetailAction.ManageIntegrations) true } - R.id.voice_call -> { + R.id.voice_call -> { callActionsHandler.onVoiceCallClicked() true } @@ -938,7 +972,7 @@ class RoomDetailFragment @Inject constructor( private val attachmentFileActivityResultLauncher = registerStartForActivityResult { if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onImageResult(it.data) + attachmentsHelper.onFileResult(it.data) } } @@ -954,15 +988,21 @@ class RoomDetailFragment @Inject constructor( } } - private val attachmentImageActivityResultLauncher = registerStartForActivityResult { + private val attachmentMediaActivityResultLauncher = registerStartForActivityResult { if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onImageResult(it.data) + attachmentsHelper.onMediaResult(it.data) } } - private val attachmentPhotoActivityResultLauncher = registerStartForActivityResult { + private val attachmentCameraActivityResultLauncher = registerStartForActivityResult { if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onPhotoResult() + attachmentsHelper.onCameraResult() + } + } + + private val attachmentCameraVideoActivityResultLauncher = registerStartForActivityResult { + if (it.resultCode == Activity.RESULT_OK) { + attachmentsHelper.onCameraVideoResult() } } @@ -1431,7 +1471,7 @@ class RoomDetailFragment @Inject constructor( override fun onUrlClicked(url: String, title: String): Boolean { permalinkHandler .launch(requireActivity(), url, object : NavigationInterceptor { - override fun navToRoom(roomId: String?, eventId: String?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { // Same room? if (roomId == roomDetailArgs.roomId) { // Navigation to same room @@ -1639,7 +1679,7 @@ class RoomDetailFragment @Inject constructor( override fun onRoomCreateLinkClicked(url: String) { permalinkHandler .launch(requireContext(), url, object : NavigationInterceptor { - override fun navToRoom(roomId: String?, eventId: String?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { requireActivity().finish() return false } @@ -1702,7 +1742,7 @@ class RoomDetailFragment @Inject constructor( sharedActionViewModel.pendingAction = action return } - lifecycleScope.launch { + session.coroutineScope.launch { val result = runCatching { session.fileService().downloadFile(messageContent = action.messageContent) } if (!isAdded) return@launch result.fold( @@ -1955,9 +1995,14 @@ class RoomDetailFragment @Inject constructor( private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) { when (type) { - AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera(requireContext(), attachmentPhotoActivityResultLauncher) + AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera( + activity = requireActivity(), + vectorPreferences = vectorPreferences, + cameraActivityResultLauncher = attachmentCameraActivityResultLauncher, + cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher + ) AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) - AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentImageActivityResultLauncher) + AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio(attachmentAudioActivityResultLauncher) AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) AttachmentTypeSelectorView.Type.STICKER -> roomDetailViewModel.handle(RoomDetailAction.SelectStickerAttachment) 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 9f801e7272..4d1e62da7e 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 @@ -54,12 +54,6 @@ sealed class RoomDetailViewEvents : VectorViewEvents { object ShowWaitingView : RoomDetailViewEvents() object HideWaitingView : RoomDetailViewEvents() - data class FileTooBigError( - val filename: String, - val fileSizeInBytes: Long, - val homeServerLimitInBytes: Long - ) : RoomDetailViewEvents() - data class DownloadFileState( val mimeType: String?, val file: File?, @@ -67,9 +61,8 @@ sealed class RoomDetailViewEvents : VectorViewEvents { ) : RoomDetailViewEvents() data class OpenFile( - val mimeType: String?, - val uri: Uri?, - val throwable: Throwable? + val uri: Uri, + val mimeType: String? ) : RoomDetailViewEvents() abstract class SendMessageResult : 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 ce53c72376..52779c863d 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 @@ -35,14 +35,15 @@ import dagger.assisted.AssistedInject import im.vector.app.BuildConfig import im.vector.app.R import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.mvrx.runCatchingToAsync import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider 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.crypto.keysrequest.OutboundSessionKeySharingStrategy import im.vector.app.features.createdirect.DirectRoomHelper +import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy 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 @@ -52,13 +53,13 @@ import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.home.room.typing.TypingHelper import im.vector.app.features.powerlevel.PowerLevelsObservableFactory import im.vector.app.features.raw.wellknown.getElementWellknown +import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorLocale import im.vector.app.features.settings.VectorPreferences import io.reactivex.Observable import io.reactivex.rxkotlin.subscribeBy import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.commonmark.parser.Parser @@ -77,7 +78,7 @@ import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isTextMessage import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel -import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities +import org.matrix.android.sdk.api.session.file.FileService import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams import org.matrix.android.sdk.api.session.room.model.Membership @@ -96,6 +97,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.getRelationContent import org.matrix.android.sdk.api.session.room.timeline.getTextEditableContent +import org.matrix.android.sdk.api.session.space.CreateSpaceParams import org.matrix.android.sdk.api.session.widgets.model.WidgetType import org.matrix.android.sdk.api.util.appendParamToUrl import org.matrix.android.sdk.api.util.toOptional @@ -139,6 +141,9 @@ class RoomDetailViewModel @AssistedInject constructor( // Slot to keep a pending action during permission request var pendingAction: RoomDetailAction? = null + // Slot to keep a pending event during permission request + var pendingEvent: RoomDetailViewEvents? = null + private var trackUnreadMessages = AtomicBoolean(false) private var mostRecentDisplayedEvent: TimelineEvent? = null @@ -177,18 +182,12 @@ class RoomDetailViewModel @AssistedInject constructor( observePowerLevel() updateShowDialerOptionState() room.getRoomSummaryLive() - viewModelScope.launch { - try { - room.markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT) - } catch (_: Exception) { - } + viewModelScope.launch(Dispatchers.IO) { + tryOrNull { room.markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT) } } // Inform the SDK that the room is displayed - viewModelScope.launch { - try { - session.onRoomDisplayed(initialState.roomId) - } catch (_: Exception) { - } + viewModelScope.launch(Dispatchers.IO) { + tryOrNull { session.onRoomDisplayed(initialState.roomId) } } callManager.addPstnSupportListener(this) callManager.checkForPSTNSupportIfNeeded() @@ -268,68 +267,67 @@ class RoomDetailViewModel @AssistedInject constructor( override fun handle(action: RoomDetailAction) { when (action) { - is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action) - is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(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.ComposerFocusChange -> handleComposerFocusChange(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.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.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 -> { + 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) - RoomDetailAction.RemoveAllFailedMessages -> handleRemoveAllFailedMessages() - RoomDetailAction.ResendAll -> handleResendAll() + is RoomDetailAction.DoNotShowPreviewUrlFor -> handleDoNotShowPreviewUrlFor(action) + RoomDetailAction.RemoveAllFailedMessages -> handleRemoveAllFailedMessages() + RoomDetailAction.ResendAll -> handleResendAll() }.exhaustive } @@ -550,11 +548,8 @@ class RoomDetailViewModel @AssistedInject constructor( private fun stopTrackingUnreadMessages() { if (trackUnreadMessages.getAndSet(false)) { mostRecentDisplayedEvent?.root?.eventId?.also { - viewModelScope.launch { - try { - room.setReadMarker(it) - } catch (_: Exception) { - } + session.coroutineScope.launch { + tryOrNull { room.setReadMarker(it) } } } mostRecentDisplayedEvent = null @@ -574,7 +569,7 @@ class RoomDetailViewModel @AssistedInject constructor( * Convert a send mode to a draft and save the draft */ private fun handleSaveDraft(action: RoomDetailAction.SaveDraft) = withState { - viewModelScope.launch(NonCancellable) { + session.coroutineScope.launch { when { it.sendMode is SendMode.REGULAR && !it.sendMode.fromSharing -> { setState { copy(sendMode = it.sendMode.copy(action.draft)) } @@ -655,12 +650,15 @@ class RoomDetailViewModel @AssistedInject constructor( val viaServers = MatrixPatterns.extractServerNameFromId(action.event.senderId) ?.let { listOf(it) } .orEmpty() - session.rx() - .joinRoom(roomId, viaServers = viaServers) - .map { roomId } - .execute { - copy(tombstoneEventHandling = it) - } + viewModelScope.launch { + val result = runCatchingToAsync { + session.joinRoom(roomId, viaServers = viaServers) + roomId + } + setState { + copy(tombstoneEventHandling = result) + } + } } } @@ -672,13 +670,13 @@ class RoomDetailViewModel @AssistedInject constructor( } when (itemId) { R.id.timeline_setting -> true - R.id.invite -> state.canInvite + R.id.invite -> state.canInvite R.id.open_matrix_apps -> true R.id.voice_call, - 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() + 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 } } @@ -823,9 +821,70 @@ class RoomDetailViewModel @AssistedInject constructor( ) } } + is ParsedCommand.CreateSpace -> { + viewModelScope.launch(Dispatchers.IO) { + try { + val params = CreateSpaceParams().apply { + name = slashCommandResult.name + invitedUserIds.addAll(slashCommandResult.invitees) + } + val spaceId = session.spaceService().createSpace(params) + session.spaceService().getSpace(spaceId) + ?.addChildren( + state.roomId, + listOf(session.sessionParams.homeServerHost ?: ""), + null, + true + ) + } catch (failure: Throwable) { + _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) + } + } + _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.AddToSpace -> { + viewModelScope.launch(Dispatchers.IO) { + try { + session.spaceService().getSpace(slashCommandResult.spaceId) + ?.addChildren( + room.roomId, + listOf(session.sessionParams.homeServerHost ?: ""), + null, + false + ) + } catch (failure: Throwable) { + _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) + } + } + _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.JoinSpace -> { + viewModelScope.launch(Dispatchers.IO) { + try { + session.spaceService().joinSpace(slashCommandResult.spaceIdOrAlias) + } catch (failure: Throwable) { + _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) + } + } + _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.LeaveRoom -> { + viewModelScope.launch(Dispatchers.IO) { + try { + session.getRoom(slashCommandResult.roomId)?.leave(null) + } catch (failure: Throwable) { + _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) + } + } + _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) + popDraft() + } }.exhaustive } - is SendMode.EDIT -> { + is SendMode.EDIT -> { // is original event a reply? val inReplyTo = state.sendMode.timelineEvent.getRelationContent()?.inReplyTo?.eventId if (inReplyTo != null) { @@ -848,7 +907,7 @@ class RoomDetailViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.MessageSent) popDraft() } - is SendMode.QUOTE -> { + is SendMode.QUOTE -> { val messageContent = state.sendMode.timelineEvent.getLastMessageContent() val textMsg = messageContent?.body @@ -869,7 +928,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) @@ -1046,23 +1105,7 @@ class RoomDetailViewModel @AssistedInject constructor( } private fun handleSendMedia(action: RoomDetailAction.SendMedia) { - val attachments = action.attachments - val homeServerCapabilities = session.getHomeServerCapabilities() - val maxUploadFileSize = homeServerCapabilities.maxUploadFileSize - - if (maxUploadFileSize == HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN) { - // Unknown limitation - room.sendMedias(attachments, action.compressBeforeSending, emptySet()) - } else { - when (val tooBigFile = attachments.find { it.size > maxUploadFileSize }) { - null -> room.sendMedias(attachments, action.compressBeforeSending, emptySet()) - else -> _viewEvents.post(RoomDetailViewEvents.FileTooBigError( - tooBigFile.name ?: tooBigFile.queryUri.toString(), - tooBigFile.size, - maxUploadFileSize - )) - } - } + room.sendMedias(action.attachments, action.compressBeforeSending, emptySet()) } private fun handleEventVisible(action: RoomDetailAction.TimelineEventTurnsVisible) { @@ -1098,19 +1141,13 @@ class RoomDetailViewModel @AssistedInject constructor( private fun handleRejectInvite() { viewModelScope.launch { - try { - room.leave(null) - } catch (_: Exception) { - } + tryOrNull { room.leave(null) } } } private fun handleAcceptInvite() { viewModelScope.launch { - try { - room.join() - } catch (_: Exception) { - } + tryOrNull { room.join() } } } @@ -1140,35 +1177,40 @@ class RoomDetailViewModel @AssistedInject constructor( val mxcUrl = action.messageFileContent.getFileUrl() ?: return val isLocalSendingFile = action.senderId == session.myUserId && mxcUrl.startsWith("content://") - val isDownloaded = session.fileService().isFileInCache(action.messageFileContent) if (isLocalSendingFile) { tryOrNull { Uri.parse(mxcUrl) }?.let { _viewEvents.post(RoomDetailViewEvents.OpenFile( - action.messageFileContent.mimeType, it, - null - )) - } - } else if (isDownloaded) { - // we can open it - session.fileService().getTemporarySharableURI(action.messageFileContent)?.let { uri -> - _viewEvents.post(RoomDetailViewEvents.OpenFile( - action.messageFileContent.mimeType, - uri, - null + action.messageFileContent.mimeType )) } } else { viewModelScope.launch { - val result = runCatching { - session.fileService().downloadFile(messageContent = action.messageFileContent) + val fileState = session.fileService().fileState(action.messageFileContent) + var canOpen = fileState is FileService.FileState.InCache && fileState.decryptedFileInCache + if (!canOpen) { + // First download, or download and decrypt, or decrypt from cache + val result = runCatching { + session.fileService().downloadFile(messageContent = action.messageFileContent) + } + + _viewEvents.post(RoomDetailViewEvents.DownloadFileState( + action.messageFileContent.mimeType, + result.getOrNull(), + result.exceptionOrNull() + )) + canOpen = result.isSuccess } - _viewEvents.post(RoomDetailViewEvents.DownloadFileState( - action.messageFileContent.mimeType, - result.getOrNull(), - result.exceptionOrNull() - )) + if (canOpen) { + // We can now open the file + session.fileService().getTemporarySharableURI(action.messageFileContent)?.let { uri -> + _viewEvents.post(RoomDetailViewEvents.OpenFile( + uri, + action.messageFileContent.mimeType + )) + } + } } } } @@ -1258,8 +1300,8 @@ class RoomDetailViewModel @AssistedInject constructor( } } bufferedMostRecentDisplayedEvent.root.eventId?.let { eventId -> - viewModelScope.launch { - room.setReadReceipt(eventId) + session.coroutineScope.launch { + tryOrNull { room.setReadReceipt(eventId) } } } }) @@ -1268,10 +1310,7 @@ class RoomDetailViewModel @AssistedInject constructor( private fun handleMarkAllAsRead() { viewModelScope.launch { - try { - room.markAsRead(ReadService.MarkAsReadParams.BOTH) - } catch (_: Exception) { - } + tryOrNull { room.markAsRead(ReadService.MarkAsReadParams.BOTH) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchActivity.kt index 94bb71243a..d9a2f98e32 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchActivity.kt @@ -39,7 +39,6 @@ class SearchActivity : VectorBaseActivity() { override fun getCoordinatorLayout() = views.coordinatorLayout override fun injectWith(injector: ScreenComponent) { - super.injectWith(injector) injector.inject(this) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewModel.kt index fb3abf002e..26111e4f2b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewModel.kt @@ -28,9 +28,9 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.search.SearchResult @@ -120,7 +120,7 @@ class SearchViewModel @AssistedInject constructor( ) onSearchResultSuccess(result) } catch (failure: Throwable) { - if (failure is Failure.Cancelled) return@launch + if (failure is CancellationException) return@launch _viewEvents.post(SearchViewEvents.Failure(failure)) setState { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt index 30587e6659..dcbc2c3293 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt @@ -28,16 +28,19 @@ import im.vector.app.core.epoxy.bottomsheet.bottomSheetMessagePreviewItem import im.vector.app.core.epoxy.bottomsheet.bottomSheetQuickReactionsItem import im.vector.app.core.epoxy.bottomsheet.bottomSheetSendStateItem import im.vector.app.core.epoxy.dividerItem +import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.resources.StringProvider import im.vector.app.core.utils.DimensionConverter 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.EventDetailsFormatter import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData import im.vector.app.features.home.room.detail.timeline.item.E2EDecoration import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod import im.vector.app.features.home.room.detail.timeline.tools.linkify import im.vector.app.features.media.ImageContentRenderer import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.session.room.send.SendState import javax.inject.Inject @@ -50,6 +53,8 @@ class MessageActionsEpoxyController @Inject constructor( private val fontProvider: EmojiCompatFontProvider, private val imageContentRenderer: ImageContentRenderer, private val dimensionConverter: DimensionConverter, + private val errorFormatter: ErrorFormatter, + private val eventDetailsFormatter: EventDetailsFormatter, private val dateFormatter: VectorDateFormatter ) : TypedEpoxyController() { @@ -68,16 +73,21 @@ class MessageActionsEpoxyController @Inject constructor( data(state.timelineEvent()?.buildImageContentRendererData(dimensionConverter.dpToPx(66))) userClicked { listener?.didSelectMenuAction(EventSharedAction.OpenUserProfile(state.informationData.senderId)) } body(state.messageBody.linkify(listener)) + bodyDetails(eventDetailsFormatter.format(state.timelineEvent()?.root)) time(formattedDate) } // Send state val sendState = state.sendState() if (sendState?.hasFailed().orFalse()) { + // Get more details about the error + val errorMessage = state.timelineEvent()?.root?.sendStateError() + ?.let { errorFormatter.toHumanReadable(Failure.ServerError(it, 0)) } + ?: stringProvider.getString(R.string.unable_to_send_message) bottomSheetSendStateItem { id("send_state") showProgress(false) - text(stringProvider.getString(R.string.unable_to_send_message)) + text(errorMessage) drawableStart(R.drawable.ic_warning_badge) } } else if (sendState?.isSending().orFalse()) { 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 b5d3102f46..b9c368ebdc 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 @@ -128,7 +128,8 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted setState { copy(actionPermissions = permissions) } - }.disposeOnClear() + } + .disposeOnClear() } private fun observeEvent() { @@ -393,7 +394,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted } private fun canReply(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean { - // Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment + // Only event of type EventType.MESSAGE are supported for the moment if (event.root.getClearType() != EventType.MESSAGE) return false if (!actionPermissions.canSendMessage) return false return when (messageContent?.msgType) { @@ -409,7 +410,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted } private fun canQuote(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean { - // Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment + // Only event of type EventType.MESSAGE are supported for the moment if (event.root.getClearType() != EventType.MESSAGE) return false if (!actionPermissions.canSendMessage) return false return when (messageContent?.msgType) { @@ -424,8 +425,8 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted } private fun canRedact(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean { - // Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment - if (event.root.getClearType() != EventType.MESSAGE) return false + // Only event of type EventType.MESSAGE or EventType.STICKER are supported for the moment + if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER)) return false // Message sent by the current user can always be redacted if (event.root.senderId == session.myUserId) return true // Check permission for messages sent by other users @@ -439,14 +440,13 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted } private fun canViewReactions(event: TimelineEvent): Boolean { - // Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment - if (event.root.getClearType() != EventType.MESSAGE) return false - // TODO if user is admin or moderator + // Only event of type EventType.MESSAGE and EventType.STICKER are supported for the moment + if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER)) return false return event.annotations?.reactionsSummary?.isNotEmpty() ?: false } private fun canEdit(event: TimelineEvent, myUserId: String, actionPermissions: ActionPermissions): Boolean { - // Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment + // Only event of type EventType.MESSAGE are supported for the moment if (event.root.getClearType() != EventType.MESSAGE) return false if (!actionPermissions.canSendMessage) return false // TODO if user is admin or moderator 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 0f214ffb13..63770e4538 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 @@ -85,6 +85,7 @@ import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_POLL import org.matrix.android.sdk.api.session.room.model.message.getFileName import org.matrix.android.sdk.api.session.room.model.message.getFileUrl +import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt @@ -337,8 +338,7 @@ class MessageItemFactory @Inject constructor( eventId = informationData.eventId, filename = messageContent.body, mimeType = messageContent.mimeType, - url = messageContent.videoInfo?.thumbnailFile?.url - ?: messageContent.videoInfo?.thumbnailUrl, + url = messageContent.videoInfo?.getThumbnailUrl(), elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(), height = messageContent.videoInfo?.height, maxHeight = maxHeight, 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 47bc60eb75..c21fe935bb 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 @@ -72,6 +72,8 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.CALL_SELECT_ANSWER, EventType.CALL_NEGOTIATE, EventType.REACTION, + EventType.STATE_SPACE_CHILD, + EventType.STATE_SPACE_PARENT, EventType.STATE_ROOM_POWER_LEVELS -> noticeItemFactory.create(params) EventType.STATE_ROOM_WIDGET_LEGACY, EventType.STATE_ROOM_WIDGET -> widgetItemFactory.create(params) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/EventDetailsFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/EventDetailsFormatter.kt new file mode 100644 index 0000000000..d5b2367c15 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/EventDetailsFormatter.kt @@ -0,0 +1,92 @@ +/* + * Copyright 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.format + +import android.content.Context +import im.vector.app.core.utils.TextUtils +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.isAudioMessage +import org.matrix.android.sdk.api.session.events.model.isFileMessage +import org.matrix.android.sdk.api.session.events.model.isImageMessage +import org.matrix.android.sdk.api.session.events.model.isVideoMessage +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent +import org.matrix.android.sdk.api.session.room.model.message.MessageFileContent +import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent +import org.threeten.bp.Duration +import javax.inject.Inject + +class EventDetailsFormatter @Inject constructor( + private val context: Context +) { + + fun format(event: Event?): CharSequence? { + event ?: return null + + if (event.isRedacted()) { + return null + } + + if (event.isEncrypted() && event.mxDecryptionResult == null) { + return null + } + + return when { + event.isImageMessage() -> formatForImageMessage(event) + event.isVideoMessage() -> formatForVideoMessage(event) + event.isAudioMessage() -> formatForAudioMessage(event) + event.isFileMessage() -> formatForFileMessage(event) + else -> null + } + } + + /** + * Example: "1024 x 720 - 670 kB" + */ + private fun formatForImageMessage(event: Event): CharSequence? { + return event.getClearContent().toModel()?.info + ?.let { "${it.width} x ${it.height} - ${it.size.asFileSize()}" } + } + + /** + * Example: "02:45 - 1024 x 720 - 670 kB" + */ + private fun formatForVideoMessage(event: Event): CharSequence? { + return event.getClearContent().toModel()?.videoInfo + ?.let { "${it.duration.asDuration()} - ${it.width} x ${it.height} - ${it.size.asFileSize()}" } + } + + /** + * Example: "02:45 - 670 kB" + */ + private fun formatForAudioMessage(event: Event): CharSequence? { + return event.getClearContent().toModel()?.audioInfo + ?.let { "${it.duration.asDuration()} - ${it.size.asFileSize()}" } + } + + /** + * Example: "670 kB - application/pdf" + */ + private fun formatForFileMessage(event: Event): CharSequence? { + return event.getClearContent().toModel()?.info + ?.let { "${it.size.asFileSize()} - ${it.mimeType}" } + } + + private fun Long.asFileSize() = TextUtils.formatFileSize(context, this) + private fun Int.asDuration() = TextUtils.formatDuration(Duration.ofMillis(toLong())) +} 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 878cec0a07..b1b96df9ea 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 @@ -107,6 +107,8 @@ class NoticeEventFormatter @Inject constructor( EventType.KEY_VERIFICATION_DONE, EventType.KEY_VERIFICATION_KEY, EventType.KEY_VERIFICATION_READY, + EventType.STATE_SPACE_CHILD, + EventType.STATE_SPACE_PARENT, EventType.REDACTION -> formatDebug(timelineEvent.root) else -> { Timber.v("Type $type not handled by this formatter") @@ -119,8 +121,8 @@ class NoticeEventFormatter @Inject constructor( val powerLevelsContent: PowerLevelsContent = event.getClearContent().toModel() ?: return null val previousPowerLevelsContent: PowerLevelsContent = event.resolvedPrevContent().toModel() ?: return null val userIds = HashSet() - userIds.addAll(powerLevelsContent.users.keys) - userIds.addAll(previousPowerLevelsContent.users.keys) + userIds.addAll(powerLevelsContent.users.orEmpty().keys) + userIds.addAll(previousPowerLevelsContent.users.orEmpty().keys) val diffs = ArrayList() userIds.forEach { userId -> val from = PowerLevelsHelper(previousPowerLevelsContent).getUserRole(userId) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt index 8216d36ac9..75570a67a0 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.timeline.helper +import android.annotation.SuppressLint import android.view.View import android.view.ViewGroup import android.widget.ProgressBar @@ -25,6 +26,7 @@ import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ScreenScope import im.vector.app.core.error.ErrorFormatter +import im.vector.app.core.extensions.exhaustive import im.vector.app.core.utils.TextUtils import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import org.matrix.android.sdk.api.session.content.ContentUploadStateTracker @@ -70,6 +72,9 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup, private val messageColorProvider: MessageColorProvider, private val errorFormatter: ErrorFormatter) : ContentUploadStateTracker.UpdateListener { + private val progressBar: ProgressBar = progressLayout.findViewById(R.id.mediaProgressBar) + private val progressTextView: TextView = progressLayout.findViewById(R.id.mediaProgressTextView) + override fun onUpdate(state: ContentUploadStateTracker.State) { when (state) { is ContentUploadStateTracker.State.Idle -> handleIdle() @@ -79,19 +84,19 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup, is ContentUploadStateTracker.State.Uploading -> handleProgress(state) is ContentUploadStateTracker.State.Failure -> handleFailure(/*state*/) is ContentUploadStateTracker.State.Success -> handleSuccess() - } + is ContentUploadStateTracker.State.CompressingImage -> handleCompressingImage() + is ContentUploadStateTracker.State.CompressingVideo -> handleCompressingVideo(state) + }.exhaustive } private fun handleIdle() { if (isLocalFile) { progressLayout.isVisible = true - val progressBar = progressLayout.findViewById(R.id.mediaProgressBar) - val progressTextView = progressLayout.findViewById(R.id.mediaProgressTextView) - progressBar?.isVisible = true - progressBar?.isIndeterminate = true - progressBar?.progress = 0 - progressTextView?.text = progressLayout.context.getString(R.string.send_file_step_idle) - progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.UNSENT)) + progressBar.isVisible = true + progressBar.isIndeterminate = true + progressBar.progress = 0 + progressTextView.text = progressLayout.context.getString(R.string.send_file_step_idle) + progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.UNSENT)) } else { progressLayout.isVisible = false } @@ -113,38 +118,54 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup, doHandleProgress(R.string.send_file_step_sending_file, state.current, state.total) } + private fun handleCompressingImage() { + progressLayout.visibility = View.VISIBLE + progressBar.isVisible = true + progressBar.isIndeterminate = true + progressTextView.isVisible = true + progressTextView.text = progressLayout.context.getString(R.string.send_file_step_compressing_image) + progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING)) + } + + // Add SuppressLint to fix a false positive + @SuppressLint("StringFormatMatches") + private fun handleCompressingVideo(state: ContentUploadStateTracker.State.CompressingVideo) { + progressLayout.visibility = View.VISIBLE + progressBar.isVisible = true + progressBar.isIndeterminate = false + progressBar.progress = state.percent.toInt() + progressTextView.isVisible = true + // False positive is here... + progressTextView.text = progressLayout.context.getString(R.string.send_file_step_compressing_video, state.percent.toInt()) + progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING)) + } + private fun doHandleEncrypting(resId: Int, current: Long, total: Long) { progressLayout.visibility = View.VISIBLE val percent = if (total > 0) (100L * (current.toFloat() / total.toFloat())) else 0f - val progressBar = progressLayout.findViewById(R.id.mediaProgressBar) - val progressTextView = progressLayout.findViewById(R.id.mediaProgressTextView) - progressBar?.isIndeterminate = false - progressBar?.progress = percent.toInt() + progressBar.isIndeterminate = false + progressBar.progress = percent.toInt() progressTextView.isVisible = true - progressTextView?.text = progressLayout.context.getString(resId) - progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.ENCRYPTING)) + progressTextView.text = progressLayout.context.getString(resId) + progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.ENCRYPTING)) } private fun doHandleProgress(resId: Int, current: Long, total: Long) { progressLayout.visibility = View.VISIBLE val percent = 100L * (current.toFloat() / total.toFloat()) - val progressBar = progressLayout.findViewById(R.id.mediaProgressBar) - val progressTextView = progressLayout.findViewById(R.id.mediaProgressTextView) - progressBar?.isVisible = true - progressBar?.isIndeterminate = false - progressBar?.progress = percent.toInt() + progressBar.isVisible = true + progressBar.isIndeterminate = false + progressBar.progress = percent.toInt() progressTextView.isVisible = true - progressTextView?.text = progressLayout.context.getString(resId, + progressTextView.text = progressLayout.context.getString(resId, TextUtils.formatFileSize(progressLayout.context, current, true), TextUtils.formatFileSize(progressLayout.context, total, true)) - progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING)) + progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING)) } private fun handleFailure(/*state: ContentUploadStateTracker.State.Failure*/) { progressLayout.visibility = View.VISIBLE - val progressBar = progressLayout.findViewById(R.id.mediaProgressBar) - val progressTextView = progressLayout.findViewById(R.id.mediaProgressTextView) - progressBar?.isVisible = false + progressBar.isVisible = false // Do not show the message it's too technical for users, and unfortunate when upload is cancelled // in the middle by turning airplane mode for example progressTextView.isVisible = false 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 14dd311265..124b196f72 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 @@ -39,6 +39,7 @@ import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited +import org.matrix.android.sdk.api.session.room.timeline.isEdition import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent import javax.inject.Inject @@ -66,9 +67,10 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses addDaySeparator || event.senderInfo.avatarUrl != nextEvent?.senderInfo?.avatarUrl || event.senderInfo.disambiguatedDisplayName != nextEvent?.senderInfo?.disambiguatedDisplayName - || (nextEvent.root.getClearType() != EventType.MESSAGE && nextEvent.root.getClearType() != EventType.ENCRYPTED) + || nextEvent.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER, EventType.ENCRYPTED) || isNextMessageReceivedMoreThanOneHourAgo || isTileTypeMessage(nextEvent) + || nextEvent.isEdition() val time = dateFormatter.format(event.root.originServerTs, DateFormatKind.MESSAGE_SIMPLE) val e2eDecoration = getE2EDecoration(event) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/image/ImageContentRendererFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/image/ImageContentRendererFactory.kt index 7ff184f664..2ad58df3b8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/image/ImageContentRendererFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/image/ImageContentRendererFactory.kt @@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent import org.matrix.android.sdk.api.session.room.model.message.getFileUrl +import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt @@ -45,15 +46,16 @@ fun TimelineEvent.buildImageContentRendererData(maxHeight: Int): ImageContentRen } root.isVideoMessage() -> root.getClearContent().toModel() ?.let { messageVideoContent -> + val videoInfo = messageVideoContent.videoInfo ImageContentRenderer.Data( eventId = eventId, filename = messageVideoContent.body, - mimeType = messageVideoContent.mimeType, - url = messageVideoContent.getFileUrl(), - elementToDecrypt = messageVideoContent.encryptedFileInfo?.toElementToDecrypt(), - height = messageVideoContent.videoInfo?.height, + mimeType = videoInfo?.thumbnailInfo?.mimeType, + url = videoInfo?.getThumbnailUrl(), + elementToDecrypt = videoInfo?.thumbnailFile?.toElementToDecrypt(), + height = videoInfo?.thumbnailInfo?.height, maxHeight = maxHeight, - width = messageVideoContent.videoInfo?.width, + width = videoInfo?.thumbnailInfo?.width, maxWidth = maxHeight * 2, allowNonMxcUrls = false ) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/CollapsableControllerExtension.kt b/vector/src/main/java/im/vector/app/features/home/room/list/CollapsableControllerExtension.kt new file mode 100644 index 0000000000..bd622faae1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/CollapsableControllerExtension.kt @@ -0,0 +1,32 @@ +/* + * 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.list + +import com.airbnb.epoxy.EpoxyController +import timber.log.Timber + +fun EpoxyController.setCollapsed(collapsed: Boolean) { + if (this is CollapsableControllerExtension) { + this.collapsed = collapsed + } else { + Timber.w("Try to collapse a controller that do not support collapse state") + } +} + +interface CollapsableControllerExtension { + var collapsed: Boolean +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/CollapsableTypedEpoxyController.kt b/vector/src/main/java/im/vector/app/features/home/room/list/CollapsableTypedEpoxyController.kt new file mode 100644 index 0000000000..19d718a3c7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/CollapsableTypedEpoxyController.kt @@ -0,0 +1,83 @@ +/* + * 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.list + +import com.airbnb.epoxy.EpoxyController + +abstract class CollapsableTypedEpoxyController + : EpoxyController(), CollapsableControllerExtension { + + private var currentData: T? = null + private var allowModelBuildRequests = false + + override var collapsed: Boolean = false + set(value) { + if (field != value) { + field = value + allowModelBuildRequests = true + requestModelBuild() + allowModelBuildRequests = false + } + } + + fun setData(data: T?) { + currentData = data + allowModelBuildRequests = true + requestModelBuild() + allowModelBuildRequests = false + } + + override fun requestModelBuild() { + check(allowModelBuildRequests) { + ("You cannot call `requestModelBuild` directly. Call `setData` instead to trigger a " + + "model refresh with new data.") + } + super.requestModelBuild() + } + + override fun moveModel(fromPosition: Int, toPosition: Int) { + allowModelBuildRequests = true + super.moveModel(fromPosition, toPosition) + allowModelBuildRequests = false + } + + override fun requestDelayedModelBuild(delayMs: Int) { + check(allowModelBuildRequests) { + ("You cannot call `requestModelBuild` directly. Call `setData` instead to trigger a " + + "model refresh with new data.") + } + super.requestDelayedModelBuild(delayMs) + } + + fun getCurrentData(): T? { + return currentData + } + + override fun buildModels() { + check(isBuildingModels()) { + ("You cannot call `buildModels` directly. Call `setData` instead to trigger a model " + + "refresh with new data.") + } + if (collapsed) { + buildModels(null) + } else { + buildModels(currentData) + } + } + + protected abstract fun buildModels(data: T?) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/GroupRoomListSectionBuilder.kt b/vector/src/main/java/im/vector/app/features/home/room/list/GroupRoomListSectionBuilder.kt new file mode 100644 index 0000000000..22b0eb091c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/GroupRoomListSectionBuilder.kt @@ -0,0 +1,262 @@ +/* + * 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.list + +import androidx.annotation.StringRes +import im.vector.app.AppStateHandler +import im.vector.app.R +import im.vector.app.RoomGroupingMethod +import im.vector.app.core.resources.StringProvider +import im.vector.app.features.home.RoomListDisplayMode +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.CoroutineScope +import org.matrix.android.sdk.api.query.RoomCategoryFilter +import org.matrix.android.sdk.api.query.RoomTagQueryFilter +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams +import org.matrix.android.sdk.api.session.room.UpdatableLivePageResult +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.rx.asObservable + +class GroupRoomListSectionBuilder( + val session: Session, + val stringProvider: StringProvider, + val viewModelScope: CoroutineScope, + val appStateHandler: AppStateHandler, + val onDisposable: (Disposable) -> Unit, + val onUdpatable: (UpdatableLivePageResult) -> Unit +) : RoomListSectionBuilder { + + override fun buildSections(mode: RoomListDisplayMode): List { + val activeGroupAwareQueries = mutableListOf() + val sections = mutableListOf() + val actualGroupId = appStateHandler.safeActiveGroupId() + + when (mode) { + RoomListDisplayMode.PEOPLE -> { + // 3 sections Invites / Fav / Dms + buildPeopleSections(sections, activeGroupAwareQueries, actualGroupId) + } + RoomListDisplayMode.ROOMS -> { + // 5 sections invites / Fav / Rooms / Low Priority / Server notice + buildRoomsSections(sections, activeGroupAwareQueries, actualGroupId) + } + RoomListDisplayMode.FILTERED -> { + // Used when searching for rooms + withQueryParams( + { + it.memberships = Membership.activeMemberships() + }, + { qpm -> + val name = stringProvider.getString(R.string.bottom_action_rooms) + session.getFilteredPagedRoomSummariesLive(qpm) + .let { updatableFilterLivePageResult -> + onUdpatable(updatableFilterLivePageResult) + sections.add(RoomsSection(name, updatableFilterLivePageResult.livePagedList)) + } + } + ) + } + RoomListDisplayMode.NOTIFICATIONS -> { + addSection( + sections, + activeGroupAwareQueries, + R.string.invitations_header, + true + ) { + it.memberships = listOf(Membership.INVITE) + it.roomCategoryFilter = RoomCategoryFilter.ALL + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeGroupAwareQueries, + R.string.bottom_action_rooms, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS + it.activeGroupId = actualGroupId + } + } + } + + appStateHandler.selectedRoomGroupingObservable + .distinctUntilChanged() + .subscribe { groupingMethod -> + val selectedGroupId = (groupingMethod.orNull() as? RoomGroupingMethod.ByLegacyGroup)?.groupSummary?.groupId + activeGroupAwareQueries.onEach { updater -> + updater.updateQuery { query -> + query.copy(activeGroupId = selectedGroupId) + } + } + }.also { + onDisposable.invoke(it) + } + return sections + } + + private fun buildRoomsSections(sections: MutableList, + activeSpaceAwareQueries: MutableList, + actualGroupId: String?) { + addSection( + sections, + activeSpaceAwareQueries, + R.string.invitations_header, + true + ) { + it.memberships = listOf(Membership.INVITE) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeSpaceAwareQueries, + R.string.bottom_action_favourites, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + it.roomTagQueryFilter = RoomTagQueryFilter(true, null, null) + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeSpaceAwareQueries, + R.string.bottom_action_rooms, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + it.roomTagQueryFilter = RoomTagQueryFilter(false, false, false) + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeSpaceAwareQueries, + R.string.low_priority_header, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + it.roomTagQueryFilter = RoomTagQueryFilter(null, true, null) + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeSpaceAwareQueries, + R.string.system_alerts_header, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + it.roomTagQueryFilter = RoomTagQueryFilter(null, null, true) + it.activeGroupId = actualGroupId + } + } + + private fun buildPeopleSections( + sections: MutableList, + activeSpaceAwareQueries: MutableList, + actualGroupId: String? + ) { + addSection(sections, + activeSpaceAwareQueries, + R.string.invitations_header, + true + ) { + it.memberships = listOf(Membership.INVITE) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeSpaceAwareQueries, + R.string.bottom_action_favourites, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM + it.roomTagQueryFilter = RoomTagQueryFilter(true, null, null) + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeSpaceAwareQueries, + R.string.bottom_action_people_x, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM + it.roomTagQueryFilter = RoomTagQueryFilter(false, null, null) + it.activeGroupId = actualGroupId + } + } + + private fun addSection(sections: MutableList, + activeSpaceUpdaters: MutableList, + @StringRes nameRes: Int, + notifyOfLocalEcho: Boolean = false, + query: (RoomSummaryQueryParams.Builder) -> Unit) { + withQueryParams( + { query.invoke(it) }, + { roomQueryParams -> + + val name = stringProvider.getString(nameRes) + session.getFilteredPagedRoomSummariesLive(roomQueryParams) + .also { + activeSpaceUpdaters.add(it) + }.livePagedList + .let { livePagedList -> + // use it also as a source to update count + livePagedList.asObservable() + .observeOn(Schedulers.computation()) + .subscribe { + sections.find { it.sectionName == name } + ?.notificationCount + ?.postValue(session.getNotificationCountForRooms(roomQueryParams)) + }.also { + onDisposable.invoke(it) + } + sections.add( + RoomsSection( + sectionName = name, + livePages = livePagedList, + notifyOfLocalEcho = notifyOfLocalEcho + ) + ) + } + } + + ) + } + + private fun withQueryParams(builder: (RoomSummaryQueryParams.Builder) -> Unit, block: (RoomSummaryQueryParams) -> Unit) { + RoomSummaryQueryParams.Builder() + .apply { builder.invoke(this) } + .build() + .let { block(it) } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListAction.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListAction.kt index 883efb2e60..37f7d148aa 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListAction.kt @@ -29,4 +29,5 @@ sealed class RoomListAction : VectorViewModelAction { data class ChangeRoomNotificationState(val roomId: String, val notificationState: RoomNotificationState) : RoomListAction() data class ToggleTag(val roomId: String, val tag: String) : RoomListAction() data class LeaveRoom(val roomId: String) : RoomListAction() + data class JoinSuggestedRoom(val roomId: String, val viaServers: List?) : RoomListAction() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt index aaa5bbcde5..76d7752ea7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt @@ -28,6 +28,7 @@ import androidx.core.view.isVisible import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.OnModelBuildFinishedListener import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel @@ -52,6 +53,7 @@ import im.vector.app.features.notifications.NotificationDrawerManager import kotlinx.parcelize.Parcelize import org.matrix.android.sdk.api.extensions.orTrue import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo import org.matrix.android.sdk.api.session.room.model.tag.RoomTag import org.matrix.android.sdk.api.session.room.notification.RoomNotificationState import javax.inject.Inject @@ -90,12 +92,12 @@ class RoomListFragment @Inject constructor( data class SectionAdapterInfo( var section: SectionKey, - val headerHeaderAdapter: SectionHeaderAdapter, - val contentAdapter: RoomSummaryPagedController + val sectionHeaderAdapter: SectionHeaderAdapter, + val contentEpoxyController: EpoxyController ) private val adapterInfosList = mutableListOf() - private var concatAdapter : ConcatAdapter? = null + private var concatAdapter: ConcatAdapter? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -106,10 +108,10 @@ class RoomListFragment @Inject constructor( sharedActionViewModel = activityViewModelProvider.get(RoomListQuickActionsSharedActionViewModel::class.java) roomListViewModel.observeViewEvents { when (it) { - is RoomListViewEvents.Loading -> showLoading(it.message) - is RoomListViewEvents.Failure -> showFailure(it.throwable) + is RoomListViewEvents.Loading -> showLoading(it.message) + is RoomListViewEvents.Failure -> showFailure(it.throwable) is RoomListViewEvents.SelectRoom -> handleSelectRoom(it) - is RoomListViewEvents.Done -> Unit + is RoomListViewEvents.Done -> Unit }.exhaustive } @@ -124,33 +126,27 @@ class RoomListFragment @Inject constructor( // it's for invites local echo adapterInfosList.filter { it.section.notifyOfLocalEcho } .onEach { - it.contentAdapter.roomChangeMembershipStates = ms + (it.contentEpoxyController as? RoomSummaryPagedController)?.roomChangeMembershipStates = ms } } } private fun refreshCollapseStates() { - var contentInsertIndex = 1 roomListViewModel.sections.forEachIndexed { index, roomsSection -> val actualBlock = adapterInfosList[index] val isRoomSectionExpanded = roomsSection.isExpanded.value.orTrue() if (actualBlock.section.isExpanded && !isRoomSectionExpanded) { - // we have to remove the content adapter - concatAdapter?.removeAdapter(actualBlock.contentAdapter.adapter) + // mark controller as collapsed + actualBlock.contentEpoxyController.setCollapsed(true) } else if (!actualBlock.section.isExpanded && isRoomSectionExpanded) { - // we must add it back! - concatAdapter?.addAdapter(contentInsertIndex, actualBlock.contentAdapter.adapter) - } - contentInsertIndex = if (isRoomSectionExpanded) { - contentInsertIndex + 2 - } else { - contentInsertIndex + 1 + // we must expand! + actualBlock.contentEpoxyController.setCollapsed(false) } actualBlock.section = actualBlock.section.copy( isExpanded = isRoomSectionExpanded ) - actualBlock.headerHeaderAdapter.updateSection( - actualBlock.headerHeaderAdapter.roomsSectionData.copy(isExpanded = isRoomSectionExpanded) + actualBlock.sectionHeaderAdapter.updateSection( + actualBlock.sectionHeaderAdapter.roomsSectionData.copy(isExpanded = isRoomSectionExpanded) ) } } @@ -160,7 +156,7 @@ class RoomListFragment @Inject constructor( } override fun onDestroyView() { - adapterInfosList.onEach { it.contentAdapter.removeModelBuildListener(modelBuildListener) } + adapterInfosList.onEach { it.contentEpoxyController.removeModelBuildListener(modelBuildListener) } adapterInfosList.clear() modelBuildListener = null views.roomListView.cleanup() @@ -179,8 +175,8 @@ class RoomListFragment @Inject constructor( private fun setupCreateRoomButton() { when (roomListParams.displayMode) { RoomListDisplayMode.NOTIFICATIONS -> views.createChatFabMenu.isVisible = true - RoomListDisplayMode.PEOPLE -> views.createChatRoomButton.isVisible = true - RoomListDisplayMode.ROOMS -> views.createGroupRoomButton.isVisible = true + RoomListDisplayMode.PEOPLE -> views.createChatRoomButton.isVisible = true + RoomListDisplayMode.ROOMS -> views.createGroupRoomButton.isVisible = true else -> Unit // No button in this mode } @@ -248,23 +244,70 @@ class RoomListFragment @Inject constructor( it.updateSection(SectionHeaderAdapter.RoomsSectionData(section.sectionName)) } - val contentAdapter = pagedControllerFactory.createRoomSummaryPagedController() - .also { controller -> - section.livePages.observe(viewLifecycleOwner) { pl -> - controller.submitList(pl) - sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy(isHidden = pl.isEmpty())) - checkEmptyState() + val contentAdapter = + when { + section.livePages != null -> { + pagedControllerFactory.createRoomSummaryPagedController() + .also { controller -> + section.livePages.observe(viewLifecycleOwner) { pl -> + controller.submitList(pl) + sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy( + isHidden = pl.isEmpty(), + isLoading = false + )) + checkEmptyState() + } + section.notificationCount.observe(viewLifecycleOwner) { counts -> + sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy( + notificationCount = counts.totalCount, + isHighlighted = counts.isHighlight + )) + } + section.isExpanded.observe(viewLifecycleOwner) { _ -> + refreshCollapseStates() + } + controller.listener = this + } } - section.notificationCount.observe(viewLifecycleOwner) { counts -> - sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy( - notificationCount = counts.totalCount, - isHighlighted = counts.isHighlight - )) + section.liveSuggested != null -> { + pagedControllerFactory.createSuggestedRoomListController() + .also { controller -> + section.liveSuggested.observe(viewLifecycleOwner) { info -> + controller.setData(info) + sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy( + isHidden = info.rooms.isEmpty(), + isLoading = false + )) + checkEmptyState() + } + section.isExpanded.observe(viewLifecycleOwner) { _ -> + refreshCollapseStates() + } + controller.listener = this + } } - section.isExpanded.observe(viewLifecycleOwner) { _ -> - refreshCollapseStates() + else -> { + pagedControllerFactory.createRoomSummaryListController() + .also { controller -> + section.liveList?.observe(viewLifecycleOwner) { list -> + controller.setData(list) + sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy( + isHidden = list.isEmpty(), + isLoading = false)) + checkEmptyState() + } + section.notificationCount.observe(viewLifecycleOwner) { counts -> + sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy( + notificationCount = counts.totalCount, + isHighlighted = counts.isHighlight + )) + } + section.isExpanded.observe(viewLifecycleOwner) { _ -> + refreshCollapseStates() + } + controller.listener = this + } } - controller.listener = this } adapterInfosList.add( SectionAdapterInfo( @@ -293,8 +336,8 @@ class RoomListFragment @Inject constructor( if (isAdded) { when (roomListParams.displayMode) { RoomListDisplayMode.NOTIFICATIONS -> views.createChatFabMenu.show() - RoomListDisplayMode.PEOPLE -> views.createChatRoomButton.show() - RoomListDisplayMode.ROOMS -> views.createGroupRoomButton.show() + RoomListDisplayMode.PEOPLE -> views.createChatRoomButton.show() + RoomListDisplayMode.ROOMS -> views.createGroupRoomButton.show() else -> Unit } } @@ -358,8 +401,9 @@ class RoomListFragment @Inject constructor( } private fun checkEmptyState() { - val hasNoRoom = adapterInfosList.all { it.headerHeaderAdapter.roomsSectionData.isHidden } - if (hasNoRoom) { + val shouldShowEmpty = adapterInfosList.all { it.sectionHeaderAdapter.roomsSectionData.isHidden } + && !adapterInfosList.any { it.sectionHeaderAdapter.roomsSectionData.isLoading } + if (shouldShowEmpty) { val emptyState = when (roomListParams.displayMode) { RoomListDisplayMode.NOTIFICATIONS -> { StateView.State.Empty( @@ -367,14 +411,14 @@ class RoomListFragment @Inject constructor( image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_noun_party_popper), message = getString(R.string.room_list_catchup_empty_body)) } - RoomListDisplayMode.PEOPLE -> + RoomListDisplayMode.PEOPLE -> StateView.State.Empty( title = getString(R.string.room_list_people_empty_title), image = ContextCompat.getDrawable(requireContext(), R.drawable.empty_state_dm), isBigImage = true, message = getString(R.string.room_list_people_empty_body) ) - RoomListDisplayMode.ROOMS -> + RoomListDisplayMode.ROOMS -> StateView.State.Empty( title = getString(R.string.room_list_rooms_empty_title), image = ContextCompat.getDrawable(requireContext(), R.drawable.empty_state_room), @@ -387,7 +431,12 @@ class RoomListFragment @Inject constructor( } views.stateView.state = emptyState } else { - views.stateView.state = StateView.State.Content + // is there something to show already? + if (adapterInfosList.any { !it.sectionHeaderAdapter.roomsSectionData.isHidden }) { + views.stateView.state = StateView.State.Content + } else { + views.stateView.state = StateView.State.Loading + } } } @@ -421,6 +470,10 @@ class RoomListFragment @Inject constructor( roomListViewModel.handle(RoomListAction.AcceptInvitation(room)) } + override fun onJoinSuggestedRoom(room: SpaceChildInfo) { + roomListViewModel.handle(RoomListAction.JoinSuggestedRoom(room.childRoomId, room.viaServers)) + } + override fun onRejectRoomInvitation(room: RoomSummary) { notificationDrawerManager.clearMemberShipNotificationForRoom(room.roomId) roomListViewModel.handle(RoomListAction.RejectInvitation(room)) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListListener.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListListener.kt index e9833d1560..0ba265f841 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListListener.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListListener.kt @@ -18,10 +18,12 @@ package im.vector.app.features.home.room.list import im.vector.app.features.home.room.filtered.FilteredRoomFooterItem import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo interface RoomListListener : FilteredRoomFooterItem.FilteredRoomFooterItemListener { fun onRoomClicked(room: RoomSummary) fun onRoomLongClicked(room: RoomSummary): Boolean fun onRejectRoomInvitation(room: RoomSummary) fun onAcceptRoomInvitation(room: RoomSummary) + fun onJoinSuggestedRoom(room: SpaceChildInfo) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilder.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilder.kt new file mode 100644 index 0000000000..5267158000 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilder.kt @@ -0,0 +1,23 @@ +/* + * 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.list + +import im.vector.app.features.home.RoomListDisplayMode + +interface RoomListSectionBuilder { + fun buildSections(mode: RoomListDisplayMode) : List +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt index 423a950591..bc24705e13 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt @@ -16,32 +16,30 @@ package im.vector.app.features.home.room.list -import androidx.annotation.StringRes +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.Async +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 im.vector.app.R +import im.vector.app.AppStateHandler +import im.vector.app.RoomGroupingMethod 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.home.RoomListDisplayMode -import io.reactivex.schedulers.Schedulers +import im.vector.app.features.settings.VectorPreferences import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.query.QueryStringValue -import org.matrix.android.sdk.api.query.RoomCategoryFilter -import org.matrix.android.sdk.api.query.RoomTagQueryFilter import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams -import org.matrix.android.sdk.api.session.room.UpdatableFilterLivePageResult +import org.matrix.android.sdk.api.session.room.UpdatableLivePageResult import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState -import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.tag.RoomTag -import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams import org.matrix.android.sdk.api.session.room.state.isPublic -import org.matrix.android.sdk.rx.asObservable import org.matrix.android.sdk.rx.rx import timber.log.Timber import javax.inject.Inject @@ -49,17 +47,57 @@ import javax.inject.Inject class RoomListViewModel @Inject constructor( initialState: RoomListViewState, private val session: Session, - private val stringProvider: StringProvider + private val stringProvider: StringProvider, + private val appStateHandler: AppStateHandler, + private val vectorPreferences: VectorPreferences ) : VectorViewModel(initialState) { interface Factory { fun create(initialState: RoomListViewState): RoomListViewModel } - private var updatableQuery: UpdatableFilterLivePageResult? = null + private var updatableQuery: UpdatableLivePageResult? = null + + private val suggestedRoomJoiningState: MutableLiveData>> = MutableLiveData(emptyMap()) + + interface ActiveSpaceQueryUpdater { + fun updateForSpaceId(roomId: String?) + } + + enum class SpaceFilterStrategy { + /** + * Filter the rooms if they are part of the current space (children and grand children). + * If current space is null, will return orphan rooms only + */ + NORMAL, + /** + * Special case when we don't want to discriminate rooms when current space is null. + * In this case return all. + */ + NOT_IF_ALL, + /** Do not filter based on space*/ + NONE + } init { observeMembershipChanges() + + appStateHandler.selectedRoomGroupingObservable + .distinctUntilChanged() + .execute { + copy( + currentRoomGrouping = it.invoke()?.orNull()?.let { Success(it) } ?: Loading() + ) + } + + session.rx().liveUser(session.myUserId) + .map { it.getOrNull()?.getBestName() } + .distinctUntilChanged() + .execute { + copy( + currentUserName = it.invoke() ?: session.myUserId + ) + } } private fun observeMembershipChanges() { @@ -81,79 +119,34 @@ class RoomListViewModel @Inject constructor( } val sections: List by lazy { - val sections = mutableListOf() - if (initialState.displayMode == RoomListDisplayMode.PEOPLE) { - addSection(sections, R.string.invitations_header, true) { - it.memberships = listOf(Membership.INVITE) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM - } - - addSection(sections, R.string.bottom_action_favourites) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM - it.roomTagQueryFilter = RoomTagQueryFilter(true, null, null) - } - - addSection(sections, R.string.bottom_action_people_x) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM - } - } else if (initialState.displayMode == RoomListDisplayMode.ROOMS) { - addSection(sections, R.string.invitations_header, true) { - it.memberships = listOf(Membership.INVITE) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - } - - addSection(sections, R.string.bottom_action_favourites) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - it.roomTagQueryFilter = RoomTagQueryFilter(true, null, null) - } - - addSection(sections, R.string.bottom_action_rooms) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - it.roomTagQueryFilter = RoomTagQueryFilter(false, false, false) - } - - addSection(sections, R.string.low_priority_header) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - it.roomTagQueryFilter = RoomTagQueryFilter(null, true, null) - } - - addSection(sections, R.string.system_alerts_header) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - it.roomTagQueryFilter = RoomTagQueryFilter(null, null, true) - } - } else if (initialState.displayMode == RoomListDisplayMode.FILTERED) { - withQueryParams( + if (appStateHandler.getCurrentRoomGroupingMethod() is RoomGroupingMethod.BySpace) { + SpaceRoomListSectionBuilder( + session, + stringProvider, + appStateHandler, + viewModelScope, + suggestedRoomJoiningState, { - it.memberships = Membership.activeMemberships() + it.disposeOnClear() }, - { qpm -> - val name = stringProvider.getString(R.string.bottom_action_rooms) - session.getFilteredPagedRoomSummariesLive(qpm) - .let { updatableFilterLivePageResult -> - updatableQuery = updatableFilterLivePageResult - sections.add(RoomsSection(name, updatableFilterLivePageResult.livePagedList)) - } + { + updatableQuery = it } - ) - } else if (initialState.displayMode == RoomListDisplayMode.NOTIFICATIONS) { - addSection(sections, R.string.invitations_header, true) { - it.memberships = listOf(Membership.INVITE) - it.roomCategoryFilter = RoomCategoryFilter.ALL - } - - addSection(sections, R.string.bottom_action_rooms, true) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS - } + ).buildSections(initialState.displayMode) + } else { + GroupRoomListSectionBuilder( + session, + stringProvider, + viewModelScope, + appStateHandler, + { + it.disposeOnClear() + }, + { + updatableQuery = it + } + ).buildSections(initialState.displayMode) } - - sections } override fun handle(action: RoomListAction) { @@ -166,50 +159,10 @@ class RoomListViewModel @Inject constructor( is RoomListAction.ChangeRoomNotificationState -> handleChangeNotificationMode(action) is RoomListAction.ToggleTag -> handleToggleTag(action) is RoomListAction.ToggleSection -> handleToggleSection(action.section) + is RoomListAction.JoinSuggestedRoom -> handleJoinSuggestedRoom(action) }.exhaustive } - private fun addSection(sections: MutableList, - @StringRes nameRes: Int, - notifyOfLocalEcho: Boolean = false, - query: (RoomSummaryQueryParams.Builder) -> Unit) { - withQueryParams( - { query.invoke(it) }, - { roomQueryParams -> - - val name = stringProvider.getString(nameRes) - session.getPagedRoomSummariesLive(roomQueryParams) - .let { livePagedList -> - - // use it also as a source to update count - livePagedList.asObservable() - .observeOn(Schedulers.computation()) - .subscribe { - sections.find { it.sectionName == name } - ?.notificationCount - ?.postValue(session.getNotificationCountForRooms(roomQueryParams)) - } - .disposeOnClear() - - sections.add( - RoomsSection( - sectionName = name, - livePages = livePagedList, - notifyOfLocalEcho = notifyOfLocalEcho - ) - ) - } - } - ) - } - - private fun withQueryParams(builder: (RoomSummaryQueryParams.Builder) -> Unit, block: (RoomSummaryQueryParams) -> Unit) { - RoomSummaryQueryParams.Builder() - .apply { builder.invoke(this) } - .build() - .let { block(it) } - } - fun isPublicRoom(roomId: String): Boolean { return session.getRoom(roomId)?.isPublic().orFalse() } @@ -236,12 +189,11 @@ class RoomListViewModel @Inject constructor( roomFilter = action.filter ) } - updatableQuery?.updateQuery( - roomSummaryQueryParams { - memberships = Membership.activeMemberships() + updatableQuery?.updateQuery { + it.copy( displayName = QueryStringValue.Contains(action.filter, QueryStringValue.Case.INSENSITIVE) - } - ) + ) + } } private fun handleAcceptInvitation(action: RoomListAction.AcceptInvitation) = withState { state -> @@ -316,6 +268,26 @@ class RoomListViewModel @Inject constructor( } } + private fun handleJoinSuggestedRoom(action: RoomListAction.JoinSuggestedRoom) { + suggestedRoomJoiningState.postValue(suggestedRoomJoiningState.value.orEmpty().toMutableMap().apply { + this[action.roomId] = Loading() + }.toMap()) + + viewModelScope.launch { + try { + session.joinRoom(action.roomId, null, action.viaServers ?: emptyList()) + + suggestedRoomJoiningState.postValue(suggestedRoomJoiningState.value.orEmpty().toMutableMap().apply { + this[action.roomId] = Success(Unit) + }.toMap()) + } catch (failure: Throwable) { + suggestedRoomJoiningState.postValue(suggestedRoomJoiningState.value.orEmpty().toMutableMap().apply { + this[action.roomId] = Fail(failure) + }.toMap()) + } + } + } + private fun handleToggleTag(action: RoomListAction.ToggleTag) { session.getRoom(action.roomId)?.let { room -> viewModelScope.launch(Dispatchers.IO) { @@ -342,7 +314,7 @@ class RoomListViewModel @Inject constructor( private fun String.otherTag(): String? { return when (this) { - RoomTag.ROOM_TAG_FAVOURITE -> RoomTag.ROOM_TAG_LOW_PRIORITY + RoomTag.ROOM_TAG_FAVOURITE -> RoomTag.ROOM_TAG_LOW_PRIORITY RoomTag.ROOM_TAG_LOW_PRIORITY -> RoomTag.ROOM_TAG_FAVOURITE else -> null } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModelFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModelFactory.kt index d36bc45ab6..a30c175f41 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModelFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModelFactory.kt @@ -16,20 +16,26 @@ package im.vector.app.features.home.room.list +import im.vector.app.AppStateHandler import im.vector.app.core.resources.StringProvider +import im.vector.app.features.settings.VectorPreferences import org.matrix.android.sdk.api.session.Session import javax.inject.Inject import javax.inject.Provider class RoomListViewModelFactory @Inject constructor(private val session: Provider, - private val stringProvider: StringProvider) + private val appStateHandler: AppStateHandler, + private val stringProvider: StringProvider, + private val vectorPreferences: VectorPreferences) : RoomListViewModel.Factory { override fun create(initialState: RoomListViewState): RoomListViewModel { return RoomListViewModel( initialState, session.get(), - stringProvider + stringProvider, + appStateHandler, + vectorPreferences ) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewState.kt index 104a3710f7..68a8b9e515 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewState.kt @@ -16,14 +16,21 @@ package im.vector.app.features.home.room.list +import com.airbnb.mvrx.Async import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.Uninitialized +import im.vector.app.RoomGroupingMethod import im.vector.app.features.home.RoomListDisplayMode import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo data class RoomListViewState( val displayMode: RoomListDisplayMode, val roomFilter: String = "", - val roomMembershipChanges: Map = emptyMap() + val roomMembershipChanges: Map = emptyMap(), + val asyncSuggestedRooms: Async> = Uninitialized, + val currentUserName: String? = null, + val currentRoomGrouping: Async = Uninitialized ) : MvRxState { constructor(args: RoomListParams) : this(displayMode = args.displayMode) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt index fa6c970d8a..283ed0ac85 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt @@ -16,6 +16,9 @@ package im.vector.app.features.home.room.list +import android.view.View +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Loading import im.vector.app.R import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.VectorDateFormatter @@ -28,6 +31,7 @@ import im.vector.app.features.home.room.typing.TypingHelper import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject @@ -50,6 +54,20 @@ class RoomSummaryItemFactory @Inject constructor(private val displayableEventFor } } + fun createSuggestion(spaceChildInfo: SpaceChildInfo, + suggestedRoomJoiningStates: Map>, + onJoinClick: View.OnClickListener): VectorEpoxyModel<*> { + return SpaceChildInfoItem_() + .id("sug_${spaceChildInfo.childRoomId}") + .matrixItem(spaceChildInfo.toMatrixItem()) + .avatarRenderer(avatarRenderer) + .topic(spaceChildInfo.topic) + .buttonLabel(stringProvider.getString(R.string.join)) + .loading(suggestedRoomJoiningStates[spaceChildInfo.childRoomId] is Loading) + .memberCount(spaceChildInfo.activeMemberCount ?: 0) + .buttonClickListener(onJoinClick) + } + private fun createInvitationItem(roomSummary: RoomSummary, changeMembershipState: ChangeMembershipState, listener: RoomListListener?): VectorEpoxyModel<*> { diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemPlaceHolder.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemPlaceHolder.kt new file mode 100644 index 0000000000..96d8f6418b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemPlaceHolder.kt @@ -0,0 +1,27 @@ +/* + * Copyright 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.list + +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel + +@EpoxyModelClass(layout = R.layout.item_room_placeholder) +abstract class RoomSummaryItemPlaceHolder : VectorEpoxyModel() { + class Holder : VectorEpoxyHolder() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryListController.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryListController.kt new file mode 100644 index 0000000000..75aaee45cb --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryListController.kt @@ -0,0 +1,32 @@ +/* + * 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.list + +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +class RoomSummaryListController( + private val roomSummaryItemFactory: RoomSummaryItemFactory +) : CollapsableTypedEpoxyController>() { + + var listener: RoomListListener? = null + + override fun buildModels(data: List?) { + data?.forEach { + add(roomSummaryItemFactory.create(it, emptyMap(), emptySet(), listener)) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedController.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedController.kt index 20386d739a..e9cbc69215 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedController.kt @@ -21,23 +21,13 @@ import com.airbnb.epoxy.paging.PagedListEpoxyController import im.vector.app.core.utils.createUIHandler import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.RoomSummary -import javax.inject.Inject - -class RoomSummaryPagedControllerFactory @Inject constructor( - private val roomSummaryItemFactory: RoomSummaryItemFactory -) { - - fun createRoomSummaryPagedController(): RoomSummaryPagedController { - return RoomSummaryPagedController(roomSummaryItemFactory) - } -} class RoomSummaryPagedController( private val roomSummaryItemFactory: RoomSummaryItemFactory ) : PagedListEpoxyController( // Important it must match the PageList builder notify Looper modelBuildingHandler = createUIHandler() -) { +), CollapsableControllerExtension { var listener: RoomListListener? = null @@ -48,21 +38,25 @@ class RoomSummaryPagedController( requestForcedModelBuild() } + override var collapsed = false + set(value) { + if (field != value) { + field = value + requestForcedModelBuild() + } + } + + override fun addModels(models: List>) { + if (collapsed) { + super.addModels(emptyList()) + } else { + super.addModels(models) + } + } + override fun buildItemModel(currentPosition: Int, item: RoomSummary?): EpoxyModel<*> { // for place holder if enabled - item ?: return roomSummaryItemFactory.createRoomItem( - roomSummary = RoomSummary( - roomId = "null_item_pos_$currentPosition", - name = "", - encryptionEventTs = null, - isEncrypted = false, - typingUsers = emptyList() - ), - selectedRoomIds = emptySet(), - onClick = null, - onLongClick = null - ) - + item ?: return RoomSummaryItemPlaceHolder_().apply { id(currentPosition) } return roomSummaryItemFactory.create(item, roomChangeMembershipStates.orEmpty(), emptySet(), listener) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedControllerFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedControllerFactory.kt new file mode 100644 index 0000000000..c86d8ab243 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedControllerFactory.kt @@ -0,0 +1,36 @@ +/* + * 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.list + +import javax.inject.Inject + +class RoomSummaryPagedControllerFactory @Inject constructor( + private val roomSummaryItemFactory: RoomSummaryItemFactory +) { + + fun createRoomSummaryPagedController(): RoomSummaryPagedController { + return RoomSummaryPagedController(roomSummaryItemFactory) + } + + fun createRoomSummaryListController(): RoomSummaryListController { + return RoomSummaryListController(roomSummaryItemFactory) + } + + fun createSuggestedRoomListController(): SuggestedRoomListController { + return SuggestedRoomListController(roomSummaryItemFactory) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomsSection.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomsSection.kt index 71b7169814..5eaae262a6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomsSection.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomsSection.kt @@ -24,7 +24,10 @@ import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotification data class RoomsSection( val sectionName: String, - val livePages: LiveData>, + // can be a paged list or a regular list + val livePages: LiveData>? = null, + val liveList: LiveData>? = null, + val liveSuggested: LiveData? = null, val isExpanded: MutableLiveData = MutableLiveData(true), val notificationCount: MutableLiveData = MutableLiveData(RoomAggregateNotificationCount(0, 0)), val notifyOfLocalEcho: Boolean = false diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/SectionHeaderAdapter.kt b/vector/src/main/java/im/vector/app/features/home/room/list/SectionHeaderAdapter.kt index f9c5766821..6cddf72c5a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/SectionHeaderAdapter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/SectionHeaderAdapter.kt @@ -35,7 +35,9 @@ class SectionHeaderAdapter constructor( val isExpanded: Boolean = true, val notificationCount: Int = 0, val isHighlighted: Boolean = false, - val isHidden: Boolean = true + val isHidden: Boolean = true, + // This will be false until real data has been submitted once + val isLoading: Boolean = true ) lateinit var roomsSectionData: RoomsSectionData diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/SpaceChildInfoItem.kt b/vector/src/main/java/im/vector/app/features/home/room/list/SpaceChildInfoItem.kt new file mode 100644 index 0000000000..cb9c8b1f2e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/SpaceChildInfoItem.kt @@ -0,0 +1,126 @@ +/* + * 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.list + +import android.view.HapticFeedbackConstants +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.themes.ThemeUtils +import me.gujun.android.span.image +import me.gujun.android.span.span +import org.matrix.android.sdk.api.util.MatrixItem + +@EpoxyModelClass(layout = R.layout.item_suggested_room) +abstract class SpaceChildInfoItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer + @EpoxyAttribute lateinit var matrixItem: MatrixItem + + // Used only for diff calculation + @EpoxyAttribute var topic: String? = null + + @EpoxyAttribute var memberCount: Int = 0 + @EpoxyAttribute var loading: Boolean = false + @EpoxyAttribute var space: Boolean = false + + @EpoxyAttribute var buttonLabel: String? = null + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var itemLongClickListener: View.OnLongClickListener? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var itemClickListener: View.OnClickListener? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var buttonClickListener: View.OnClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.rootView.setOnClickListener(itemClickListener) + holder.rootView.setOnLongClickListener { + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + itemLongClickListener?.onLongClick(it) ?: false + } + holder.titleView.text = matrixItem.getBestName() + if (space) { + avatarRenderer.renderSpace(matrixItem, holder.avatarImageView) + } else { + avatarRenderer.render(matrixItem, holder.avatarImageView) + } + + holder.descriptionText.text = span { + span { + apply { + val tintColor = ThemeUtils.getColor(holder.view.context, R.attr.riotx_text_secondary) + ContextCompat.getDrawable(holder.view.context, R.drawable.ic_member_small) + ?.apply { + ThemeUtils.tintDrawableWithColor(this, tintColor) + }?.let { + image(it) + } + } + +" $memberCount" + apply { + topic?.let { + +" - $topic" + } + } + } + } + + holder.joinButton.text = buttonLabel + + if (loading) { + holder.joinButtonLoading.isVisible = true + holder.joinButton.isInvisible = true + } else { + holder.joinButtonLoading.isVisible = false + holder.joinButton.isVisible = true + } + + holder.joinButton.setOnClickListener { + // local echo + holder.joinButton.isEnabled = false + holder.view.postDelayed({ holder.joinButton.isEnabled = true }, 400) + buttonClickListener?.onClick(it) + } + } + + override fun unbind(holder: Holder) { + holder.rootView.setOnClickListener(null) + holder.rootView.setOnLongClickListener(null) + avatarRenderer.clear(holder.avatarImageView) + super.unbind(holder) + } + + class Holder : VectorEpoxyHolder() { + val titleView by bind(R.id.roomNameView) + val joinButton by bind